Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-admin-csp-external-images.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 3 additions & 33 deletions packages/core/src/astro/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -50,35 +51,6 @@ 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("; ");
}

/**
* API routes that skip auth — each handles its own access control.
*
Expand Down Expand Up @@ -239,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;
}
Expand All @@ -249,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;
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/astro/middleware/csp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* 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.
*
* 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(): string {
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 'self' https: data: blob:",
"object-src 'none'",
"base-uri 'self'",
].join("; ");
}
30 changes: 30 additions & 0 deletions packages/core/tests/unit/middleware/csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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();
const imgSrc = csp.split("; ").find((d) => d.startsWith("img-src"));
expect(imgSrc).toContain("https:");
});

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:");
});

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'");
});
});
Loading