Back to Blog
critical SEVERITY6 min read

How Unvalidated External Content Fetching happens in Python Build Scripts and how to fix it

A Python build script in the NUR (Nix User Repository) project was fetching external content from GitHub without implementing response integrity validation or proper error handling. While TLS verification was enabled by default, the absence of timeout controls, status code validation, and integrity checks left the build pipeline vulnerable to man-in-the-middle attacks and denial-of-service conditions that could compromise the generated static site content.

O
By Orbis AppSec
Published September 4, 2026Reviewed September 4, 2026

Answer Summary

This vulnerability involves unvalidated external content fetching in Python's requests library (CWE-494), where the generate_pages.py script downloaded README and repository data from GitHub without timeout controls, status validation, or integrity checks. While TLS was enabled, sophisticated MITM attacks could still manipulate responses. The fix adds explicit `timeout=30`, `verify=True`, and `raise_for_status()` calls to harden the HTTP requests against network-level attacks and ensure the build pipeline fails safely on malicious or malformed responses.

Vulnerability at a Glance

cweCWE-494 (Download of Code Without Integrity Check)
fixAdd explicit timeout, verify=True, and raise_for_status() to all requests.get() calls
riskBuild pipeline compromise via MITM or malicious response injection
languagePython
root causeMissing timeout, status validation, and integrity checks on external HTTP requests
vulnerabilityUnvalidated External Content Fetching

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:

  1. 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.

  2. 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.

  3. No explicit TLS verification: While requests defaults to verify=True, making it explicit is a security best practice that prevents accidental disabling in future modifications.

  4. No integrity checks: The script blindly trusts that the content from raw.githubusercontent.com is 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:

  1. Intercept the HTTPS connection to raw.githubusercontent.com
  2. Present a valid certificate (obtained through CA compromise or DNS manipulation)
  3. Serve a malicious README.md containing XSS payloads or phishing links
  4. The build script writes this malicious content to content/documentation/_index.md
  5. The compromised documentation gets deployed to the NUR website
  6. 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:

  1. 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.

  2. 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 adding verify=False during debugging and forgetting to remove it).

  3. 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.py script 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, and raise_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

Frequently Asked Questions

What is unvalidated external content fetching?

It occurs when an application downloads content from external sources without verifying its integrity, authenticity, or safety. This can allow attackers to inject malicious content through man-in-the-middle attacks or compromised servers.

How do you prevent unvalidated external content fetching in Python?

Always use timeouts on HTTP requests, explicitly enable TLS verification with verify=True, validate HTTP status codes with raise_for_status(), implement certificate pinning for critical endpoints, and verify content checksums or signatures when available.

What CWE is unvalidated external content fetching?

CWE-494 (Download of Code Without Integrity Check) covers downloading code or data without verifying integrity. Related CWEs include CWE-295 (Improper Certificate Validation) and CWE-345 (Insufficient Verification of Data Authenticity).

Is HTTPS enough to prevent unvalidated external content fetching attacks?

No. While HTTPS provides transport security, it doesn't protect against compromised servers, DNS hijacking with valid certificates, or sophisticated state-level MITM attacks. Additional defenses like certificate pinning, content integrity checks (checksums/signatures), and proper error handling are essential for high-security contexts.

Can static analysis detect unvalidated external content fetching?

Yes. Static analysis tools can flag HTTP requests missing timeout parameters, status validation, or integrity checks. Tools like Semgrep, Bandit, and specialized security scanners can identify these patterns and suggest hardening measures.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #59

Related Articles

critical

How Rate Limiting Vulnerabilities Happen in Node.js OAuth Endpoints and How to Fix Them

A critical resource exhaustion vulnerability was discovered in the OAuth token endpoint at `server/routes/oauth.js`. Without rate limiting, attackers could flood the `/api/oauth/token` endpoint with requests, each triggering expensive bcrypt verification operations that would exhaust server CPU and memory. The fix implements per-IP rate limiting using `express-rate-limit` to cap requests at 20 per 15-minute window.

high

How Denial of Service Attacks Happen in PHP Markdown Parsers and How to Fix Them

The league/commonmark library contained a denial of service vulnerability in its Attributes extension that could be triggered by specially crafted markdown with distinctly-named attributes. This vulnerability was fixed in version 2.10.0 by addressing how attribute names are processed during markdown parsing, preventing attackers from exhausting server resources.

critical

How dependency confusion attacks happen in Node.js package.json and how to fix it

The avim-chrome browser extension used caret (^) version ranges in package.json devDependencies, allowing automatic installation of newer minor/patch versions without review. This created a supply chain attack vector where compromised versions of htmlclean, jshint, terser, or yazl could be automatically pulled into the build process. The fix pins all devDependencies to exact versions, preventing unauthorized code from entering the build pipeline.

critical

How Wildcard Dependency Constraints Happen in Node.js and how to fix them

A critical supply chain vulnerability was discovered in the `package.json` of the `bpmn-js-task-resize` library, where wildcard (`*`) version constraints for `bpmn-js` and `diagram-js` allowed any version of those packages to be installed — including a maliciously compromised one. By pinning these dependencies to specific semver ranges (`^4.0.4` and `^4.0.3` respectively), the attack surface is dramatically reduced. This fix protects downstream consumers of the library from unknowingly executing

critical

How Supply Chain Attacks Happen via pnpm Workspace Configuration and How to Fix Them

A pnpm workspace configuration was missing the `minimumReleaseAge` setting, leaving the project vulnerable to supply chain attacks from newly published malicious or compromised npm packages. By adding `minimumReleaseAge: 10080` (seven days in minutes), the fix ensures that only packages that have survived community scrutiny for at least a week are resolved during installation. This defensive hardening is especially critical for web applications where a compromised dependency could introduce XSS,

critical

How hardcoded API key exposure happens in Node.js plugins and how to fix it

A critical hardcoded API key (`actor-studio-gpt-beta`) was discovered in the `src/plugins/llm/index.js` file of the Actor Studio application, granting anyone with source code access the ability to make unauthorized requests to the LLM service endpoints. The fix removes the default key from both the LLM class definition and the settings module, requiring the key to be explicitly configured through module settings instead.