b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
/**
|
|
* Registry artifact proxy route.
|
|
*
|
|
* The proxy never accepts an artifact URL from the client. The caller
|
|
* addresses an artifact by `(did, slug, version, kind, index)`; the server
|
|
* resolves the *declared* URL from the validated release record fetched from
|
|
* the aggregator, then fetches it. So the route must:
|
|
* - validate the coordinate params (400 on bad input),
|
|
* - resolve only the publisher-declared URL (never a caller-supplied one),
|
|
* - reject private / loopback / link-local hosts on the resolved URL (SSRF),
|
|
* - reject non-image content types, including SVG (allowlist), allow AVIF,
|
|
* - cap the body size, and serve image bytes with hardened headers.
|
|
*
|
|
* We drive the route's `GET` directly with a fabricated context, mock the
|
|
* `DiscoveryClient` so release resolution returns a controlled `artifacts`
|
|
* map, stub `globalThis.fetch` for the artifact fetch, and inject a DNS
|
|
* resolver so hostnames resolve to controlled IPs without real network.
|
|
*/
|
|
|
|
import type { APIContext } from "astro";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { setDefaultDnsResolver } from "../../../src/security/ssrf.js";
|
|
|
|
const PNG_1x1 = Uint8Array.from(
|
|
Buffer.from(
|
|
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100ffff03000006000557bf0a8a0000000049454e44ae426082",
|
|
"hex",
|
|
),
|
|
);
|
|
|
|
// The release record the mocked DiscoveryClient resolves. Tests mutate
|
|
// `mockArtifacts` per case to point the declared URL where they need it.
|
|
let mockArtifacts: unknown;
|
|
let mockReleaseVersion = "1.0.0";
|
|
|
|
const getPackage = vi.fn(async () => ({ profile: {} }));
|
|
const getLatestRelease = vi.fn(async () => ({
|
|
version: mockReleaseVersion,
|
|
release: { version: mockReleaseVersion, artifacts: mockArtifacts },
|
|
}));
|
|
const listReleases = vi.fn(async () => ({
|
|
releases: [
|
|
{
|
|
version: mockReleaseVersion,
|
|
release: { version: mockReleaseVersion, artifacts: mockArtifacts },
|
|
},
|
|
],
|
|
cursor: undefined,
|
|
}));
|
|
|
|
vi.mock("@emdash-cms/registry-client/discovery", () => ({
|
|
DiscoveryClient: class {
|
|
getPackage = getPackage;
|
|
getLatestRelease = getLatestRelease;
|
|
listReleases = listReleases;
|
|
},
|
|
}));
|
|
|
|
// Imported after the mock is registered.
|
|
const { GET } = await import("../../../src/astro/routes/api/admin/plugins/registry/artifact.js");
|
|
|
|
// Roles are numeric levels: SUBSCRIBER 10, EDITOR 40, ADMIN 50. `plugins:read`
|
|
// requires EDITOR.
|
|
const adminUser = { id: "u1", role: 50 };
|
|
const subscriberUser = { id: "v", role: 10 };
|
|
|
|
const AGGREGATOR_URL = "https://registry.example.com";
|
|
|
|
const DEFAULT_PARAMS: Record<string, string> = {
|
|
did: "did:plc:abc123",
|
|
slug: "myplugin",
|
|
kind: "icon",
|
|
};
|
|
|
|
function makeContext(
|
|
params: Record<string, string | null> = DEFAULT_PARAMS,
|
|
user: unknown = adminUser,
|
|
registry: unknown = AGGREGATOR_URL,
|
|
): APIContext {
|
|
const u = new URL("https://site.test/_emdash/api/admin/plugins/registry/artifact");
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value !== null) u.searchParams.set(key, value);
|
|
}
|
|
return {
|
|
url: u,
|
|
locals: {
|
|
emdash: { db: {}, config: { experimental: { registry } } },
|
|
user,
|
|
},
|
|
} as unknown as APIContext;
|
|
}
|
|
|
|
function imageResponse(
|
|
bytes: Uint8Array,
|
|
contentType = "image/png",
|
|
extra: Record<string, string> = {},
|
|
) {
|
|
return new Response(bytes, { status: 200, headers: { "content-type": contentType, ...extra } });
|
|
}
|
|
|
|
describe("registry artifact proxy", () => {
|
|
let realFetch: typeof globalThis.fetch;
|
|
|
|
beforeEach(() => {
|
|
realFetch = globalThis.fetch;
|
|
mockArtifacts = {
|
|
icon: { url: "https://cdn.example.com/icon.png" },
|
|
banner: { url: "https://cdn.example.com/banner.png" },
|
|
screenshots: [
|
|
{ url: "https://cdn.example.com/s0.png" },
|
|
{ url: "https://cdn.example.com/s1.png" },
|
|
],
|
|
};
|
|
mockReleaseVersion = "1.0.0";
|
|
getPackage.mockClear();
|
|
getLatestRelease.mockClear();
|
|
listReleases.mockClear();
|
|
// Default: every hostname resolves to a public IP. Individual tests
|
|
// override the resolver to exercise private-IP rejection.
|
|
setDefaultDnsResolver(async () => ["93.184.216.34"]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = realFetch;
|
|
setDefaultDnsResolver(null);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ── auth ───────────────────────────────────────────────────────────
|
|
|
|
it("requires authentication", async () => {
|
|
const res = await GET(makeContext(DEFAULT_PARAMS, null));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("forbids users without plugins:read", async () => {
|
|
const res = await GET(makeContext(DEFAULT_PARAMS, subscriberUser));
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
// ── param validation ───────────────────────────────────────────────
|
|
|
|
it("rejects a missing did/slug/kind", async () => {
|
|
expect((await GET(makeContext({ slug: "x", kind: "icon" }))).status).toBe(400);
|
|
expect((await GET(makeContext({ did: "did:plc:a", kind: "icon" }))).status).toBe(400);
|
|
expect((await GET(makeContext({ did: "did:plc:a", slug: "x" }))).status).toBe(400);
|
|
});
|
|
|
|
it("rejects a malformed did", async () => {
|
|
const res = await GET(makeContext({ did: "notadid", slug: "x", kind: "icon" }));
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("rejects an invalid slug", async () => {
|
|
const res = await GET(makeContext({ did: "did:plc:a", slug: "../etc", kind: "icon" }));
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("rejects an unknown kind", async () => {
|
|
const res = await GET(makeContext({ did: "did:plc:a", slug: "x", kind: "favicon" }));
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("rejects a screenshot without an index", async () => {
|
|
const res = await GET(makeContext({ did: "did:plc:a", slug: "x", kind: "screenshot" }));
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("rejects a non-integer / negative index", async () => {
|
|
expect(
|
|
(await GET(makeContext({ did: "did:plc:a", slug: "x", kind: "screenshot", index: "1.5" })))
|
|
.status,
|
|
).toBe(400);
|
|
expect(
|
|
(await GET(makeContext({ did: "did:plc:a", slug: "x", kind: "screenshot", index: "-1" })))
|
|
.status,
|
|
).toBe(400);
|
|
expect(
|
|
(await GET(makeContext({ did: "did:plc:a", slug: "x", kind: "screenshot", index: "abc" })))
|
|
.status,
|
|
).toBe(400);
|
|
});
|
|
|
|
// ── config ─────────────────────────────────────────────────────────
|
|
|
|
it("returns 400 when the registry is not configured", async () => {
|
|
const u = new URL("https://site.test/_emdash/api/admin/plugins/registry/artifact");
|
|
for (const [key, value] of Object.entries(DEFAULT_PARAMS)) u.searchParams.set(key, value);
|
|
const ctx = {
|
|
url: u,
|
|
locals: { emdash: { db: {}, config: { experimental: {} } }, user: adminUser },
|
|
} as unknown as APIContext;
|
|
const res = await GET(ctx);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
// ── resolution → declared URL is proxied ───────────────────────────
|
|
|
|
it("resolves the declared icon URL and proxies it", async () => {
|
|
const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch;
|
|
globalThis.fetch = fetchMock;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(200);
|
|
expect(getLatestRelease).toHaveBeenCalled();
|
|
// The fetched URL is the publisher-DECLARED url, never a client param.
|
|
const fetched = (fetchMock as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0];
|
|
expect(fetched).toBe("https://cdn.example.com/icon.png");
|
|
expect(res.headers.get("content-type")).toBe("image/png");
|
|
expect(res.headers.get("cache-control")).toBe("private, no-store");
|
|
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
expect(res.headers.get("content-disposition")).toBe("attachment");
|
|
expect(res.headers.get("content-security-policy")).toBe("default-src 'none'; sandbox");
|
|
const body = new Uint8Array(await res.arrayBuffer());
|
|
expect(body).toEqual(PNG_1x1);
|
|
});
|
|
|
|
it("resolves the declared banner URL", async () => {
|
|
const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch;
|
|
globalThis.fetch = fetchMock;
|
|
const res = await GET(makeContext({ did: "did:plc:abc123", slug: "myplugin", kind: "banner" }));
|
|
expect(res.status).toBe(200);
|
|
const fetched = (fetchMock as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0];
|
|
expect(fetched).toBe("https://cdn.example.com/banner.png");
|
|
});
|
|
|
|
it("resolves the declared screenshot URL by index", async () => {
|
|
const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch;
|
|
globalThis.fetch = fetchMock;
|
|
const res = await GET(
|
|
makeContext({ did: "did:plc:abc123", slug: "myplugin", kind: "screenshot", index: "1" }),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const fetched = (fetchMock as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0];
|
|
expect(fetched).toBe("https://cdn.example.com/s1.png");
|
|
});
|
|
|
|
it("paginates listReleases for an explicit version", async () => {
|
|
mockReleaseVersion = "2.0.0";
|
|
const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch;
|
|
globalThis.fetch = fetchMock;
|
|
const res = await GET(
|
|
makeContext({ did: "did:plc:abc123", slug: "myplugin", kind: "icon", version: "2.0.0" }),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(listReleases).toHaveBeenCalled();
|
|
expect(getLatestRelease).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ── 404s ───────────────────────────────────────────────────────────
|
|
|
|
it("returns 404 when the requested artifact kind is absent", async () => {
|
|
mockArtifacts = { icon: { url: "https://cdn.example.com/icon.png" } };
|
|
const res = await GET(makeContext({ did: "did:plc:abc123", slug: "myplugin", kind: "banner" }));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 404 when a screenshot index is out of range", async () => {
|
|
const res = await GET(
|
|
makeContext({ did: "did:plc:abc123", slug: "myplugin", kind: "screenshot", index: "9" }),
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 404 when the artifact entry has no usable url", async () => {
|
|
mockArtifacts = { icon: { width: 64 } };
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 404 when no release is found", async () => {
|
|
getLatestRelease.mockResolvedValueOnce({ version: "1.0.0", release: null } as never);
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
// ── content-type allowlist ─────────────────────────────────────────
|
|
|
|
it("allows AVIF", async () => {
|
|
globalThis.fetch = vi.fn(async () =>
|
|
imageResponse(PNG_1x1, "image/avif"),
|
|
) as typeof globalThis.fetch;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-type")).toBe("image/avif");
|
|
});
|
|
|
|
it("rejects SVG (active content removed from the allowlist)", async () => {
|
|
globalThis.fetch = vi.fn(async () =>
|
|
imageResponse(new TextEncoder().encode("<svg/>"), "image/svg+xml"),
|
|
) as typeof globalThis.fetch;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(415);
|
|
});
|
|
|
|
it("rejects a non-image content type", async () => {
|
|
globalThis.fetch = vi.fn(
|
|
async () =>
|
|
new Response("<html>nope</html>", {
|
|
status: 200,
|
|
headers: { "content-type": "text/html" },
|
|
}),
|
|
) as typeof globalThis.fetch;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(415);
|
|
});
|
|
|
|
it("rejects octet-stream (no content-type sniffing escape)", async () => {
|
|
globalThis.fetch = vi.fn(async () =>
|
|
imageResponse(PNG_1x1, "application/octet-stream"),
|
|
) as typeof globalThis.fetch;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(415);
|
|
});
|
|
|
|
it("normalises a content type with parameters", async () => {
|
|
globalThis.fetch = vi.fn(async () =>
|
|
imageResponse(PNG_1x1, "image/png; charset=binary"),
|
|
) as typeof globalThis.fetch;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-type")).toBe("image/png");
|
|
});
|
|
|
|
// ── SSRF on the RESOLVED url ────────────────────────────────────────
|
|
|
|
it("rejects a declared non-http(s) scheme", async () => {
|
|
mockArtifacts = { icon: { url: "file:///etc/passwd" } };
|
|
const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch;
|
|
globalThis.fetch = fetchMock;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(400);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a declared cloud metadata IP", async () => {
|
|
mockArtifacts = { icon: { url: "http://169.254.169.254/latest/meta-data/" } };
|
|
const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch;
|
|
globalThis.fetch = fetchMock;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(400);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a declared hostname that resolves to a private IP (DNS rebinding)", async () => {
|
|
mockArtifacts = { icon: { url: "https://rebind.attacker.test/icon.png" } };
|
|
setDefaultDnsResolver(async () => ["10.0.0.5"]);
|
|
const fetchMock = vi.fn(async () => imageResponse(PNG_1x1)) as typeof globalThis.fetch;
|
|
globalThis.fetch = fetchMock;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(400);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("re-validates a redirect target and rejects a private hop", async () => {
|
|
mockArtifacts = { icon: { url: "https://cdn.example.com/redirect" } };
|
|
setDefaultDnsResolver(async (host) =>
|
|
host === "cdn.example.com" ? ["93.184.216.34"] : ["169.254.169.254"],
|
|
);
|
|
globalThis.fetch = vi.fn(
|
|
async () =>
|
|
new Response(null, {
|
|
status: 302,
|
|
headers: { location: "http://internal.attacker.test/secret" },
|
|
}),
|
|
) as typeof globalThis.fetch;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
// ── upstream + size ─────────────────────────────────────────────────
|
|
|
|
it("rejects an upstream error status", async () => {
|
|
globalThis.fetch = vi.fn(
|
|
async () =>
|
|
new Response("not found", { status: 404, headers: { "content-type": "text/plain" } }),
|
|
) as typeof globalThis.fetch;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(502);
|
|
});
|
|
|
|
it("rejects an oversized declared content-length", async () => {
|
|
globalThis.fetch = vi.fn(async () =>
|
|
imageResponse(PNG_1x1, "image/png", { "content-length": String(10 * 1024 * 1024) }),
|
|
) as typeof globalThis.fetch;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(413);
|
|
});
|
|
|
|
it("rejects a streamed body that exceeds the cap with no content-length", async () => {
|
|
const chunk = new Uint8Array(1024 * 1024);
|
|
let emitted = 0;
|
|
const body = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (emitted >= 6) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
emitted++;
|
|
controller.enqueue(chunk);
|
|
},
|
|
});
|
|
const response = new Response(body, {
|
|
status: 200,
|
|
headers: { "content-type": "image/png" },
|
|
});
|
|
expect(response.headers.get("content-length")).toBeNull();
|
|
globalThis.fetch = vi.fn(async () => response) as typeof globalThis.fetch;
|
|
const res = await GET(makeContext(DEFAULT_PARAMS));
|
|
expect(res.status).toBe(413);
|
|
});
|
|
});
|