How Server-Side Request Forgery (SSRF) happens in JavaScript fetch() and how to fix it
Introduction
The popup.js file in this Electron application handles image processing from scraped webpages—a seemingly benign operation that masked a critical Server-Side Request Forgery vulnerability. At line 646, multiple fetch() calls accepted image URLs directly from user-controlled sources without any validation. An attacker controlling scraped webpage content could inject URLs pointing to internal network addresses, AWS metadata endpoints (169.254.169.254), or localhost services, forcing the application to make requests it was never intended to make.
This vulnerability exemplifies a common misconception in web security: that the fetch() API is "safe by default" because it's a modern browser API. In reality, when fetch() is used in a server context (or in this case, an Electron application with network access), it becomes a powerful attack vector if URLs aren't properly validated.
The Vulnerability Explained
What Made This Code Vulnerable
The vulnerable code pattern in popup.js:646 looked like this:
func: async (imgUrl) => {
try {
const res = await fetch(imgUrl);
const blob = await res.blob();
return await new Promise(resolve => {
// ... process blob
});
}
}
The problem is deceptively simple: imgUrl comes from scraped webpage content, and there's zero validation before it reaches fetch(). The developer likely assumed that only valid image URLs would be present, but this assumption breaks down when:
- Scraped content is untrusted: Webpages can be compromised, contain injected content, or be controlled by attackers
- URL parsing is lenient: JavaScript's URL constructor accepts many formats that developers don't expect
- No protocol filtering: The code would happily fetch from
file://,gopher://, or other schemes if the JavaScript engine supports them - No IP range filtering: Internal addresses like
10.0.0.1,172.16.0.1, or192.168.1.1are treated the same as public URLs
Attack Scenarios Specific to This Code
Scenario 1: AWS Metadata Exfiltration
An attacker compromises a website that the application scrapes. They inject:
<img src="http://169.254.169.254/latest/meta-data/iam/security-credentials/default" />
The application's fetch() call retrieves AWS credentials for the EC2 instance it's running on, and the attacker extracts them from error messages or response timing.
Scenario 2: Internal Service Exploitation
The attacker injects:
<img src="http://localhost:5000/admin/reset-password?token=admin" />
If the application is running alongside a local admin service, this triggers unintended administrative actions.
Scenario 3: Network Reconnaissance
The attacker injects multiple URLs like:
<img src="http://192.168.1.1/" />
<img src="http://192.168.1.100/" />
<img src="http://10.0.0.5:8080/" />
By observing response times and error patterns, they map the internal network topology.
The Fix
The PR introduced three critical validation checks before the fetch() call:
func: async (imgUrl) => {
try {
const _u = new URL(imgUrl);
if (!['http:', 'https:'].includes(_u.protocol)) return null;
if (/^(localhost$|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(_u.hostname)) return null;
const res = await fetch(imgUrl);
const blob = await res.blob();
return await new Promise(resolve => {
// ... process blob
});
}
}
What Changed and Why
Line 1: URL Parsing
const _u = new URL(imgUrl);
This parses the URL into its components. If imgUrl is malformed, new URL() throws an exception, which is caught by the existing try-catch block. This prevents protocol confusion attacks.
Line 2: Protocol Whitelist
if (!['http:', 'https:'].includes(_u.protocol)) return null;
This explicitly allows only HTTP and HTTPS. Without this check, an attacker could use:
- file:// to access local files
- ftp:// to connect to FTP servers
- gopher:// or other schemes depending on the runtime environment
By returning null instead of throwing, the code gracefully handles invalid URLs without crashing the image processing pipeline.
Line 3: Private IP Range Blocking
if (/^(localhost$|127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(_u.hostname)) return null;
This regex blocks:
- localhost (exact match)
- 127.x.x.x (loopback addresses)
- 10.x.x.x (private Class A)
- 192.168.x.x (private Class C)
- 172.16-31.x.x (private Class B)
These ranges cover all RFC 1918 private IP addresses plus loopback. The regex 172\.(1[6-9]|2\d|3[01]) specifically matches 172.16 through 172.31 using digit ranges.
Why This Approach is Correct
Rather than maintaining a whitelist of "safe" domains (which would be fragile), the fix uses a blacklist of dangerous patterns. This is appropriate here because:
- Simplicity: The application legitimately needs to fetch from arbitrary public image URLs
- Maintenance: Whitelisting would require knowing all valid image sources in advance
- Completeness: The private IP ranges are well-defined by RFC standards and unlikely to change
Prevention & Best Practices
For This Specific Code Pattern
- Always validate URLs before network calls: Treat URLs as user input, even if they come from scraped content
- Use the URL constructor: JavaScript's
new URL()is safer than string manipulation and provides structured access to components - Implement protocol whitelisting: Explicitly allow only HTTP/HTTPS for general web requests
- Block private IP ranges: Use the RFC 1918 ranges plus loopback and link-local addresses
Broader Security Recommendations
- Content Security Policy (CSP): In browser contexts, CSP can restrict fetch destinations
- Network segmentation: Isolate the application from internal networks when possible
- Monitoring: Log all external URLs being fetched; unusual patterns may indicate exploitation attempts
- Input validation at the source: Validate scraped URLs when they're extracted, not just when they're used
Detection Tools
- Semgrep: Use the rule
javascript.lang.security.audit.unvalidated-url-fetchto find similar patterns - Static analysis: ESLint plugins can flag
fetch()calls with non-literal URLs - Dynamic analysis: Monitor network connections made by the application; unexpected internal requests are suspicious
Key Takeaways
- URL validation in
popup.jsline 646 was the critical missing step: The code assumed all scraped URLs were safe, but attackers could inject internal addresses - The regex pattern blocks all RFC 1918 private ranges: This single check prevents most SSRF attacks without requiring a whitelist
- Protocol whitelisting prevents scheme confusion attacks: Blocking
file://andgopher://closes alternative attack vectors - The fix preserves legitimate functionality: Public image URLs still work; only internal/private addresses are blocked
- SSRF is often overlooked in JavaScript: Many developers don't realize that
fetch()can be weaponized to attack internal infrastructure
How Orbis AppSec Detected This
| Component | Details |
|---|---|
| Source | Image URLs from scraped webpage content passed to the image processing function in popup.js |
| Sink | fetch(imgUrl) call at line 646 in popup.js without URL validation |
| Missing Control | No protocol validation, no private IP range checking, no URL format validation |
| CWE | CWE-918: Server-Side Request Forgery (SSRF) |
| Fix | Added URL object instantiation, protocol whitelist check (['http:', 'https:']), and regex-based private IP range blocking before the fetch() call |
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
Server-Side Request Forgery vulnerabilities in JavaScript are particularly insidious because the fetch() API appears safe and modern, leading developers to trust it with user-controlled input. The fix in this PR demonstrates that security isn't about complex algorithms—it's about systematic validation at every trust boundary.
The three-line validation added to popup.js transforms an exploitable vulnerability into a hardened function. For developers working with scraped content, external APIs, or any untrusted URLs, this pattern is a template: parse the URL, whitelist the protocol, and block private IP ranges. These simple checks prevent entire classes of attacks.
As you review your own code, ask: "Where does my application make network requests based on external input?" The answer might reveal your own SSRF vulnerability waiting to be fixed.