From f1cc1df642d2c7b809c70182f534bb3dcb9265ad Mon Sep 17 00:00:00 2001 From: barckcode Date: Tue, 7 Apr 2026 15:31:02 +0200 Subject: [PATCH 1/3] fix(core): allow external HTTPS images in admin CSP The admin UI's Content Security Policy blocked external images referenced in content (e.g. blog.helmcode.com, media.tenor.com) because img-src only allowed 'self', data:, and blob:. Add https: to the img-src directive so the admin can display images from any HTTPS origin. This is standard for CMS admin UIs that manage user content with external image references. Extract buildEmDashCsp() into its own module for testability. Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-admin-csp-external-images.md | 5 ++++ packages/core/src/astro/middleware/auth.ts | 30 ++----------------- packages/core/src/astro/middleware/csp.ts | 28 +++++++++++++++++ .../core/tests/unit/middleware/csp.test.ts | 28 +++++++++++++++++ 4 files changed, 63 insertions(+), 28 deletions(-) create mode 100644 .changeset/fix-admin-csp-external-images.md create mode 100644 packages/core/src/astro/middleware/csp.ts create mode 100644 packages/core/tests/unit/middleware/csp.test.ts diff --git a/.changeset/fix-admin-csp-external-images.md b/.changeset/fix-admin-csp-external-images.md new file mode 100644 index 00000000..4985aae9 --- /dev/null +++ b/.changeset/fix-admin-csp-external-images.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Allows external HTTPS images in the admin UI by adding `https:` to the `img-src` CSP directive. Fixes external content images (e.g. from migration or external hosting) being blocked in the content editor. diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 76228d1c..d3ff6a01 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -30,6 +30,7 @@ import { hasScope } from "../../auth/api-tokens.js"; import { getAuthMode, type ExternalAuthMode } from "../../auth/mode.js"; import type { ExternalAuthConfig } from "../../auth/types.js"; import type { EmDashHandlers, EmDashManifest } from "../types.js"; +import { buildEmDashCsp } from "./csp.js"; declare global { namespace App { @@ -50,34 +51,7 @@ declare global { // Role level constants (matching @emdash-cms/auth) const ROLE_ADMIN = 50; -/** - * Strict Content-Security-Policy for /_emdash routes (admin + API). - * - * Applied via middleware header rather than Astro's built-in CSP because - * Astro's auto-hashing defeats 'unsafe-inline' (CSP3 ignores 'unsafe-inline' - * when hashes are present), which would break user-facing pages. - */ -function buildEmDashCsp(marketplaceUrl?: string): string { - const imgSources = ["'self'", "data:", "blob:"]; - if (marketplaceUrl) { - try { - imgSources.push(new URL(marketplaceUrl).origin); - } catch { - // ignore invalid marketplace URL - } - } - return [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline'", - "style-src 'self' 'unsafe-inline'", - "connect-src 'self'", - "form-action 'self'", - "frame-ancestors 'none'", - `img-src ${imgSources.join(" ")}`, - "object-src 'none'", - "base-uri 'self'", - ].join("; "); -} +export { buildEmDashCsp }; /** * API routes that skip auth — each handles its own access control. diff --git a/packages/core/src/astro/middleware/csp.ts b/packages/core/src/astro/middleware/csp.ts new file mode 100644 index 00000000..7f6811b0 --- /dev/null +++ b/packages/core/src/astro/middleware/csp.ts @@ -0,0 +1,28 @@ +/** + * Strict Content-Security-Policy for /_emdash routes (admin + API). + * + * Applied via middleware header rather than Astro's built-in CSP because + * Astro's auto-hashing defeats 'unsafe-inline' (CSP3 ignores 'unsafe-inline' + * when hashes are present), which would break user-facing pages. + */ +export function buildEmDashCsp(marketplaceUrl?: string): string { + const imgSources = ["'self'", "https:", "data:", "blob:"]; + if (marketplaceUrl) { + try { + imgSources.push(new URL(marketplaceUrl).origin); + } catch { + // ignore invalid marketplace URL + } + } + return [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "connect-src 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + `img-src ${imgSources.join(" ")}`, + "object-src 'none'", + "base-uri 'self'", + ].join("; "); +} diff --git a/packages/core/tests/unit/middleware/csp.test.ts b/packages/core/tests/unit/middleware/csp.test.ts new file mode 100644 index 00000000..14daf071 --- /dev/null +++ b/packages/core/tests/unit/middleware/csp.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; + +import { buildEmDashCsp } from "../../../src/astro/middleware/csp.js"; + +describe("buildEmDashCsp", () => { + it("includes https: in img-src to allow external images", () => { + const csp = buildEmDashCsp(); + expect(csp).toContain("img-src"); + // Extract the img-src directive + const imgSrc = csp.split("; ").find((d) => d.startsWith("img-src")); + expect(imgSrc).toContain("https:"); + }); + + it("includes https: in img-src even with a marketplace URL", () => { + const csp = buildEmDashCsp("https://marketplace.example.com/plugins"); + const imgSrc = csp.split("; ").find((d) => d.startsWith("img-src")); + expect(imgSrc).toContain("https:"); + expect(imgSrc).toContain("https://marketplace.example.com"); + }); + + it("still includes self, data:, and blob: in img-src", () => { + const csp = buildEmDashCsp(); + const imgSrc = csp.split("; ").find((d) => d.startsWith("img-src")); + expect(imgSrc).toContain("'self'"); + expect(imgSrc).toContain("data:"); + expect(imgSrc).toContain("blob:"); + }); +}); From e798b10cbb9d5f45574a66401a597d420ba43ddf Mon Sep 17 00:00:00 2001 From: barckcode Date: Tue, 7 Apr 2026 15:41:38 +0200 Subject: [PATCH 2/3] fix: remove unnecessary re-export of buildEmDashCsp The function is only used internally by auth.ts. No external consumers import it from this module. Avoids accidental public API surface expansion. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/astro/middleware/auth.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index d3ff6a01..7d91968d 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -51,8 +51,6 @@ declare global { // Role level constants (matching @emdash-cms/auth) const ROLE_ADMIN = 50; -export { buildEmDashCsp }; - /** * API routes that skip auth — each handles its own access control. * From 2bfbabb7835d69aa55a92ff5203a554de0ba4513 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 8 Apr 2026 15:17:26 +0100 Subject: [PATCH 3/3] fix(core): remove redundant marketplaceUrl from CSP img-src Simplify buildEmDashCsp() by removing the marketplaceUrl parameter. With https: already in img-src, per-origin marketplace logic is redundant. Additionally, marketplace images are served through server-side proxies (/_emdash/api/admin/plugins/marketplace/[id]/icon), so the browser never loads directly from the marketplace origin. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/astro/middleware/auth.ts | 6 ++---- packages/core/src/astro/middleware/csp.ts | 17 +++++++--------- .../core/tests/unit/middleware/csp.test.ts | 20 ++++++++++--------- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 7d91968d..27a63aa7 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -211,8 +211,7 @@ export const onRequest = defineMiddleware(async (context, next) => { const response = await next(); if (!import.meta.env.DEV) { - const marketplaceUrl = context.locals.emdash?.config.marketplace; - response.headers.set("Content-Security-Policy", buildEmDashCsp(marketplaceUrl)); + response.headers.set("Content-Security-Policy", buildEmDashCsp()); } return response; } @@ -221,8 +220,7 @@ export const onRequest = defineMiddleware(async (context, next) => { // Set strict CSP on all /_emdash responses (prod only) if (!import.meta.env.DEV) { - const marketplaceUrl = context.locals.emdash?.config.marketplace; - response.headers.set("Content-Security-Policy", buildEmDashCsp(marketplaceUrl)); + response.headers.set("Content-Security-Policy", buildEmDashCsp()); } return response; diff --git a/packages/core/src/astro/middleware/csp.ts b/packages/core/src/astro/middleware/csp.ts index 7f6811b0..0381dfb6 100644 --- a/packages/core/src/astro/middleware/csp.ts +++ b/packages/core/src/astro/middleware/csp.ts @@ -4,16 +4,13 @@ * Applied via middleware header rather than Astro's built-in CSP because * Astro's auto-hashing defeats 'unsafe-inline' (CSP3 ignores 'unsafe-inline' * when hashes are present), which would break user-facing pages. + * + * img-src allows any HTTPS origin because the admin renders user content that + * may reference external images (migrations, external hosting, embeds). + * Plugin security does not rely on img-src -- plugins run in V8 isolates with + * no DOM access, and connect-src 'self' blocks fetch-based exfiltration. */ -export function buildEmDashCsp(marketplaceUrl?: string): string { - const imgSources = ["'self'", "https:", "data:", "blob:"]; - if (marketplaceUrl) { - try { - imgSources.push(new URL(marketplaceUrl).origin); - } catch { - // ignore invalid marketplace URL - } - } +export function buildEmDashCsp(): string { return [ "default-src 'self'", "script-src 'self' 'unsafe-inline'", @@ -21,7 +18,7 @@ export function buildEmDashCsp(marketplaceUrl?: string): string { "connect-src 'self'", "form-action 'self'", "frame-ancestors 'none'", - `img-src ${imgSources.join(" ")}`, + "img-src 'self' https: data: blob:", "object-src 'none'", "base-uri 'self'", ].join("; "); diff --git a/packages/core/tests/unit/middleware/csp.test.ts b/packages/core/tests/unit/middleware/csp.test.ts index 14daf071..1ec0b3dc 100644 --- a/packages/core/tests/unit/middleware/csp.test.ts +++ b/packages/core/tests/unit/middleware/csp.test.ts @@ -5,19 +5,10 @@ import { buildEmDashCsp } from "../../../src/astro/middleware/csp.js"; describe("buildEmDashCsp", () => { it("includes https: in img-src to allow external images", () => { const csp = buildEmDashCsp(); - expect(csp).toContain("img-src"); - // Extract the img-src directive const imgSrc = csp.split("; ").find((d) => d.startsWith("img-src")); expect(imgSrc).toContain("https:"); }); - it("includes https: in img-src even with a marketplace URL", () => { - const csp = buildEmDashCsp("https://marketplace.example.com/plugins"); - const imgSrc = csp.split("; ").find((d) => d.startsWith("img-src")); - expect(imgSrc).toContain("https:"); - expect(imgSrc).toContain("https://marketplace.example.com"); - }); - it("still includes self, data:, and blob: in img-src", () => { const csp = buildEmDashCsp(); const imgSrc = csp.split("; ").find((d) => d.startsWith("img-src")); @@ -25,4 +16,15 @@ describe("buildEmDashCsp", () => { expect(imgSrc).toContain("data:"); expect(imgSrc).toContain("blob:"); }); + + it("keeps connect-src restricted to self", () => { + const csp = buildEmDashCsp(); + const connectSrc = csp.split("; ").find((d) => d.startsWith("connect-src")); + expect(connectSrc).toBe("connect-src 'self'"); + }); + + it("blocks framing with frame-ancestors none", () => { + const csp = buildEmDashCsp(); + expect(csp).toContain("frame-ancestors 'none'"); + }); });