Introduction
The fetch-worker.js file handles background URL fetching using Node.js worker threads, processing URLs from a sources.txt file. However, a critical flaw in the message handler at line 4 created a severe security risk: URLs were passed directly to fetch operations without any validation of the target domain or IP address.
This meant that if an attacker could modify sources.txt—through a separate file write vulnerability, supply chain attack, or compromised configuration—they could force the application to make requests to internal services, cloud metadata endpoints like http://169.254.169.254/, or other sensitive network resources that should never be accessible from the outside.
The vulnerable code pattern was deceptively simple:
parentPort.on('message', async (message) => {
const { id, url, userAgent } = message;
// url is used directly without any validation
// ... fetch operation proceeds
});
The Vulnerability Explained
Server-Side Request Forgery (SSRF) occurs when an application can be tricked into making HTTP requests to unintended destinations. In this case, the fetch-worker.js worker thread accepted any URL passed through the parentPort.on('message') handler and would attempt to fetch it without question.
The Dangerous Code Path
Looking at the original code:
const { parentPort } = require('worker_threads');
parentPort.on('message', async (message) => {
const { id, url, userAgent } = message;
// No validation whatsoever - any URL is accepted
let headerUA = userAgent || 'unknown';
// ... proceeds to fetch the URL
});
The url variable extracted from the message could contain:
- Internal IP addresses: http://192.168.1.1/admin
- Localhost services: http://127.0.0.1:8080/internal-api
- Cloud metadata endpoints: http://169.254.169.254/latest/meta-data/
- Internal Kubernetes services: http://kubernetes.default.svc/api/v1/secrets
Attack Scenario
Consider this attack against a Jellyfin server deployment:
- An attacker identifies that
sources.txtis used to configure external data sources - Through a configuration vulnerability or social engineering, they add a malicious entry:
http://169.254.169.254/latest/meta-data/iam/security-credentials/ - The fetch worker processes this URL and retrieves AWS IAM credentials
- The response containing sensitive credentials is returned through the worker's message system
- The attacker now has temporary AWS credentials with whatever permissions the instance role provides
This is particularly dangerous because:
- The request originates from within the trusted network perimeter
- Cloud metadata services don't require authentication from the instance
- The application appears to be functioning normally while exfiltrating sensitive data
The Fix
The fix introduces a new isAllowedUrl() function that implements a strict allowlist approach, validating URLs before any fetch operation occurs.
Before (Vulnerable)
parentPort.on('message', async (message) => {
const { id, url, userAgent } = message;
// Immediately proceeds to use the URL
let headerUA = userAgent || 'unknown';
After (Secure)
function isAllowedUrl(url) {
let parsed;
try { parsed = new URL(url); } catch { return false; }
if (parsed.protocol !== 'https:') return false;
const host = parsed.hostname;
if (/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|0\.0\.0\.0)/i.test(host)) return false;
return true;
}
parentPort.on('message', async (message) => {
const { id, url, userAgent } = message;
if (!isAllowedUrl(url)) {
parentPort.postMessage({ id, success: false, error: 'URL not allowed', url });
return;
}
// Only now proceeds with the fetch
How Each Change Protects Against SSRF
- URL Parsing with try/catch: Malformed URLs that could bypass string-based checks are rejected immediately
- HTTPS-only enforcement:
parsed.protocol !== 'https:'blocks HTTP, file://, gopher://, and other dangerous protocols -
Private IP blocking: The regex pattern blocks:
-localhost- the loopback hostname
-127.- IPv4 loopback range
-10.- Class A private network
-192.168.- Class C private network
-172.16-31.- Class B private networks
-169.254.- Link-local and cloud metadata range
-0.0.0.0- All interfaces binding address -
Early return with error: Invalid URLs trigger an immediate response with
success: falseand a clear error message, preventing any network request
Prevention & Best Practices
1. Always Validate URLs Before Fetching
// Good: Validate before use
function safeFetch(url) {
const parsed = new URL(url);
if (!isAllowedDestination(parsed)) {
throw new Error('URL not permitted');
}
return fetch(url);
}
2. Use Allowlists, Not Blocklists
While the fix uses a blocklist for private IPs (which is appropriate for this use case), consider maintaining an explicit allowlist of permitted domains when possible:
const ALLOWED_DOMAINS = ['api.example.com', 'cdn.trusted.org'];
function isDomainAllowed(hostname) {
return ALLOWED_DOMAINS.some(domain =>
hostname === domain || hostname.endsWith('.' + domain)
);
}
3. Resolve DNS and Validate the IP
Sophisticated attackers may use DNS rebinding. For high-security applications:
const dns = require('dns').promises;
async function isIPAllowed(hostname) {
const addresses = await dns.resolve4(hostname);
return addresses.every(ip => !isPrivateIP(ip));
}
4. Network Segmentation
Deploy applications that fetch external URLs in isolated network segments where they cannot reach internal services, even if SSRF occurs.
Security Standards Reference
- OWASP: SSRF is listed in the OWASP Top 10 2021 as A10
- CWE-918: Server-Side Request Forgery
Key Takeaways
- Never fetch URLs from
sources.txtor similar configuration files without validation—thefetch-worker.jspattern of reading and fetching is common but dangerous - The
169.254.x.xrange is critical to block—this is where cloud metadata services live on AWS, GCP, and Azure - HTTPS enforcement provides defense in depth—it prevents protocol smuggling attacks and ensures encrypted transport
- Worker threads don't provide security isolation—the
parentPort.on('message')handler needs the same input validation as any other entry point - Regex-based IP blocking must cover all private ranges—missing even one range (like
172.16-31.x) leaves a gap attackers will find
How Orbis AppSec Detected This
- Source: URLs read from
sources.txtand passed viaparentPort.on('message')infetch-worker.js - Sink: The fetch operation that would make HTTP requests to attacker-controlled destinations
- Missing control: No validation of URL protocol, hostname, or IP address before fetching
- CWE: CWE-918 (Server-Side Request Forgery)
- Fix: Added
isAllowedUrl()function that enforces HTTPS protocol and blocks private IP ranges before any fetch operation
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 SSRF vulnerability in fetch-worker.js demonstrates how a simple oversight—trusting URLs from a configuration file—can create a critical security risk. The fix is elegant and focused: a 14-line isAllowedUrl() function that validates protocol and blocks private networks before any fetch occurs.
For Node.js developers building applications that fetch external resources, remember: every URL from an external source is potentially malicious. Validate the protocol, check the destination, and when in doubt, use an explicit allowlist of permitted domains.