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:
112
packages/cloudflare/tests/db/do-dialect.test.ts
Normal file
112
packages/cloudflare/tests/db/do-dialect.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { CompiledQuery } from "kysely";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { PreviewDODialect } from "../../src/db/do-dialect.js";
|
||||
import type { PreviewDODialectConfig } from "../../src/db/do-dialect.js";
|
||||
|
||||
function createMockStub(queryFn = vi.fn()) {
|
||||
return { query: queryFn };
|
||||
}
|
||||
|
||||
function createConfig(queryFn = vi.fn()): PreviewDODialectConfig {
|
||||
const stub = createMockStub(queryFn);
|
||||
return { getStub: () => stub };
|
||||
}
|
||||
|
||||
describe("PreviewDODialect", () => {
|
||||
it("creates a SqliteAdapter", () => {
|
||||
const dialect = new PreviewDODialect(createConfig());
|
||||
const adapter = dialect.createAdapter();
|
||||
expect(adapter.constructor.name).toBe("SqliteAdapter");
|
||||
});
|
||||
|
||||
it("creates a SqliteQueryCompiler", () => {
|
||||
const dialect = new PreviewDODialect(createConfig());
|
||||
const compiler = dialect.createQueryCompiler();
|
||||
expect(compiler.constructor.name).toBe("SqliteQueryCompiler");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PreviewDODriver", () => {
|
||||
it("acquires a connection", async () => {
|
||||
const dialect = new PreviewDODialect(createConfig());
|
||||
const driver = dialect.createDriver();
|
||||
const conn = await driver.acquireConnection();
|
||||
expect(conn).toBeDefined();
|
||||
expect(conn.executeQuery).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("transaction methods are no-ops (preview is read-only)", async () => {
|
||||
const dialect = new PreviewDODialect(createConfig());
|
||||
const driver = dialect.createDriver();
|
||||
const conn = await driver.acquireConnection();
|
||||
|
||||
// These should not throw
|
||||
await driver.beginTransaction(conn, {});
|
||||
await driver.commitTransaction(conn);
|
||||
await driver.rollbackTransaction(conn);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PreviewDOConnection", () => {
|
||||
it("passes sql and parameters to the stub", async () => {
|
||||
const queryFn = vi.fn().mockResolvedValue({ rows: [], changes: 0 });
|
||||
const dialect = new PreviewDODialect(createConfig(queryFn));
|
||||
const driver = dialect.createDriver();
|
||||
const conn = await driver.acquireConnection();
|
||||
|
||||
await conn.executeQuery(CompiledQuery.raw("SELECT * FROM users WHERE id = ?", ["abc"]));
|
||||
|
||||
expect(queryFn).toHaveBeenCalledWith("SELECT * FROM users WHERE id = ?", ["abc"]);
|
||||
});
|
||||
|
||||
it("returns rows from the stub result", async () => {
|
||||
const rows = [
|
||||
{ id: "1", name: "Alice" },
|
||||
{ id: "2", name: "Bob" },
|
||||
];
|
||||
const queryFn = vi.fn().mockResolvedValue({ rows, changes: 0 });
|
||||
const dialect = new PreviewDODialect(createConfig(queryFn));
|
||||
const driver = dialect.createDriver();
|
||||
const conn = await driver.acquireConnection();
|
||||
|
||||
const result = await conn.executeQuery(CompiledQuery.raw("SELECT * FROM users"));
|
||||
|
||||
expect(result.rows).toEqual(rows);
|
||||
});
|
||||
|
||||
it("converts changes to bigint numAffectedRows", async () => {
|
||||
const queryFn = vi.fn().mockResolvedValue({ rows: [], changes: 3 });
|
||||
const dialect = new PreviewDODialect(createConfig(queryFn));
|
||||
const driver = dialect.createDriver();
|
||||
const conn = await driver.acquireConnection();
|
||||
|
||||
const result = await conn.executeQuery(CompiledQuery.raw("UPDATE users SET name = ?", ["x"]));
|
||||
|
||||
expect(result.numAffectedRows).toBe(3n);
|
||||
});
|
||||
|
||||
it("sets numAffectedRows to undefined when changes is undefined", async () => {
|
||||
const queryFn = vi.fn().mockResolvedValue({ rows: [{ count: 5 }] });
|
||||
const dialect = new PreviewDODialect(createConfig(queryFn));
|
||||
const driver = dialect.createDriver();
|
||||
const conn = await driver.acquireConnection();
|
||||
|
||||
const result = await conn.executeQuery(
|
||||
CompiledQuery.raw("SELECT count(*) as count FROM users"),
|
||||
);
|
||||
|
||||
expect(result.numAffectedRows).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles zero changes correctly", async () => {
|
||||
const queryFn = vi.fn().mockResolvedValue({ rows: [], changes: 0 });
|
||||
const dialect = new PreviewDODialect(createConfig(queryFn));
|
||||
const driver = dialect.createDriver();
|
||||
const conn = await driver.acquireConnection();
|
||||
|
||||
const result = await conn.executeQuery(CompiledQuery.raw("DELETE FROM users WHERE 1=0"));
|
||||
|
||||
expect(result.numAffectedRows).toBe(0n);
|
||||
});
|
||||
});
|
||||
97
packages/cloudflare/tests/db/do-playground-routes.test.ts
Normal file
97
packages/cloudflare/tests/db/do-playground-routes.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { isBlockedInPlayground } from "../../src/db/do-playground-routes.js";
|
||||
|
||||
describe("isBlockedInPlayground", () => {
|
||||
describe("blocked routes", () => {
|
||||
it.each([
|
||||
// Auth routes
|
||||
"/_emdash/api/auth/",
|
||||
"/_emdash/api/auth/passkey/options",
|
||||
"/_emdash/api/auth/passkey/verify",
|
||||
"/_emdash/api/auth/dev-bypass",
|
||||
"/_emdash/api/auth/magic-link/send",
|
||||
"/_emdash/api/auth/logout",
|
||||
// Setup routes
|
||||
"/_emdash/api/setup/",
|
||||
"/_emdash/api/setup/status",
|
||||
"/_emdash/api/setup/admin",
|
||||
"/_emdash/api/setup/dev-bypass",
|
||||
// OAuth routes
|
||||
"/_emdash/api/oauth/",
|
||||
"/_emdash/api/oauth/authorize",
|
||||
"/_emdash/api/oauth/token",
|
||||
// Token management
|
||||
"/_emdash/api/tokens/",
|
||||
"/_emdash/api/tokens/abc123",
|
||||
// User invite
|
||||
"/_emdash/api/users/invite",
|
||||
// Plugin install/marketplace
|
||||
"/_emdash/api/plugins/install",
|
||||
"/_emdash/api/plugins/marketplace",
|
||||
"/_emdash/api/plugins/marketplace/featured",
|
||||
// Media upload (abuse vector)
|
||||
"/_emdash/api/media/upload",
|
||||
// Snapshot export
|
||||
"/_emdash/api/snapshot",
|
||||
"/_emdash/api/snapshot?drafts=true",
|
||||
])("blocks %s", (path: string) => {
|
||||
expect(isBlockedInPlayground(path)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowed routes", () => {
|
||||
it.each([
|
||||
// Site pages
|
||||
"/",
|
||||
"/blog/my-post",
|
||||
"/about",
|
||||
"/sitemap.xml",
|
||||
// Admin UI
|
||||
"/_emdash/admin",
|
||||
"/_emdash/admin/content",
|
||||
"/_emdash/admin/content/posts",
|
||||
"/_emdash/admin/settings",
|
||||
"/_emdash/admin/media",
|
||||
"/_emdash/admin/schema",
|
||||
// Auth allowlist (admin UI needs /auth/me)
|
||||
"/_emdash/api/auth/me",
|
||||
// Content CRUD (the whole point of the playground)
|
||||
"/_emdash/api/content/posts",
|
||||
"/_emdash/api/content/posts/abc123",
|
||||
// Schema editing
|
||||
"/_emdash/api/schema",
|
||||
"/_emdash/api/schema/collections",
|
||||
"/_emdash/api/schema/collections/posts/fields",
|
||||
// Taxonomies
|
||||
"/_emdash/api/taxonomies",
|
||||
"/_emdash/api/taxonomies/category/terms",
|
||||
// Menus
|
||||
"/_emdash/api/menus",
|
||||
"/_emdash/api/menus/primary/items",
|
||||
// Widgets
|
||||
"/_emdash/api/widgets",
|
||||
// Search
|
||||
"/_emdash/api/search",
|
||||
"/_emdash/api/search/suggest",
|
||||
// Settings (read/write)
|
||||
"/_emdash/api/settings",
|
||||
// Dashboard
|
||||
"/_emdash/api/dashboard",
|
||||
// Manifest
|
||||
"/_emdash/api/manifest",
|
||||
// Media listing (not upload)
|
||||
"/_emdash/api/media",
|
||||
"/_emdash/api/media/abc123",
|
||||
"/_emdash/api/media/file/image.jpg",
|
||||
// Users list (not invite)
|
||||
"/_emdash/api/users",
|
||||
"/_emdash/api/users/abc123",
|
||||
// Plugin list (not install/marketplace)
|
||||
"/_emdash/api/plugins",
|
||||
"/_emdash/api/plugins/my-plugin",
|
||||
])("allows %s", (path: string) => {
|
||||
expect(isBlockedInPlayground(path)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
94
packages/cloudflare/tests/db/do-preview-sign.test.ts
Normal file
94
packages/cloudflare/tests/db/do-preview-sign.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { signPreviewUrl, verifyPreviewSignature } from "../../src/db/do-preview-sign.js";
|
||||
|
||||
const SECRET = "test-secret-key";
|
||||
const DIGITS = /^\d+$/;
|
||||
const HEX_64 = /^[0-9a-f]{64}$/;
|
||||
|
||||
describe("signPreviewUrl", () => {
|
||||
it("returns a URL with source, exp, and sig params", async () => {
|
||||
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
|
||||
const parsed = new URL(url);
|
||||
|
||||
expect(parsed.origin).toBe("https://preview.example.com");
|
||||
expect(parsed.searchParams.get("source")).toBe("https://mysite.com");
|
||||
expect(parsed.searchParams.get("exp")).toMatch(DIGITS);
|
||||
expect(parsed.searchParams.get("sig")).toMatch(HEX_64);
|
||||
});
|
||||
|
||||
it("sets expiry based on ttl", async () => {
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const url = await signPreviewUrl(
|
||||
"https://preview.example.com",
|
||||
"https://mysite.com",
|
||||
SECRET,
|
||||
7200,
|
||||
);
|
||||
const after = Math.floor(Date.now() / 1000);
|
||||
|
||||
const exp = Number(new URL(url).searchParams.get("exp"));
|
||||
expect(exp).toBeGreaterThanOrEqual(before + 7200);
|
||||
expect(exp).toBeLessThanOrEqual(after + 7200);
|
||||
});
|
||||
|
||||
it("defaults to 1 hour TTL", async () => {
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
|
||||
const exp = Number(new URL(url).searchParams.get("exp"));
|
||||
expect(exp).toBeGreaterThanOrEqual(before + 3600);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPreviewSignature", () => {
|
||||
it("verifies a signature produced by signPreviewUrl", async () => {
|
||||
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
|
||||
const parsed = new URL(url);
|
||||
const source = parsed.searchParams.get("source")!;
|
||||
const exp = Number(parsed.searchParams.get("exp"));
|
||||
const sig = parsed.searchParams.get("sig")!;
|
||||
|
||||
expect(await verifyPreviewSignature(source, exp, sig, SECRET)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a wrong secret", async () => {
|
||||
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
|
||||
const parsed = new URL(url);
|
||||
const source = parsed.searchParams.get("source")!;
|
||||
const exp = Number(parsed.searchParams.get("exp"));
|
||||
const sig = parsed.searchParams.get("sig")!;
|
||||
|
||||
expect(await verifyPreviewSignature(source, exp, sig, "wrong-secret")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a tampered source", async () => {
|
||||
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
|
||||
const parsed = new URL(url);
|
||||
const exp = Number(parsed.searchParams.get("exp"));
|
||||
const sig = parsed.searchParams.get("sig")!;
|
||||
|
||||
expect(await verifyPreviewSignature("https://evil.com", exp, sig, SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a tampered expiry", async () => {
|
||||
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
|
||||
const parsed = new URL(url);
|
||||
const source = parsed.searchParams.get("source")!;
|
||||
const sig = parsed.searchParams.get("sig")!;
|
||||
|
||||
expect(await verifyPreviewSignature(source, 9999999999, sig, SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a tampered signature", async () => {
|
||||
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
|
||||
const parsed = new URL(url);
|
||||
const source = parsed.searchParams.get("source")!;
|
||||
const exp = Number(parsed.searchParams.get("exp"));
|
||||
|
||||
expect(await verifyPreviewSignature(source, exp, "a".repeat(64), SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a signature with wrong length", async () => {
|
||||
expect(await verifyPreviewSignature("https://x.com", 123, "tooshort", SECRET)).toBe(false);
|
||||
});
|
||||
});
|
||||
61
packages/cloudflare/tests/db/do-preview.test.ts
Normal file
61
packages/cloudflare/tests/db/do-preview.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { isBlockedInPreview } from "../../src/db/do-preview-routes.js";
|
||||
|
||||
describe("isBlockedInPreview", () => {
|
||||
it.each([
|
||||
// Admin UI
|
||||
"/_emdash/admin",
|
||||
"/_emdash/admin/content",
|
||||
"/_emdash/admin/settings",
|
||||
// Auth endpoints
|
||||
"/_emdash/api/auth/passkey/options",
|
||||
"/_emdash/api/auth/passkey/verify",
|
||||
"/_emdash/api/auth/dev-bypass",
|
||||
"/_emdash/api/auth/magic-link/send",
|
||||
"/_emdash/api/auth/oauth/github",
|
||||
"/_emdash/api/auth/oauth/github/callback",
|
||||
"/_emdash/api/auth/me",
|
||||
"/_emdash/api/auth/logout",
|
||||
// Setup endpoints
|
||||
"/_emdash/api/setup/status",
|
||||
"/_emdash/api/setup/dev-bypass",
|
||||
"/_emdash/api/setup/dev-reset",
|
||||
"/_emdash/api/setup/admin",
|
||||
// Write endpoints (plugins, users, settings, imports)
|
||||
"/_emdash/api/plugins/install",
|
||||
"/_emdash/api/users",
|
||||
"/_emdash/api/settings",
|
||||
"/_emdash/api/import",
|
||||
// Any unknown /_emdash/ path
|
||||
"/_emdash/api/unknown-future-endpoint",
|
||||
"/_emdash/anything",
|
||||
])("blocks %s", (path: string) => {
|
||||
expect(isBlockedInPreview(path)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// Site pages (not /_emdash/)
|
||||
"/",
|
||||
"/blog/my-post",
|
||||
"/about",
|
||||
"/sitemap.xml",
|
||||
"/robots.txt",
|
||||
// Allowlisted read-only API routes
|
||||
"/_emdash/api/content/posts",
|
||||
"/_emdash/api/content/posts/abc123",
|
||||
"/_emdash/api/schema",
|
||||
"/_emdash/api/schema/collections",
|
||||
"/_emdash/api/manifest",
|
||||
"/_emdash/api/dashboard",
|
||||
"/_emdash/api/search",
|
||||
"/_emdash/api/search/suggest",
|
||||
"/_emdash/api/media",
|
||||
"/_emdash/api/media/file/image.jpg",
|
||||
"/_emdash/api/taxonomies",
|
||||
"/_emdash/api/menus",
|
||||
"/_emdash/api/snapshot",
|
||||
])("allows %s", (path: string) => {
|
||||
expect(isBlockedInPreview(path)).toBe(false);
|
||||
});
|
||||
});
|
||||
44
packages/cloudflare/tests/db/playground-dialect.test.ts
Normal file
44
packages/cloudflare/tests/db/playground-dialect.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Kysely } from "kysely";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { PreviewDODialect } from "../../src/db/do-dialect.js";
|
||||
import type { PreviewDBStub } from "../../src/db/do-dialect.js";
|
||||
|
||||
/**
|
||||
* Recreates the playground's dummy dialect logic inline to avoid
|
||||
* importing playground.ts which re-exports do-class.ts (cloudflare:workers).
|
||||
*/
|
||||
function createTestDialect() {
|
||||
const notInitialized: PreviewDBStub = {
|
||||
async query(): Promise<{ rows: Record<string, unknown>[] }> {
|
||||
throw new Error(
|
||||
"Playground database not initialized. " +
|
||||
"Ensure the playground middleware is registered in src/middleware.ts " +
|
||||
"and all requests go through it.",
|
||||
);
|
||||
},
|
||||
};
|
||||
return new PreviewDODialect({ getStub: () => notInitialized });
|
||||
}
|
||||
|
||||
describe("playground dummy dialect", () => {
|
||||
it("creates a dialect without throwing", () => {
|
||||
const dialect = createTestDialect();
|
||||
expect(dialect).toBeDefined();
|
||||
expect(dialect.createAdapter).toBeTypeOf("function");
|
||||
expect(dialect.createDriver).toBeTypeOf("function");
|
||||
expect(dialect.createQueryCompiler).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("throws when a query is executed (no middleware ALS override)", async () => {
|
||||
const dialect = createTestDialect();
|
||||
const db = new Kysely<any>({ dialect });
|
||||
|
||||
await expect(
|
||||
db
|
||||
.selectFrom("users" as any)
|
||||
.selectAll()
|
||||
.execute(),
|
||||
).rejects.toThrow("Playground database not initialized");
|
||||
});
|
||||
});
|
||||
73
packages/cloudflare/tests/db/playground-toolbar.test.ts
Normal file
73
packages/cloudflare/tests/db/playground-toolbar.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { renderPlaygroundToolbar } from "../../src/db/playground-toolbar.js";
|
||||
|
||||
const BASE_CONFIG = {
|
||||
createdAt: "2026-03-16T12:00:00.000Z",
|
||||
ttl: 3600,
|
||||
editMode: false,
|
||||
};
|
||||
|
||||
describe("renderPlaygroundToolbar", () => {
|
||||
it("renders HTML with data attributes", () => {
|
||||
const html = renderPlaygroundToolbar(BASE_CONFIG);
|
||||
|
||||
expect(html).toContain('id="emdash-playground-toolbar"');
|
||||
expect(html).toContain('data-created-at="2026-03-16T12:00:00.000Z"');
|
||||
expect(html).toContain('data-ttl="3600"');
|
||||
});
|
||||
|
||||
it("renders the playground badge", () => {
|
||||
const html = renderPlaygroundToolbar(BASE_CONFIG);
|
||||
expect(html).toContain("Playground");
|
||||
});
|
||||
|
||||
it("renders the deploy CTA link", () => {
|
||||
const html = renderPlaygroundToolbar(BASE_CONFIG);
|
||||
expect(html).toContain("Deploy your own");
|
||||
expect(html).toContain("github.com/emdash-cms/emdash");
|
||||
});
|
||||
|
||||
it("renders reset and dismiss buttons", () => {
|
||||
const html = renderPlaygroundToolbar(BASE_CONFIG);
|
||||
expect(html).toContain('id="ec-pg-reset"');
|
||||
expect(html).toContain('id="ec-pg-dismiss"');
|
||||
expect(html).toContain("/_playground/reset");
|
||||
});
|
||||
|
||||
it("escapes HTML in data attributes", () => {
|
||||
const html = renderPlaygroundToolbar({
|
||||
...BASE_CONFIG,
|
||||
createdAt: '"<script>alert(1)</script>',
|
||||
});
|
||||
|
||||
expect(html).toContain("<script>alert(1)</script>");
|
||||
expect(html).not.toContain('data-created-at="<script>');
|
||||
});
|
||||
|
||||
it("includes countdown script", () => {
|
||||
const html = renderPlaygroundToolbar(BASE_CONFIG);
|
||||
expect(html).toContain("<script>");
|
||||
expect(html).toContain("getRemaining");
|
||||
expect(html).toContain("formatRemaining");
|
||||
});
|
||||
|
||||
it("renders edit toggle unchecked when editMode is false", () => {
|
||||
const html = renderPlaygroundToolbar({ ...BASE_CONFIG, editMode: false });
|
||||
expect(html).toContain('id="ec-pg-edit-toggle"');
|
||||
expect(html).toContain('data-edit-mode="false"');
|
||||
expect(html).not.toContain('id="ec-pg-edit-toggle" checked');
|
||||
});
|
||||
|
||||
it("renders edit toggle checked when editMode is true", () => {
|
||||
const html = renderPlaygroundToolbar({ ...BASE_CONFIG, editMode: true });
|
||||
expect(html).toContain('data-edit-mode="true"');
|
||||
expect(html).toContain('id="ec-pg-edit-toggle" checked');
|
||||
});
|
||||
|
||||
it("includes edit mode hover styles for data-emdash-ref elements", () => {
|
||||
const html = renderPlaygroundToolbar(BASE_CONFIG);
|
||||
expect(html).toContain("[data-emdash-ref]");
|
||||
expect(html).toContain('data-edit-mode="true"');
|
||||
});
|
||||
});
|
||||
35
packages/cloudflare/tests/do-config.test.ts
Normal file
35
packages/cloudflare/tests/do-config.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { previewDatabase, playgroundDatabase } from "../src/index.js";
|
||||
|
||||
describe("previewDatabase()", () => {
|
||||
it("returns a sqlite DatabaseDescriptor with the DO entrypoint", () => {
|
||||
const result = previewDatabase({ binding: "PREVIEW_DB" });
|
||||
expect(result).toEqual({
|
||||
entrypoint: "@emdash-cms/cloudflare/db/do",
|
||||
config: { binding: "PREVIEW_DB" },
|
||||
type: "sqlite",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes binding through to config", () => {
|
||||
const result = previewDatabase({ binding: "MY_PREVIEW" });
|
||||
expect(result.config).toEqual({ binding: "MY_PREVIEW" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("playgroundDatabase()", () => {
|
||||
it("returns a sqlite DatabaseDescriptor with the playground entrypoint", () => {
|
||||
const result = playgroundDatabase({ binding: "PLAYGROUND_DB" });
|
||||
expect(result).toEqual({
|
||||
entrypoint: "@emdash-cms/cloudflare/db/playground",
|
||||
config: { binding: "PLAYGROUND_DB" },
|
||||
type: "sqlite",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes binding through to config", () => {
|
||||
const result = playgroundDatabase({ binding: "MY_PLAYGROUND" });
|
||||
expect(result.config).toEqual({ binding: "MY_PLAYGROUND" });
|
||||
});
|
||||
});
|
||||
479
packages/cloudflare/tests/sandbox/bridge-http.test.ts
Normal file
479
packages/cloudflare/tests/sandbox/bridge-http.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user