Skip to content

fix(plugins): require manage + CSRF on private plugin routes regardless of HTTP method#1863

Merged
ascorbic merged 1 commit into
emdash-cms:mainfrom
swissky:fix/plugin-route-method-auth
Jul 7, 2026
Merged

fix(plugins): require manage + CSRF on private plugin routes regardless of HTTP method#1863
ascorbic merged 1 commit into
emdash-cms:mainfrom
swissky:fix/plugin-route-method-auth

Conversation

@swissky

@swissky swissky commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #1853 — a privilege escalation / CSRF hole on the plugin API catch-all (/_emdash/api/plugins/{id}/*).

Plugin routes dispatch by name only — the HTTP method never selects a different handler, so a route reached via GET or HEAD runs exactly the same code (and side effects) as one reached via POST. But the catch-all tiered permission by method:

  • GET/HEAD/OPTIONSplugins:read (editor) + CSRF skipped
  • everything else → plugins:manage (admin) + CSRF required

So an editor could invoke an admin-only, state-changing plugin route by sending it as GET, and a cross-origin page could reach it via HEAD (a CORS-safe method that Astro dispatches to the GET export, then strips the body — the handler still runs). The reporter verified this end-to-end on 0.25.1: HEAD /_emdash/api/plugins/<id>/updateHomeConfig returned 200 and overwrote stored plugin state.

The fix stops trusting the method as a read/write signal: every private plugin invocation now requires plugins:manage and the X-EmDash-Request CSRF header, regardless of method. Public routes (public: true) are unchanged — they still skip auth and CSRF as before.

Behavior note: the plugins:read/GET tier is removed. In practice every first-party admin caller (plugin admin pages, field optionsRoute) already uses POST, which already required plugins:manage, so this doesn't regress existing flows — it only closes the method-chosen bypass. A per-route method/permission declaration (issue suggestion 2/3) would be a larger, additive change; happy to go that way instead if maintainers prefer to keep a read tier.

Type of change

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

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (n/a — no UI strings)
  • I have added a changeset
  • New features link to an approved Discussion (n/a — bug fix)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Fable 5

Screenshots / test output

New regression test tests/unit/astro/plugin-api-route-auth.test.ts (7 cases). The GET- and HEAD-as-editor cases fail on main and pass with the fix (verified by reverting the source change):

Tests  7 passed (7)   # with fix
Tests  3 failed | 4 passed (7)   # source reverted, test kept

…ss of method

Plugin API routes dispatch by name only — the HTTP method never selects a
different handler — so a route reached via GET or HEAD runs exactly the same
code and side effects as one reached via POST. The catch-all tiered
permission by method (GET/HEAD/OPTIONS -> plugins:read, else plugins:manage)
and skipped CSRF for the read tier, so an editor (plugins:read) could invoke
an admin-only mutating route by choosing GET, and a cross-origin page could
reach it via HEAD (CORS-safe, and Astro dispatches HEAD to the GET export).

Gate every private invocation on plugins:manage and require the CSRF header
regardless of method. Public routes are unchanged.

Fixes emdash-cms#1853
@changeset-bot

changeset-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7c6b4a2

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

This PR includes changesets to release 16 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds 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

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/core size/M labels Jul 7, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1863

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1863

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1863

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1863

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1863

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1863

emdash

npm i https://pkg.pr.new/emdash@1863

create-emdash

npm i https://pkg.pr.new/create-emdash@1863

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1863

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1863

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1863

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1863

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1863

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1863

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1863

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1863

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1863

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1863

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1863

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1863

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1863

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1863

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1863

commit: 7c6b4a2

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a clean, narrowly-scoped security fix for #1853.

Approach: Correct. Plugin routes dispatch by route name, not by HTTP method, so gating GET/HEAD/OPTIONS on plugins:read and skipping CSRF allowed editors (and cross-origin pages via CORS-safe HEAD) to invoke state-changing admin routes. Removing the method-based tier and requiring plugins:manage + the X-EmDash-Request CSRF header for every private invocation is the right fix. Public routes remain unauthenticated as designed.

What I checked:

  • The catch-all route now requires plugins:manage, the admin token scope, and CSRF for all non-public invocations regardless of method.
  • HEAD dispatches to GET in Astro, so the GET-export test correctly covers the HEAD bypass vector.
  • Token-authenticated requests retain the CSRF exemption because they are not ambient credentials.
  • Error response codes (401/403) and the apiError envelope shape follow project conventions.
  • The new regression test has 7 cases that exercise editor GET/HEAD/POST, admin-with/without CSRF, and public-route behavior.
  • The changeset is present, present-tense, user-facing, and clearly frames the breaking security behavior change.

No blocking issues, style problems, or convention violations found.

@ascorbic ascorbic merged commit 9e4701e into emdash-cms:main Jul 7, 2026
47 checks passed
@emdashbot emdashbot Bot mentioned this pull request Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core review/needs-review No maintainer or bot review yet size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: plugin API routes execute on HTTP HEAD with plugins:read + no CSRF (editors can invoke admin/mutating routes)

2 participants