Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,479 @@
/**
* Tests for sandboxHttpFetch — the bridge's outbound HTTP helper used by
* sandboxed plugins.
*
* Two behaviours that need coverage:
* - Redirects must re-validate against allowedHosts at every hop. The
* native `fetch` follows 3xx responses automatically, so an allowed host
* that 302s to a blocked host would otherwise bypass the allowlist.
* - Credential headers (Authorization, Cookie, Proxy-Authorization) must
* be stripped on cross-origin hops so they don't leak to attacker
* destinations.
* - With `network:request:unrestricted` (no allowlist), requests targeting literal
* private IPs or known internal hostnames must still be rejected.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { sandboxHttpFetch } from "../../src/sandbox/bridge-http.js";
function okResponse(body = "ok"): Response {
return new Response(body, { status: 200 });
}
function redirectResponse(location: string, status = 302): Response {
return new Response(null, { status, headers: { Location: location } });
}
type FetchImpl = NonNullable<Parameters<typeof sandboxHttpFetch>[2]["fetchImpl"]>;
function mockFetchSequence(responses: Response[]): FetchImpl {
const queue = [...responses];
return vi.fn(async () => {
const next = queue.shift();
if (!next) throw new Error("fetch called more times than expected");
return next;
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- vi.fn's generic signature doesn't line up with Workers' fetch type; cast to the injectable contract
}) as unknown as FetchImpl;
}
afterEach(() => {
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Capability gating
// ---------------------------------------------------------------------------
describe("sandboxHttpFetch — capability enforcement", () => {
it("rejects when neither network:request nor network:request:unrestricted is held", async () => {
await expect(
sandboxHttpFetch("https://a.example.com/", undefined, {
capabilities: [],
allowedHosts: ["a.example.com"],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow(/network:request/);
});
it("allows when network:request is held and host is on the list", async () => {
const res = await sandboxHttpFetch("https://a.example.com/", undefined, {
capabilities: ["network:request"],
allowedHosts: ["a.example.com"],
fetchImpl: mockFetchSequence([okResponse()]),
});
expect(res.status).toBe(200);
});
it("allows when network:request:unrestricted is held and skips the allowlist for public hosts", async () => {
const res = await sandboxHttpFetch("https://a.example.com/", undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
});
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// Host allowlist enforcement per redirect hop
// ---------------------------------------------------------------------------
describe("sandboxHttpFetch — redirect allowlist enforcement", () => {
it("rejects a redirect to a host not on the allowlist", async () => {
await expect(
sandboxHttpFetch("https://a.example.com/", undefined, {
capabilities: ["network:request"],
allowedHosts: ["a.example.com"],
fetchImpl: mockFetchSequence([redirectResponse("https://evil.example.com/"), okResponse()]),
}),
).rejects.toThrow(/not allowed|host/i);
});
it("follows a redirect to a host that IS on the allowlist", async () => {
const res = await sandboxHttpFetch("https://a.example.com/", undefined, {
capabilities: ["network:request"],
allowedHosts: ["a.example.com", "b.example.com"],
fetchImpl: mockFetchSequence([
redirectResponse("https://b.example.com/next"),
okResponse("from-b"),
]),
});
expect(res.status).toBe(200);
expect(res.text).toBe("from-b");
});
it("rejects chains that exceed the redirect limit", async () => {
// 6 redirects to the same allowed host — more than our max of 5
const fetchImpl = mockFetchSequence([
redirectResponse("https://a.example.com/1"),
redirectResponse("https://a.example.com/2"),
redirectResponse("https://a.example.com/3"),
redirectResponse("https://a.example.com/4"),
redirectResponse("https://a.example.com/5"),
redirectResponse("https://a.example.com/6"),
okResponse(),
]);
await expect(
sandboxHttpFetch("https://a.example.com/", undefined, {
capabilities: ["network:request"],
allowedHosts: ["a.example.com"],
fetchImpl,
}),
).rejects.toThrow(/too many redirects|redirect/i);
});
});
// ---------------------------------------------------------------------------
// Credential header stripping on cross-origin redirects
// ---------------------------------------------------------------------------
describe("sandboxHttpFetch — credential header stripping", () => {
it("preserves credentials on same-origin redirect", async () => {
const fetchImpl = mockFetchSequence([
redirectResponse("https://a.example.com/page2"),
okResponse(),
]);
await sandboxHttpFetch(
"https://a.example.com/",
{
headers: { Authorization: "Bearer secret-token" },
},
{
capabilities: ["network:request"],
allowedHosts: ["a.example.com"],
fetchImpl,
},
);
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- vi.Mock type hygiene
const secondCall = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[1];
const init = secondCall?.[1] as RequestInit | undefined;
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer secret-token");
});
it("strips Authorization on cross-origin redirect", async () => {
const fetchImpl = mockFetchSequence([
redirectResponse("https://b.example.com/after"),
okResponse(),
]);
await sandboxHttpFetch(
"https://a.example.com/",
{
headers: { Authorization: "Bearer secret-token" },
},
{
capabilities: ["network:request"],
allowedHosts: ["a.example.com", "b.example.com"],
fetchImpl,
},
);
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- vi.Mock type hygiene
const secondCall = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[1];
const init = secondCall?.[1] as RequestInit | undefined;
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBeNull();
});
it("strips Cookie and Proxy-Authorization on cross-origin redirect", async () => {
const fetchImpl = mockFetchSequence([
redirectResponse("https://b.example.com/after"),
okResponse(),
]);
await sandboxHttpFetch(
"https://a.example.com/",
{
headers: {
Cookie: "session=abc",
"Proxy-Authorization": "Basic creds",
},
},
{
capabilities: ["network:request"],
allowedHosts: ["a.example.com", "b.example.com"],
fetchImpl,
},
);
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- vi.Mock type hygiene
const secondCall = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[1];
const init = secondCall?.[1] as RequestInit | undefined;
const headers = new Headers(init?.headers);
expect(headers.get("cookie")).toBeNull();
expect(headers.get("proxy-authorization")).toBeNull();
});
});
// ---------------------------------------------------------------------------
// SSRF defence for network:request:unrestricted
// ---------------------------------------------------------------------------
describe("sandboxHttpFetch — SSRF defence with network:request:unrestricted", () => {
it("rejects literal loopback IPv4", async () => {
await expect(
sandboxHttpFetch("http://127.0.0.1/", undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow();
});
it("rejects literal private IPv4 ranges", async () => {
for (const url of [
"http://10.0.0.1/",
"http://192.168.1.1/",
"http://172.16.0.1/",
"http://169.254.169.254/latest/meta-data/",
]) {
await expect(
sandboxHttpFetch(url, undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow();
}
});
it("rejects localhost and metadata hostnames", async () => {
for (const url of ["http://localhost/", "http://metadata.google.internal/"]) {
await expect(
sandboxHttpFetch(url, undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow();
}
});
it("rejects IPv6 loopback", async () => {
await expect(
sandboxHttpFetch("http://[::1]/", undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow();
});
it("re-applies the SSRF check on redirects", async () => {
// Public host redirects to a private IP — must be blocked.
await expect(
sandboxHttpFetch("https://public.example.com/", undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([
redirectResponse("http://169.254.169.254/latest/meta-data/"),
okResponse(),
]),
}),
).rejects.toThrow();
});
// The WHATWG URL parser normalises IPv4-mapped IPv6 to hex form:
// [::ffff:127.0.0.1] -> [::ffff:7f00:1]
// [::ffff:169.254.169.254] -> [::ffff:a9fe:a9fe]
// A literal-string check against "::ffff:127.0.0.1" never matches the
// form the bridge actually sees. We must normalise the hex form back
// to dotted-decimal before the range check.
it("rejects IPv4-mapped IPv6 loopback in hex form", async () => {
await expect(
sandboxHttpFetch("http://[::ffff:7f00:1]/", undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow();
});
it("rejects IPv4-mapped IPv6 metadata address in hex form", async () => {
await expect(
sandboxHttpFetch("http://[::ffff:a9fe:a9fe]/latest/meta-data/", undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow();
});
it("rejects IPv4-mapped IPv6 private ranges in hex form", async () => {
for (const url of [
"http://[::ffff:a00:1]/", // 10.0.0.1
"http://[::ffff:c0a8:1]/", // 192.168.0.1
"http://[::ffff:ac10:1]/", // 172.16.0.1
]) {
await expect(
sandboxHttpFetch(url, undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow();
}
});
});
// ---------------------------------------------------------------------------
// SSRF defence applies even when the restricted path uses allowedHosts=["*"]
// ---------------------------------------------------------------------------
describe('sandboxHttpFetch — SSRF defence with allowedHosts=["*"]', () => {
// A plugin with { capabilities: ["network:request"], allowedHosts: ["*"] }
// gets full egress with zero SSRF protection unless we apply the literal
// check on the restricted path too. The allowlist describes scope, not
// safety.
it("rejects literal private IPv4 even with allowedHosts=['*']", async () => {
await expect(
sandboxHttpFetch("http://127.0.0.1/", undefined, {
capabilities: ["network:request"],
allowedHosts: ["*"],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow();
});
it("rejects cloud-metadata IP even with allowedHosts=['*']", async () => {
await expect(
sandboxHttpFetch("http://169.254.169.254/", undefined, {
capabilities: ["network:request"],
allowedHosts: ["*"],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow();
});
it("rejects localhost even with allowedHosts=['*']", async () => {
await expect(
sandboxHttpFetch("http://localhost/", undefined, {
capabilities: ["network:request"],
allowedHosts: ["*"],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow();
});
it("still allows public hosts with allowedHosts=['*']", async () => {
const res = await sandboxHttpFetch("https://api.example.com/", undefined, {
capabilities: ["network:request"],
allowedHosts: ["*"],
fetchImpl: mockFetchSequence([okResponse()]),
});
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// URL scheme enforcement
// ---------------------------------------------------------------------------
describe("sandboxHttpFetch — scheme enforcement", () => {
it("rejects file: scheme", async () => {
await expect(
sandboxHttpFetch("file:///etc/passwd", undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow(/scheme/i);
});
it("rejects data: scheme", async () => {
await expect(
sandboxHttpFetch("data:text/plain,secret", undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow(/scheme/i);
});
it("rejects ftp: scheme", async () => {
await expect(
sandboxHttpFetch("ftp://example.com/file", undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow(/scheme/i);
});
it("accepts http: and https:", async () => {
for (const url of ["http://a.example.com/", "https://a.example.com/"]) {
const res = await sandboxHttpFetch(url, undefined, {
capabilities: ["network:request"],
allowedHosts: ["a.example.com"],
fetchImpl: mockFetchSequence([okResponse()]),
});
expect(res.status).toBe(200);
}
});
});
// ---------------------------------------------------------------------------
// Allowlist normalisation — trailing dots and mixed case
// ---------------------------------------------------------------------------
describe("sandboxHttpFetch — allowlist normalisation", () => {
it("matches when the manifest uses mixed case", async () => {
const res = await sandboxHttpFetch("https://api.example.com/", undefined, {
capabilities: ["network:request"],
allowedHosts: ["API.Example.COM"],
fetchImpl: mockFetchSequence([okResponse()]),
});
expect(res.status).toBe(200);
});
it("matches when the request uses a trailing dot FQDN", async () => {
const res = await sandboxHttpFetch("https://api.example.com./", undefined, {
capabilities: ["network:request"],
allowedHosts: ["api.example.com"],
fetchImpl: mockFetchSequence([okResponse()]),
});
expect(res.status).toBe(200);
});
it("matches wildcard patterns case-insensitively", async () => {
const res = await sandboxHttpFetch("https://api.example.com/", undefined, {
capabilities: ["network:request"],
allowedHosts: ["*.Example.COM"],
fetchImpl: mockFetchSequence([okResponse()]),
});
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// *.localhost hostnames
// ---------------------------------------------------------------------------
describe("sandboxHttpFetch — *.localhost", () => {
// RFC 6761 reserves .localhost for loopback. Subdomains of localhost
// must be treated as internal too.
it("rejects app.localhost", async () => {
await expect(
sandboxHttpFetch("http://app.localhost/", undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow();
});
it("rejects nested *.localhost subdomains", async () => {
await expect(
sandboxHttpFetch("http://admin.app.localhost/", undefined, {
capabilities: ["network:request:unrestricted"],
allowedHosts: [],
fetchImpl: mockFetchSequence([okResponse()]),
}),
).rejects.toThrow();
});
});