Skip to content

Commit 06a7074

Browse files
fix(browser-runtime): block SSRF via SKILL_API.fetch bridge (#222)
2 parents d2da201 + 642f992 commit 06a7074

3 files changed

Lines changed: 354 additions & 1 deletion

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { createSkillAPIBridge } from "./skill-api";
3+
import { assertSkillFetchUrlAllowed, SsrfBlockedError } from "./url-guard";
4+
5+
describe("assertSkillFetchUrlAllowed", () => {
6+
it("allows public https URLs", () => {
7+
expect(() =>
8+
assertSkillFetchUrlAllowed("https://example.com/path"),
9+
).not.toThrow();
10+
expect(() =>
11+
assertSkillFetchUrlAllowed("http://example.com"),
12+
).not.toThrow();
13+
});
14+
15+
it("blocks non-http(s) schemes", () => {
16+
expect(() => assertSkillFetchUrlAllowed("file:///etc/passwd")).toThrow(
17+
SsrfBlockedError,
18+
);
19+
expect(() => assertSkillFetchUrlAllowed("ftp://example.com")).toThrow(
20+
SsrfBlockedError,
21+
);
22+
expect(() => assertSkillFetchUrlAllowed("chrome://settings")).toThrow(
23+
SsrfBlockedError,
24+
);
25+
expect(() => assertSkillFetchUrlAllowed("javascript:alert(1)")).toThrow(
26+
SsrfBlockedError,
27+
);
28+
});
29+
30+
it("blocks loopback hostnames and addresses", () => {
31+
expect(() => assertSkillFetchUrlAllowed("http://localhost/")).toThrow(
32+
SsrfBlockedError,
33+
);
34+
expect(() => assertSkillFetchUrlAllowed("http://127.0.0.1/")).toThrow(
35+
SsrfBlockedError,
36+
);
37+
expect(() => assertSkillFetchUrlAllowed("http://127.255.0.1/")).toThrow(
38+
SsrfBlockedError,
39+
);
40+
expect(() => assertSkillFetchUrlAllowed("http://[::1]/")).toThrow(
41+
SsrfBlockedError,
42+
);
43+
});
44+
45+
it("blocks cloud-metadata link-local 169.254.169.254", () => {
46+
expect(() =>
47+
assertSkillFetchUrlAllowed("http://169.254.169.254/latest/meta-data/"),
48+
).toThrow(SsrfBlockedError);
49+
});
50+
51+
it("blocks RFC1918 private ranges", () => {
52+
expect(() => assertSkillFetchUrlAllowed("http://10.0.0.1/")).toThrow(
53+
SsrfBlockedError,
54+
);
55+
expect(() => assertSkillFetchUrlAllowed("http://172.16.5.4/")).toThrow(
56+
SsrfBlockedError,
57+
);
58+
expect(() => assertSkillFetchUrlAllowed("http://172.31.255.255/")).toThrow(
59+
SsrfBlockedError,
60+
);
61+
expect(() => assertSkillFetchUrlAllowed("http://192.168.1.1/")).toThrow(
62+
SsrfBlockedError,
63+
);
64+
});
65+
66+
it("blocks IPv4-mapped IPv6 loopback", () => {
67+
expect(() =>
68+
assertSkillFetchUrlAllowed("http://[::ffff:127.0.0.1]/"),
69+
).toThrow(SsrfBlockedError);
70+
});
71+
72+
it("blocks IPv6 link-local and ULA", () => {
73+
expect(() => assertSkillFetchUrlAllowed("http://[fe80::1]/")).toThrow(
74+
SsrfBlockedError,
75+
);
76+
expect(() => assertSkillFetchUrlAllowed("http://[fc00::1]/")).toThrow(
77+
SsrfBlockedError,
78+
);
79+
expect(() => assertSkillFetchUrlAllowed("http://[fd12:3456::1]/")).toThrow(
80+
SsrfBlockedError,
81+
);
82+
});
83+
84+
it("blocks .local / .internal / .localhost suffixes", () => {
85+
expect(() => assertSkillFetchUrlAllowed("http://printer.local/")).toThrow(
86+
SsrfBlockedError,
87+
);
88+
expect(() =>
89+
assertSkillFetchUrlAllowed("http://service.internal/"),
90+
).toThrow(SsrfBlockedError);
91+
expect(() => assertSkillFetchUrlAllowed("http://app.localhost/")).toThrow(
92+
SsrfBlockedError,
93+
);
94+
});
95+
96+
it("permits non-private public IPs", () => {
97+
expect(() => assertSkillFetchUrlAllowed("http://8.8.8.8/")).not.toThrow();
98+
expect(() => assertSkillFetchUrlAllowed("https://1.1.1.1/")).not.toThrow();
99+
});
100+
});
101+
102+
describe("SKILL_API.fetch SSRF guard", () => {
103+
const originalFetch = globalThis.fetch;
104+
let fetchSpy: ReturnType<typeof vi.fn>;
105+
106+
beforeEach(() => {
107+
fetchSpy = vi.fn(
108+
async () =>
109+
new Response("ok", {
110+
status: 200,
111+
headers: { "content-type": "text/plain" },
112+
}),
113+
);
114+
// @ts-expect-error - override global fetch for the test
115+
globalThis.fetch = fetchSpy;
116+
});
117+
118+
afterEach(() => {
119+
globalThis.fetch = originalFetch;
120+
});
121+
122+
it("forwards public URLs to host fetch", async () => {
123+
const api = createSkillAPIBridge({ skillId: "test" });
124+
const result = await api.fetch("https://example.com/data");
125+
expect(fetchSpy).toHaveBeenCalledTimes(1);
126+
expect(result.ok).toBe(true);
127+
expect(result.status).toBe(200);
128+
});
129+
130+
it("rejects SSRF attempts to cloud metadata without invoking host fetch", async () => {
131+
const api = createSkillAPIBridge({ skillId: "test" });
132+
await expect(
133+
api.fetch(
134+
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
135+
),
136+
).rejects.toThrow(/Fetch failed/);
137+
expect(fetchSpy).not.toHaveBeenCalled();
138+
});
139+
140+
it("rejects SSRF attempts to localhost without invoking host fetch", async () => {
141+
const api = createSkillAPIBridge({ skillId: "test" });
142+
await expect(api.fetch("http://localhost:8080/admin")).rejects.toThrow(
143+
/Fetch failed/,
144+
);
145+
await expect(api.fetch("http://127.0.0.1/")).rejects.toThrow(
146+
/Fetch failed/,
147+
);
148+
expect(fetchSpy).not.toHaveBeenCalled();
149+
});
150+
151+
it("rejects file:// scheme attempts", async () => {
152+
const api = createSkillAPIBridge({ skillId: "test" });
153+
await expect(api.fetch("file:///etc/passwd")).rejects.toThrow(
154+
/Fetch failed/,
155+
);
156+
expect(fetchSpy).not.toHaveBeenCalled();
157+
});
158+
159+
it("does not follow redirects (defense-in-depth against redirect-to-internal)", async () => {
160+
const api = createSkillAPIBridge({ skillId: "test" });
161+
await api.fetch("https://example.com/");
162+
const callOptions = fetchSpy.mock.calls[0][1];
163+
expect(callOptions.redirect).toBe("error");
164+
});
165+
});

packages/browser-runtime/src/lib/vm/skill-api.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import type { FileStats } from "./types";
8+
import { assertSkillFetchUrlAllowed } from "./url-guard";
89
import { zenfs } from "./zenfs-manager";
910

1011
export type { FileStats };
@@ -217,8 +218,26 @@ export function createSkillAPIBridge(options: {
217218
async fetch(url: string, options?: RequestInit): Promise<any> {
218219
console.log(`[SKILL_API] fetch: ${url}`);
219220

221+
// SSRF guard: skills are untrusted code. Reject requests targeting
222+
// private/internal network ranges or non-http(s) schemes before they
223+
// reach the host fetch in the extension service worker context.
224+
let validatedUrl: URL;
220225
try {
221-
const response = await fetch(url, options);
226+
validatedUrl = assertSkillFetchUrlAllowed(url);
227+
} catch (error: any) {
228+
console.error("[SKILL_API] Fetch blocked:", error?.message);
229+
throw new Error(`Fetch failed: ${error?.message || String(error)}`);
230+
}
231+
232+
// Disallow following redirects so the SSRF guard cannot be bypassed
233+
// by a public host that 3xx-redirects to an internal address.
234+
const safeOptions: RequestInit = {
235+
...(options || {}),
236+
redirect: options?.redirect || "error",
237+
};
238+
239+
try {
240+
const response = await fetch(validatedUrl.toString(), safeOptions);
222241

223242
// Convert response to a plain object that can be serialized
224243
const result = {
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* URL guard for the SKILL_API.fetch bridge.
3+
*
4+
* Skills are untrusted user-supplied code packages executed in a QuickJS VM.
5+
* The host-side fetch bridge runs in the extension's service worker context
6+
* and would otherwise allow a malicious skill to perform Server-Side Request
7+
* Forgery (SSRF) against private network resources reachable from the host
8+
* (e.g. cloud-metadata services at 169.254.169.254, services bound to
9+
* localhost, or RFC1918 ranges on the user's LAN).
10+
*
11+
* This module provides an allow-by-default-public-only policy:
12+
* - Only http(s) URLs are permitted.
13+
* - Hostnames that resolve to (or literally are) loopback, link-local,
14+
* unique-local, broadcast, multicast, or RFC1918 private ranges are
15+
* rejected.
16+
* - "localhost" and analogous reserved names are rejected.
17+
*
18+
* Note: Because hostname resolution happens inside fetch(), DNS-based
19+
* rebinding is a residual risk. Where strict isolation is required, callers
20+
* should additionally restrict to an explicit allowlist.
21+
*/
22+
23+
/** Hostnames that always resolve to loopback / unsafe targets. */
24+
const BLOCKED_HOSTNAMES = new Set<string>([
25+
"localhost",
26+
"ip6-localhost",
27+
"ip6-loopback",
28+
"broadcasthost",
29+
]);
30+
31+
/** Reserved hostname suffixes (mDNS / link-local naming). */
32+
const BLOCKED_HOSTNAME_SUFFIXES = [".localhost", ".local", ".internal"];
33+
34+
function isIPv4(host: string): boolean {
35+
const parts = host.split(".");
36+
if (parts.length !== 4) return false;
37+
return parts.every(
38+
(p) => /^\d{1,3}$/.test(p) && Number(p) >= 0 && Number(p) <= 255,
39+
);
40+
}
41+
42+
function isPrivateIPv4(host: string): boolean {
43+
if (!isIPv4(host)) return false;
44+
const parts = host.split(".").map(Number);
45+
const a = parts[0] ?? -1;
46+
const b = parts[1] ?? -1;
47+
// 0.0.0.0/8 - "this" network
48+
if (a === 0) return true;
49+
// 10.0.0.0/8 - private
50+
if (a === 10) return true;
51+
// 127.0.0.0/8 - loopback
52+
if (a === 127) return true;
53+
// 169.254.0.0/16 - link-local (incl. 169.254.169.254 cloud metadata)
54+
if (a === 169 && b === 254) return true;
55+
// 172.16.0.0/12 - private
56+
if (a === 172 && b >= 16 && b <= 31) return true;
57+
// 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24 - reserved/docs
58+
if (a === 192 && b === 0) return true;
59+
// 192.168.0.0/16 - private
60+
if (a === 192 && b === 168) return true;
61+
// 198.18.0.0/15 - benchmarking
62+
if (a === 198 && (b === 18 || b === 19)) return true;
63+
// 198.51.100.0/24, 203.0.113.0/24 - documentation
64+
if (a === 198 && b === 51) return true;
65+
if (a === 203 && b === 0) return true;
66+
// 224.0.0.0/4 - multicast
67+
if (a >= 224 && a <= 239) return true;
68+
// 240.0.0.0/4 - reserved (incl. 255.255.255.255 broadcast)
69+
if (a >= 240) return true;
70+
return false;
71+
}
72+
73+
function normalizeIPv6(host: string): string {
74+
// Strip brackets if present (URL hostnames preserve brackets in some envs)
75+
return host.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
76+
}
77+
78+
function isPrivateIPv6(rawHost: string): boolean {
79+
const host = normalizeIPv6(rawHost);
80+
if (!host.includes(":")) return false;
81+
// Loopback ::1
82+
if (host === "::1" || host === "0:0:0:0:0:0:0:1") return true;
83+
// Unspecified ::
84+
if (host === "::" || /^0+(:0+){0,7}$/.test(host)) return true;
85+
// IPv4-mapped dotted form (::ffff:a.b.c.d)
86+
const v4MappedMatch = host.match(
87+
/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/,
88+
);
89+
if (v4MappedMatch?.[1] && isPrivateIPv4(v4MappedMatch[1])) return true;
90+
// IPv4-mapped hex form (::ffff:HHHH:HHHH) — convert to dotted and re-check
91+
const v4MappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
92+
if (v4MappedHex) {
93+
const high = parseInt(v4MappedHex[1] ?? "0", 16);
94+
const low = parseInt(v4MappedHex[2] ?? "0", 16);
95+
const dotted = [
96+
(high >> 8) & 0xff,
97+
high & 0xff,
98+
(low >> 8) & 0xff,
99+
low & 0xff,
100+
].join(".");
101+
if (isPrivateIPv4(dotted)) return true;
102+
}
103+
// Link-local fe80::/10
104+
if (/^fe[89ab][0-9a-f]?:/.test(host)) return true;
105+
// Unique local fc00::/7
106+
if (/^f[cd][0-9a-f]{2}:/.test(host)) return true;
107+
// Multicast ff00::/8
108+
if (/^ff[0-9a-f]{2}:/.test(host)) return true;
109+
return false;
110+
}
111+
112+
export class SsrfBlockedError extends Error {
113+
constructor(message: string) {
114+
super(message);
115+
this.name = "SsrfBlockedError";
116+
}
117+
}
118+
119+
/**
120+
* Validate that a URL is safe for the skill fetch bridge.
121+
* Throws SsrfBlockedError if the URL targets a private/internal resource
122+
* or uses a non-http(s) scheme.
123+
*/
124+
export function assertSkillFetchUrlAllowed(rawUrl: string): URL {
125+
let parsed: URL;
126+
try {
127+
parsed = new URL(rawUrl);
128+
} catch {
129+
throw new SsrfBlockedError(`Invalid URL: ${rawUrl}`);
130+
}
131+
132+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
133+
throw new SsrfBlockedError(
134+
`Blocked URL scheme '${parsed.protocol}' (only http/https are allowed)`,
135+
);
136+
}
137+
138+
// Strip brackets that URL parsing leaves around IPv6 literals
139+
const hostname = parsed.hostname
140+
.toLowerCase()
141+
.replace(/^\[/, "")
142+
.replace(/\]$/, "");
143+
144+
if (!hostname) {
145+
throw new SsrfBlockedError("Blocked URL with empty hostname");
146+
}
147+
148+
if (BLOCKED_HOSTNAMES.has(hostname)) {
149+
throw new SsrfBlockedError(`Blocked private hostname: ${hostname}`);
150+
}
151+
152+
for (const suffix of BLOCKED_HOSTNAME_SUFFIXES) {
153+
if (hostname === suffix.slice(1) || hostname.endsWith(suffix)) {
154+
throw new SsrfBlockedError(
155+
`Blocked private hostname suffix: ${hostname}`,
156+
);
157+
}
158+
}
159+
160+
if (isIPv4(hostname) && isPrivateIPv4(hostname)) {
161+
throw new SsrfBlockedError(`Blocked private IPv4 address: ${hostname}`);
162+
}
163+
164+
if (hostname.includes(":") && isPrivateIPv6(hostname)) {
165+
throw new SsrfBlockedError(`Blocked private IPv6 address: ${hostname}`);
166+
}
167+
168+
return parsed;
169+
}

0 commit comments

Comments
 (0)