The Vulnerability: Unvalidated Feed URLs in a Browser Extension's Offscreen Context
The offscreen.js script in this browser extension handles a specific and powerful job: it runs in a dedicated offscreen document — an isolated page that Chrome extensions use to perform DOM operations and network requests outside the main service worker. Because it communicates via message passing and can reach arbitrary URLs, what goes into its fetch() calls matters enormously.
In modules/rssManager.js, the fetchRssFeed() function accepted a feedUrl parameter sourced from extension storage or incoming messages and passed it directly to the browser's fetch() API — no scheme check, no IP range validation, no redirect policy. This is a textbook Server-Side Request Forgery (SSRF) vulnerability, and in the context of a browser extension running on a user's machine, the blast radius extends to every service reachable from that device.
The Vulnerability Explained
The Vulnerable Code Pattern
Before the fix, fetchRssFeed in rssManager.js looked like this (simplified to the critical path):
async function fetchRssFeed(feedUrl) {
try {
let parsedData;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
// ❌ feedUrl is passed directly — no validation
const response = await fetch(feedUrl, { signal: controller.signal });
// ... parse response
}
}
}
The feedUrl value flows from extension storage or a message-passing handler into fetch() with zero sanitization. There is no check for:
- URL scheme —
file://,ftp://, andhttp://are all accepted alongsidehttps:// - Destination IP — private ranges (
10.x.x.x,192.168.x.x,172.16-31.x.x) and link-local addresses (169.254.x.x) are not blocked - Redirect following — the default
redirect: 'follow'behavior means a seemingly-safe HTTPS URL can chain through a redirect to an internal resource
How an Attacker Exploits This
An attacker who can write to the extension's storage (e.g., via a compromised sync account, a malicious web page that exploits a separate XSS, or direct manipulation of the RSS subscription list) can plant a feed URL like:
http://169.254.169.254/latest/meta-data/iam/security-credentials/
When the extension's background script calls fetchRssFeed() on the next refresh cycle, it dutifully fetches the AWS Instance Metadata Service endpoint. The response — containing IAM role credentials — is then processed by the extension's RSS parser. Even if the parser fails gracefully, the HTTP request has already been made and the credentials may have been logged or exfiltrated.
Other high-value targets on a typical developer's machine include:
| Target | URL |
|---|---|
| AWS metadata | http://169.254.169.254/latest/meta-data/ |
| Local Kubernetes API | http://localhost:8001/api/v1/secrets |
| Docker daemon | http://localhost:2375/containers/json |
| Corporate intranet | http://10.0.0.1/admin |
Because the fetch originates from the browser extension context (not a remote server), traditional network perimeter controls offer no protection here.
Why the Offscreen Context Makes This Worse
Chrome's offscreen documents were introduced to let extensions perform tasks like audio playback and DOM parsing in an isolated context. But "isolated" refers to the rendering environment — the network access is still subject to the same permissions as the extension. An extension with broad host_permissions can reach internal addresses. The offscreen document is a convenient fetch proxy, and without URL validation it becomes an open one.
The Fix
The fix is clean, targeted, and applied at exactly the right layer. Three coordinated changes close the vulnerability:
1. Pre-flight URL Validation in fetchRssFeed()
// modules/rssManager.js
// NEW import at the top of the file
import { validateFeedUrl as checkUrlSafety } from './utils/urlSafety.js';
async function fetchRssFeed(feedUrl) {
// ✅ Validate before any network activity
const urlError = checkUrlSafety(feedUrl);
if (urlError) {
throw new Error(urlError);
}
try {
let parsedData;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
// ✅ redirect: 'manual' prevents redirect-chain bypasses
const response = await fetch(feedUrl, { signal: controller.signal, redirect: 'manual' });
The checkUrlSafety() call (aliased from validateFeedUrl) runs synchronously before the AbortController is even created. If the URL fails validation, an error is thrown immediately and no network request is ever initiated. This is the correct place to validate: as early as possible, before any side effects.
2. redirect: 'manual' Stops Redirect-Chain Bypasses
// Before
const response = await fetch(feedUrl, { signal: controller.signal });
// After
const response = await fetch(feedUrl, { signal: controller.signal, redirect: 'manual' });
This single option change is subtle but critical. Without it, an attacker could register an HTTPS feed URL that returns a 301 Redirect to http://169.254.169.254/. The browser would follow the redirect transparently, and validateFeedUrl — which only saw the original HTTPS URL — would have been bypassed. With redirect: 'manual', the extension receives an opaque redirect response and never follows the chain.
3. Build Pipeline Fix Ensures the Validated Code Ships
# Before: offscreen.js was copied as a raw module (not bundled)
PROD_STATIC_FILES = \
icons \
lib \
_locales \
offscreen.html \
offscreen.js # ← raw, unbundled source
# After: offscreen.js is bundled via esbuild like other scripts
build-prod:
@npx esbuild offscreen.js --bundle --minify --outfile=$(PROD_BUILD_DIR)/offscreen.js
@sed -i.bak 's/type="module" //' $(PROD_BUILD_DIR)/offscreen.html
This change is easy to overlook but essential. Previously, offscreen.js was shipped as a raw ES module — meaning its import statements (including the new import { validateFeedUrl } from urlSafety.js) would not resolve correctly in the production build. By moving it through esbuild, the entire dependency graph including the new validation utility is bundled and minified into the production artifact. The fix would have been inert without this Makefile change.
Prevention & Best Practices
URL Validation for Fetch-Based Features
Any time user-supplied or user-influenced data flows into a fetch(), XMLHttpRequest, or similar network call, apply these controls:
1. Validate scheme first
function validateFeedUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return 'Invalid URL format';
}
if (parsed.protocol !== 'https:') {
return 'Only HTTPS feed URLs are permitted';
}
// ... additional checks
}
2. Block private and link-local IP ranges
After scheme validation, resolve the hostname and check against RFC 1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), loopback (127.0.0.0/8), and link-local (169.254.0.0/16). In a browser extension context you can use chrome.dns.resolve() or a pre-fetch DNS lookup to check the resolved IP before fetching.
3. Always set redirect: 'manual'
When fetching user-supplied URLs, disable automatic redirect following. If your use case requires following redirects, re-validate each redirect target before following.
4. Apply the validation as close to the source as possible
The fix validates in fetchRssFeed() — the single function responsible for all outbound RSS fetches. This is the right chokepoint. Avoid relying on callers to pre-validate; validate at the sink.
For Browser Extension Developers Specifically
- Declare the minimum necessary
host_permissions. If your extension only needs to fetch RSS feeds, restrict to known feed domains rather than<all_urls>. - Treat extension storage as untrusted input. Stored values can be modified by sync, by other extensions with storage access, or by bugs elsewhere in your codebase.
- Audit all
fetch()calls in offscreen documents and service workers — these are high-privilege contexts.
Detection with Static Analysis
This vulnerability class (tainted URL flowing to fetch()) is well-supported by static analysis tools:
- Semgrep: Rules in the
javascript.browser.security.ssrfnamespace can trace message-passing sources to fetch sinks. - CodeQL: The
js/request-forgeryquery covers this pattern. - ESLint plugins:
eslint-plugin-securityflags dynamic fetch calls with non-literal URLs.
Relevant standards:
- OWASP SSRF Prevention Cheat Sheet
- CWE-918: Server-Side Request Forgery
Key Takeaways
fetchRssFeed()was the single unfenced gate — every outbound RSS request flowed through it, making it the perfect — and correct — place to add thevalidateFeedUrlcheck.redirect: 'manual'is not optional when validating user URLs — a URL that passes scheme and IP validation can still redirect to a forbidden destination; disabling auto-redirect closes this bypass.- The Makefile change was load-bearing — bundling
offscreen.jsthroughesbuildwas required for the newurlSafety.jsimport to resolve in production; a security fix that doesn't ship is not a fix. - Extension storage is attacker-influenced data — treat any value read from
chrome.storagewith the same skepticism as a value from an HTTP request parameter. - Offscreen documents inherit extension network permissions — their apparent isolation is rendering-only; a fetch from an offscreen document can reach the same internal addresses as any other extension context.
How Orbis AppSec Detected This
- Source: User-controlled feed URL stored in extension storage and passed via message passing into
fetchRssFeed()inmodules/rssManager.js - Sink:
fetch(feedUrl, { signal: controller.signal })at the unguarded call site inrssManager.js(around line 610 before the fix), reached through the offscreen document context inoffscreen.js:140 - Missing control: No URL scheme validation, no private IP range blocklist, and no
redirect: 'manual'policy — the URL was passed verbatim tofetch()with full redirect-following enabled - CWE: CWE-918 — Server-Side Request Forgery (SSRF)
- Fix: Added
validateFeedUrlpre-flight check imported frommodules/utils/urlSafety.jsand setredirect: 'manual'on all outbound feed fetches
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
SSRF vulnerabilities in browser extensions are underappreciated precisely because developers think of SSRF as a server-side problem. But a browser extension that fetches user-supplied URLs is acting as a network proxy — and without validation, it's an open one. The offscreen.js vulnerability demonstrated how a single missing guard in fetchRssFeed() could turn an RSS reader feature into a tool for probing internal networks and cloud metadata services.
The fix is a model for how to address this class of issue: validate at the chokepoint (fetchRssFeed), disable redirect following, and ensure the fix actually ships by updating the build pipeline. If you maintain a browser extension that fetches user-supplied URLs, audit every fetch() call today.