Introduction
In the NUR (Nix User Repository) project, a critical security gap was discovered in scripts/generate_pages.py at line 68, where the build script fetches external content from GitHub's raw URLs to generate static documentation pages. The download_readme() and download_repo_urls() functions were making HTTP requests using Python's requests library without implementing essential hardening measures: no timeout controls, no explicit status code validation, and no response integrity verification.
This matters because generate_pages.py is part of the build pipeline that generates the public-facing NUR documentation website. If an attacker could intercept or manipulate these HTTP responses—through DNS poisoning, BGP hijacking, or a compromised certificate authority—they could inject malicious content directly into the documentation that gets served to thousands of Nix users.
The Vulnerability Explained
Let's examine the vulnerable code in scripts/generate_pages.py:
def download_readme():
url = "https://raw.githubusercontent.com/nix-community/NUR/main/README.md"
r = requests.get(url) # ❌ No timeout, no status validation
with open("content/documentation/_index.md", 'wb') as f:
# ... writes response directly to documentation file
And similarly in the repository manifest fetcher:
def download_repo_urls() -> Dict[str, str]:
url = "https://raw.githubusercontent.com/nix-community/NUR/main/repos.json"
manifest = requests.get(url).json() # ❌ No timeout, no status check
return {name: repo["url"] for name, repo in manifest["repos"].items()}
The specific problems:
-
No timeout parameter: A malicious or compromised server could hold the connection open indefinitely, causing the build process to hang. This is a denial-of-service vector.
-
Missing status validation: If GitHub returns a 404, 500, or 403 error, the code would still attempt to process the response body, potentially writing error HTML into the documentation files.
-
No explicit TLS verification: While
requestsdefaults toverify=True, making it explicit is a security best practice that prevents accidental disabling in future modifications. -
No integrity checks: The script blindly trusts that the content from
raw.githubusercontent.comis legitimate, without verifying checksums or signatures.
Attack Scenario:
Imagine an attacker who has compromised a certificate authority or can perform BGP hijacking (as seen in real-world incidents). They could:
- Intercept the HTTPS connection to
raw.githubusercontent.com - Present a valid certificate (obtained through CA compromise or DNS manipulation)
- Serve a malicious README.md containing XSS payloads or phishing links
- The build script writes this malicious content to
content/documentation/_index.md - The compromised documentation gets deployed to the NUR website
- Thousands of Nix users visit the site and are exposed to the attack
While this requires sophisticated capabilities, the fix is trivial and eliminates an entire class of attacks.
The Fix
The security patch adds three critical hardening measures to both HTTP request sites. Here's the before-and-after comparison:
Before (vulnerable):
def download_readme():
url = "https://raw.githubusercontent.com/nix-community/NUR/main/README.md"
r = requests.get(url) # ❌ Vulnerable
with open("content/documentation/_index.md", 'wb') as f:
# ...
After (hardened):
def download_readme():
url = "https://raw.githubusercontent.com/nix-community/NUR/main/README.md"
r = requests.get(url, timeout=30, verify=True) # ✅ Timeout + explicit TLS
r.raise_for_status() # ✅ Validate HTTP status code
with open("content/documentation/_index.md", 'wb') as f:
# ...
The same pattern was applied to download_repo_urls():
Before:
manifest = requests.get(url).json()
After:
r = requests.get(url, timeout=30, verify=True)
r.raise_for_status()
manifest = r.json()
How each change improves security:
-
timeout=30: Prevents indefinite hangs. If the server doesn't respond within 30 seconds, the request fails cleanly. This protects against slowloris-style attacks and network issues. -
verify=True: Makes TLS certificate verification explicit. While this is the default, making it explicit serves as documentation and prevents accidental disabling (e.g., someone addingverify=Falseduring debugging and forgetting to remove it). -
r.raise_for_status(): Raises an HTTPError for 4xx/5xx responses. This ensures the script fails fast if GitHub returns an error, rather than attempting to parse error HTML as markdown or JSON.
These three lines transform the code from "optimistically trusting" to "defensively validating." The build pipeline now fails safely when something goes wrong, rather than silently incorporating potentially malicious content.
Prevention & Best Practices
To prevent unvalidated external content fetching vulnerabilities in your Python projects:
1. Always Use Timeouts
Never make an HTTP request without a timeout. The default is no timeout, which can hang indefinitely:
# ❌ Bad
response = requests.get(url)
# ✅ Good
response = requests.get(url, timeout=30)
2. Validate HTTP Status Codes
Always check that the request succeeded before processing the response:
response = requests.get(url, timeout=30)
response.raise_for_status() # Raises HTTPError for bad status codes
data = response.json()
3. Implement Content Integrity Checks
For critical content, verify checksums or signatures:
import hashlib
response = requests.get(url, timeout=30)
response.raise_for_status()
expected_sha256 = "abc123..." # From a trusted source
actual_sha256 = hashlib.sha256(response.content).hexdigest()
if actual_sha256 != expected_sha256:
raise ValueError("Content integrity check failed")
4. Use Certificate Pinning for High-Security Contexts
For extremely sensitive applications, pin the expected certificate:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.ssl_ import create_urllib3_context
# Pin specific certificates (advanced usage)
# See: https://urllib3.readthedocs.io/en/stable/advanced-usage.html#ssl-warnings
5. Implement Rate Limiting and Retries
Use libraries like requests-retry or tenacity to handle transient failures gracefully:
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
retries = Retry(total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504])
session.mount('https://', HTTPAdapter(max_retries=retries))
response = session.get(url, timeout=30)
response.raise_for_status()
6. Use Static Analysis Tools
Tools like Bandit and Semgrep can detect missing timeouts and other security issues:
# Install Bandit
pip install bandit
# Scan your code
bandit -r scripts/
# Use Semgrep with security rules
semgrep --config=auto scripts/
7. Follow OWASP Guidelines
Refer to OWASP's Third Party JavaScript Management Cheat Sheet for comprehensive guidance on managing external content.
Key Takeaways
-
The
generate_pages.pyscript was fetching external content without timeout controls, exposing the build pipeline to indefinite hangs and denial-of-service conditions. -
Missing
raise_for_status()calls meant HTTP errors were silently ignored, potentially writing error responses into documentation files that would be deployed to production. -
Build scripts are attack surfaces too: Even though this is a "local CLI tool," it runs in CI/CD pipelines where supply chain attacks can have widespread impact.
-
Three lines of code—
timeout=30,verify=True, andraise_for_status()—dramatically improved the security posture by implementing defense-in-depth against network-level attacks. -
Defense-in-depth matters: While HTTPS provides transport security, additional layers like timeouts, status validation, and integrity checks protect against sophisticated attacks that bypass TLS.
How Orbis AppSec Detected This
Source: External HTTP requests to raw.githubusercontent.com in the download_readme() and download_repo_urls() functions.
Sink: requests.get() calls at lines 68 and 83 in scripts/generate_pages.py that write responses directly to filesystem and parse as JSON without validation.
Missing control: No timeout parameter, no HTTP status validation via raise_for_status(), and no response integrity verification (checksums/signatures).
CWE: CWE-494 (Download of Code Without Integrity Check) - The application downloads content from a remote location without verifying its integrity.
Fix: Added explicit timeout=30 and verify=True parameters to all requests.get() calls, and inserted raise_for_status() validation before processing responses.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This vulnerability demonstrates that security hardening isn't just about preventing obvious exploits—it's about eliminating primitives that automated exploit tools could chain together in sophisticated attacks. The generate_pages.py script's missing timeout and status validation created an exploitable primitive in the build pipeline.
By adding three simple defensive measures—timeouts, explicit TLS verification, and status code validation—the fix raises the bar significantly against supply chain attacks. While each individual measure might seem minor, together they implement defense-in-depth that makes exploitation exponentially harder.
The key lesson: never trust external content implicitly, even from "trusted" sources like GitHub. Always validate, always timeout, always fail safely. These defensive programming practices are the foundation of secure software development.
References
- CWE-494: Download of Code Without Integrity Check
- CWE-295: Improper Certificate Validation
- OWASP Third Party JavaScript Management Cheat Sheet
- Python Requests Documentation: Timeouts
- Python Requests Documentation: SSL Cert Verification
- Semgrep Rule: Python requests without timeout
- GitHub PR: harden: the generate_pages in generate_pages.py