Skip to content

fix(core): allow external HTTPS images in admin CSP#352

Open
barckcode wants to merge 3 commits intoemdash-cms:mainfrom
barckcode:fix/admin-csp-external-images
Open

fix(core): allow external HTTPS images in admin CSP#352
barckcode wants to merge 3 commits intoemdash-cms:mainfrom
barckcode:fix/admin-csp-external-images

Conversation

@barckcode
Copy link
Copy Markdown

@barckcode barckcode commented Apr 7, 2026

What does this PR do?

The admin UI's Content Security Policy blocks external images referenced in content. When users have content with images from external HTTPS origins (e.g. blog.helmcode.com, media.tenor.com), the browser blocks them because img-src only allows 'self' data: blob:.

This adds https: to the img-src CSP directive so the admin can display images from any HTTPS origin. This is standard practice for CMS admin UIs that manage user content with external image references (WordPress, Ghost, Sanity Studio all allow this).

Also extracts buildEmDashCsp() into its own module (middleware/csp.ts) for unit testability.

Error reproduced in production:

Loading the image 'https://blog.helmcode.com/content/images/2025/06/image.png'
violates the following Content Security Policy directive: "img-src 'self' data: blob:".

Related: #205 (similar CSP issue for connect-src with S3 storage)

Type of change

  • Bug fix
  • Feature (requires approved Discussion)
  • Refactor (no behavior change)
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm --silent lint:json | jq '.diagnostics | length' returns 0
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code

Screenshots / test output

image

CSP header before fix:

img-src 'self' data: blob:

CSP header after fix:

img-src 'self' https: data: blob:

Test output:

 ✓ tests/unit/middleware/csp.test.ts (3 tests) 1ms
   ✓ includes https: in img-src to allow external images
   ✓ includes https: in img-src even with a marketplace URL
   ✓ still includes self, data:, and blob: in img-src

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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 7, 2026 13:31
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 7, 2026

🦋 Changeset detected

Latest commit: 2bfbabb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-ai-moderation Patch
@emdash-cms/plugin-atproto Patch
@emdash-cms/plugin-audit-log Patch
@emdash-cms/plugin-color Patch
@emdash-cms/plugin-embeds Patch
@emdash-cms/plugin-forms Patch
@emdash-cms/plugin-webhook-notifier Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the admin/API Content Security Policy (CSP) used on /_emdash routes to allow loading images from external HTTPS origins, and factors CSP construction into a dedicated middleware module with unit coverage.

Changes:

  • Add https: to the img-src directive so admin-rendered content can display externally hosted HTTPS images.
  • Extract buildEmDashCsp() into packages/core/src/astro/middleware/csp.ts for easier testing/reuse.
  • Add unit tests covering the updated CSP img-src behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
packages/core/src/astro/middleware/csp.ts New module containing buildEmDashCsp() with updated img-src sources.
packages/core/src/astro/middleware/auth.ts Switch middleware to import CSP builder from the new module (and re-export it).
packages/core/tests/unit/middleware/csp.test.ts New unit tests asserting https: is present in img-src and marketplace behavior remains.
.changeset/fix-admin-csp-external-images.md Patch changeset describing the CSP change for the emdash package.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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 <noreply@anthropic.com>
@barckcode
Copy link
Copy Markdown
Author

Note: This PR overlaps with #226 (CSP fix for S3 storage connect-src). Both extract buildEmDashCsp() into middleware/csp.ts. Whichever merges second will need a trivial conflict resolution. The two fixes are complementary: #226 adds the storage origin to connect-src + img-src, this PR adds https: to img-src for external content images.

@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented Apr 7, 2026

This effectively removes CSP restrictions for images, which is a legitimate thing to suggest, but in this scenario we might as well remove the CSP rules for them entirely including special handling for the marketplace etc. The very strict CSP was originally done in order to help prevent malicious plugins using it as a vector to exfiltrate user data. This was before we introduced block kit for sandboxed plugins, so may no longer be needed, but I would like to investigate it a bit more.

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 <noreply@anthropic.com>
@barckcode
Copy link
Copy Markdown
Author

Hey @ascorbic, thanks for the feedback. I dug into the codebase to understand the full security model before suggesting a path forward.

Why relaxing img-src is safe

The original strict CSP was designed to prevent malicious plugins from exfiltrating data. After reviewing the current architecture, the plugin isolation doesn't rely on browser CSP at all. There are 5 layers before CSP even comes into play:

  1. V8 Isolate (runner.ts:258) -- globalOutbound: null blocks all direct network access. Plugins have zero DOM access.
  2. PluginBridge RPC -- All host operations go through a service binding, not browser requests.
  3. Capability gating -- Plugins must declare network:fetch and only get what they request.
  4. Host validation (bridge.ts:818-833) -- Even with network:fetch, requests are checked against allowedHosts patterns.
  5. CSP connect-src 'self' -- Blocks any browser-level fetch()/XHR/WebSocket to external origins.

The only exfiltration vector via img-src would be pixel tracking (encoding data in a URL loaded as <img>), but that requires DOM access, which plugins don't have in V8 isolates. And even for admin UI code, connect-src 'self' already blocks the more dangerous fetch-based exfiltration.

The marketplaceUrl in img-src was already redundant

Beyond https: making it redundant, the browser never loads images directly from the marketplace origin. Icons and thumbnails are served through server-side proxies:

  • /_emdash/api/admin/plugins/marketplace/[id]/icon
  • /_emdash/api/admin/themes/marketplace/[id]/thumbnail

These fetch from the marketplace on the server and return the content as 'self' origin responses. So the marketplaceUrl in img-src was never actually needed for marketplace images to render.

I've pushed the simplification -- removed the marketplaceUrl parameter entirely and made img-src fully static. The current code in this PR now only addresses the img-src change.

Suggested next step: align with #226 on connect-src

This also aligns with #226 (S3 storage CSP fix by @WebDevByCam). Both PRs overlap on middleware/csp.ts, but they're complementary. If you'd like, I can extend this PR to also incorporate the storageEndpoint for connect-src from #226, resulting in a single clean function:

function buildEmDashCsp(storageEndpoint?: string): string {
    const connectSources = ["'self'"];
    if (storageEndpoint) {
        try {
            connectSources.push(new URL(storageEndpoint).origin);
        } catch { /* ignore invalid URL */ }
    }

    return [
        "default-src 'self'",
        "script-src 'self' 'unsafe-inline'",
        "style-src 'self' 'unsafe-inline'",
        `connect-src ${connectSources.join(" ")}`,
        "form-action 'self'",
        "frame-ancestors 'none'",
        "img-src 'self' https: data: blob:",
        "object-src 'none'",
        "base-uri 'self'",
    ].join("; ");
}

This would:

  • marketplaceUrl param removed -- doubly redundant: https: already covers any HTTPS origin, and marketplace images are served through server-side proxies ('self' origin) anyway
  • storageEndpoint param kept but only for connect-src -- S3 uploads need fetch() access to signed URLs, which connect-src 'self' blocks. This is the actual security-relevant directive. Its img-src entry becomes redundant with https:
  • img-src becomes fully static -- no per-origin logic needed, cleaner code
  • Critical directives untouched -- connect-src, script-src, default-src, frame-ancestors stay strict
  • Media files keep their own strict sandbox CSP (file/[key].ts:50-53)

Happy to add the storageEndpoint/connect-src change to this PR if you'd like to consolidate both fixes. It would also reduce the merge conflict surface with #226. What do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants