diff --git a/.changeset/plugin-route-raw-body.md b/.changeset/plugin-route-raw-body.md new file mode 100644 index 0000000000..e469672f89 --- /dev/null +++ b/.changeset/plugin-route-raw-body.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds opt-in raw request body access for native plugin routes: set `rawBody: true` on a route to receive the unparsed body as `ctx.rawBody`, enabling webhook signature verification and non-JSON payloads. diff --git a/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx b/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx index e5d42847fa..3738cd3f01 100644 --- a/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx +++ b/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx @@ -151,6 +151,29 @@ Key details of this configuration: - **`id`, `version`, and `capabilities` appear twice.** Once on the descriptor, once on `definePlugin()`. They should match. The descriptor's copy is what `astro.config.mjs` sees at build time; the `definePlugin()` copy is what runs at request time. - **Native route handlers take a single argument** — `(ctx: RouteContext)` where `ctx.input`, `ctx.request`, and `ctx.requestMeta` are merged with the regular `PluginContext` properties. This is the opposite of standard format's two-argument shape. See [API routes](/plugins/creating-plugins/api-routes/) for the full surface (everything else is identical). +## Raw request bodies (webhook signatures) + +The dispatcher parses the request body before your handler runs and exposes it as `ctx.input`; the body stream is consumed, so `ctx.request.text()` is unavailable. That's fine for normal routes — but webhook providers (Stripe, GitHub, Svix, …) sign the payload _exactly as delivered_, and an HMAC computed over a re-serialized `ctx.input` will never match (whitespace and key order don't survive a parse/stringify round-trip). + +Routes that need the unparsed body opt in with `rawBody: true`: + +```typescript +routes: { + "webhooks/provider": { + public: true, + rawBody: true, + handler: async (ctx) => { + const signature = ctx.request.headers.get("X-Signature") ?? ""; + await verifyHmac(ctx.rawBody ?? "", signature, secret); // throw on mismatch + const event = JSON.parse(ctx.rawBody ?? "{}"); + // ... + }, + }, +}, +``` + +`ctx.rawBody` is the delivered body as a UTF-8 decoded string — webhook payloads are UTF-8 text in practice, so signature libraries that accept a string (or `new TextEncoder().encode(ctx.rawBody)`) work directly; binary bodies are not preserved byte-exactly. `ctx.input` still works as usual (parsed from the same buffer). Non-JSON payloads — e.g. form-encoded webhook deliveries — arrive with `ctx.input` undefined but `ctx.rawBody` intact, so the handler can parse them itself. The flag is native-format only; sandboxed plugins receive requests across a serialization boundary and don't support it yet. + ## Plugin id rules The `id` field must match `/^[a-z][a-z0-9_-]*$/` — start with a lowercase letter, then letters, digits, hyphens, or underscores. The id is used as a single path segment in plugin route URLs and as part of generated SQL identifiers for plugin storage indexes, so anything outside that pattern fails at runtime. The following values show which ids are accepted: diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index ecf6fa0c82..91ea195ab2 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -3342,14 +3342,20 @@ export class EmDashRuntime { const routeKey = path.replace(LEADING_SLASH_PATTERN, ""); + // Buffer the body as text so routes with `rawBody: true` can see the + // payload exactly as delivered — as a UTF-8 decoded string, which is + // what webhook signature verification needs in practice; parse JSON + // from the same buffer for `ctx.input`. let body: unknown = undefined; + let rawBody: string | undefined; try { - body = await request.json(); + rawBody = await request.text(); + if (rawBody) body = JSON.parse(rawBody); } catch { - // No body or not JSON + // No body or not JSON — rawBody (when read) is still passed through } - return routeRegistry.invoke(pluginId, routeKey, { request, body }); + return routeRegistry.invoke(pluginId, routeKey, { request, body, rawBody }); } // Check sandboxed (marketplace) plugins second diff --git a/packages/core/src/plugins/routes.ts b/packages/core/src/plugins/routes.ts index 24b02a58df..3969c20cee 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -38,7 +38,8 @@ function guardConsumedRequestBody(request: Request): Request { throw new Error( `[emdash] ctx.request.${prop}() is not available inside a plugin route handler: ` + `EmDash has already parsed the request body and exposes it as ctx.input. ` + - `Read ctx.input instead of ctx.request.${prop}().`, + `Read ctx.input instead of ctx.request.${prop}() — or set rawBody: true ` + + `on the route and read ctx.rawBody if you need the unparsed body.`, ); }; } @@ -78,6 +79,8 @@ export interface InvokeRouteOptions { request: Request; /** Request body (already parsed) */ body?: unknown; + /** Unparsed request body; forwarded to the handler only for routes with `rawBody: true` */ + rawBody?: string; } /** @@ -141,6 +144,8 @@ export class PluginRouteHandler { // (#1293). Metadata extraction uses the original request (headers only). request: guardConsumedRequestBody(options.request), requestMeta: extractRequestMeta(options.request, this.trustedProxyHeaders), + // Only routes that opt in see the raw body (signature verification). + rawBody: route.rawBody === true ? options.rawBody : undefined, }; // Execute handler diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 981172f0c1..d4366f3a0b 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -1160,6 +1160,15 @@ export interface RouteContext extends PluginContext { request: Request; /** Normalized request metadata (IP, user agent, geo) */ requestMeta: RequestMeta; + /** + * The unparsed request body as a UTF-8 decoded string. Only populated when + * the route sets `rawBody: true` — needed to verify webhook signatures, + * which are computed over the delivered payload (a re-serialized + * `ctx.input` never matches, since whitespace and key order don't survive + * a parse/stringify round-trip). Webhook payloads are UTF-8 text in + * practice; binary bodies are not preserved byte-exactly. + */ + rawBody?: string; } /** @@ -1173,6 +1182,12 @@ export interface PluginRoute { * Public routes skip session/token auth and CSRF checks. */ public?: boolean; + /** + * Expose the unparsed request body as `ctx.rawBody`, alongside the parsed + * `ctx.input`. Opt-in so the body string is only retained where a handler + * actually needs it (webhook signature verification, non-JSON payloads). + */ + rawBody?: boolean; /** Route handler */ handler: (ctx: RouteContext) => Promise; } diff --git a/packages/core/tests/integration/runtime/plugin-raw-body-route.test.ts b/packages/core/tests/integration/runtime/plugin-raw-body-route.test.ts new file mode 100644 index 0000000000..cffbd85161 --- /dev/null +++ b/packages/core/tests/integration/runtime/plugin-raw-body-route.test.ts @@ -0,0 +1,150 @@ +/** + * Trusted plugin routes with `rawBody: true` must receive the delivered + * body as a UTF-8 string on `ctx.rawBody`, alongside the parsed `ctx.input`. + * + * The dispatcher reads `request.text()` once, parses JSON from the same + * buffer into `ctx.input`, and only surfaces `rawBody` to opted-in routes — + * the behavioral core of the raw-body feature that unit tests invoking + * `PluginRouteHandler.invoke` directly do not exercise. + */ + +import { randomUUID } from "node:crypto"; + +import Database from "better-sqlite3"; +import { SqliteDialect } from "kysely"; +import { describe, expect, it } from "vitest"; + +import { EmDashRuntime } from "../../../src/emdash-runtime.js"; +import type { RuntimeDependencies } from "../../../src/emdash-runtime.js"; +import { definePlugin } from "../../../src/plugins/define-plugin.js"; +import type { Storage } from "../../../src/storage/types.js"; + +const stubStorage: Storage = { + async upload() { + throw new Error("storage not used by this test"); + }, + async download() { + throw new Error("storage not used by this test"); + }, + async delete() {}, + async exists() { + return false; + }, + async list() { + return { items: [] }; + }, + async getSignedUploadUrl() { + throw new Error("storage not used by this test"); + }, + getPublicUrl: (key) => `/media/${key}`, +}; + +interface Captured { + hasRawBody: boolean; + rawBody: string | undefined; + input: unknown; +} + +function createDeps(captured: Captured, rawBodyOptIn: boolean): RuntimeDependencies { + const entrypoint = `test-plugin-raw-body-${randomUUID()}`; + return { + config: { + database: { entrypoint, config: {}, type: "sqlite" }, + storage: { entrypoint, config: {} }, + }, + plugins: [ + definePlugin({ + id: "webhook-sink", + version: "1.0.0", + capabilities: [], + routes: { + hook: { + rawBody: rawBodyOptIn, + handler: async (ctx) => { + captured.hasRawBody = "rawBody" in ctx && ctx.rawBody !== undefined; + captured.rawBody = ctx.rawBody; + captured.input = ctx.input; + return { ok: true }; + }, + }, + }, + }), + ], + createDialect: () => new SqliteDialect({ database: new Database(":memory:") }), + createStorage: () => stubStorage, + sandboxEnabled: false, + sandboxedPluginEntries: [], + createSandboxRunner: null, + }; +} + +function post(json: string): Request { + return new Request("http://test.local/_emdash/api/plugin/webhook-sink/hook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: json, + }); +} + +describe("EmDashRuntime.handlePluginApiRoute — rawBody", () => { + it("populates ctx.rawBody with the exact delivered text and ctx.input with parsed JSON", async () => { + const captured: Captured = { hasRawBody: false, rawBody: undefined, input: undefined }; + const runtime = await EmDashRuntime.create(createDeps(captured, true)); + try { + // Whitespace and key order must survive in rawBody even though + // ctx.input is the parsed equivalent. + const payload = '{ "b": 1,\n "a": "x" }'; + const result = await runtime.handlePluginApiRoute( + "webhook-sink", + "POST", + "/hook", + post(payload), + ); + + expect(result.success).toBe(true); + expect(captured.hasRawBody).toBe(true); + expect(captured.rawBody).toBe(payload); + expect(captured.input).toEqual({ a: "x", b: 1 }); + } finally { + await runtime.stopCron(); + } + }); + + it("leaves ctx.input undefined for non-JSON payloads but still exposes rawBody", async () => { + const captured: Captured = { hasRawBody: false, rawBody: undefined, input: undefined }; + const runtime = await EmDashRuntime.create(createDeps(captured, true)); + try { + const result = await runtime.handlePluginApiRoute( + "webhook-sink", + "POST", + "/hook", + post("not json at all"), + ); + + expect(result.success).toBe(true); + expect(captured.rawBody).toBe("not json at all"); + expect(captured.input).toBeUndefined(); + } finally { + await runtime.stopCron(); + } + }); + + it("does not populate ctx.rawBody for routes that did not opt in", async () => { + const captured: Captured = { hasRawBody: false, rawBody: undefined, input: undefined }; + const runtime = await EmDashRuntime.create(createDeps(captured, false)); + try { + const result = await runtime.handlePluginApiRoute( + "webhook-sink", + "POST", + "/hook", + post('{"a":1}'), + ); + + expect(result.success).toBe(true); + expect(captured.hasRawBody).toBe(false); + expect(captured.input).toEqual({ a: 1 }); + } finally { + await runtime.stopCron(); + } + }); +}); diff --git a/packages/core/tests/unit/plugins/routes.test.ts b/packages/core/tests/unit/plugins/routes.test.ts index 1187cd54ef..25ea212a50 100644 --- a/packages/core/tests/unit/plugins/routes.test.ts +++ b/packages/core/tests/unit/plugins/routes.test.ts @@ -334,6 +334,89 @@ describe("PluginRouteHandler", () => { expect(result.data).toEqual({ hasEmail: true, hasSend: true }); }); + it("exposes ctx.rawBody on routes that opt in with rawBody: true", async () => { + // Signature verification needs the payload exactly as delivered: + // whitespace and key order must survive, which a re-serialized + // ctx.input can't guarantee. + const raw = `{"b":2, "a":1}`; + let seen: { rawBody?: string; input?: unknown } = {}; + const plugin = createTestPlugin({ + routes: { + webhook: { + rawBody: true, + handler: async (ctx) => { + seen = { rawBody: ctx.rawBody, input: ctx.input }; + return null; + }, + }, + }, + }); + const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); + + const result = await handler.invoke("webhook", { + request: new Request("http://test.com", { method: "POST", body: raw }), + body: JSON.parse(raw), + rawBody: raw, + }); + + expect(result.success).toBe(true); + expect(seen.rawBody).toBe(raw); + expect(seen.input).toEqual({ a: 1, b: 2 }); + }); + + it("keeps ctx.rawBody undefined on routes without the rawBody flag", async () => { + let seenRawBody: string | undefined = "sentinel"; + const plugin = createTestPlugin({ + routes: { + normal: { + handler: async (ctx) => { + seenRawBody = ctx.rawBody; + return null; + }, + }, + }, + }); + const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); + + const result = await handler.invoke("normal", { + request: new Request("http://test.com", { method: "POST", body: "{}" }), + body: {}, + rawBody: "{}", + }); + + expect(result.success).toBe(true); + expect(seenRawBody).toBeUndefined(); + }); + + it("delivers non-JSON bodies to rawBody routes even though input is undefined", async () => { + // Form-encoded webhook deliveries parse to undefined today and are + // lost; with rawBody: true the handler can parse them itself. + const raw = "event=order.paid&id=42"; + let seen: { rawBody?: string; input?: unknown } = {}; + const plugin = createTestPlugin({ + routes: { + webhook: { + rawBody: true, + handler: async (ctx) => { + seen = { rawBody: ctx.rawBody, input: ctx.input }; + return null; + }, + }, + }, + }); + const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); + + const result = await handler.invoke("webhook", { + request: new Request("http://test.com", { method: "POST", body: raw }), + body: undefined, + rawBody: raw, + }); + + expect(result.success).toBe(true); + expect(seen.rawBody).toBe(raw); + expect(seen.input).toBeUndefined(); + }); + it("surfaces an actionable error when a handler reads the consumed request body (#1293)", async () => { // EmDash parses the body once and exposes it as ctx.input; the same // Request is then handed to the handler with its stream already spent.