From ba484107bf8e9d2a69ad3f6926b9a41f12160b36 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Fri, 1 May 2026 21:10:38 +0000 Subject: [PATCH 1/3] =?UTF-8?q?refactor(email-worker):=20wrangler.json,=20?= =?UTF-8?q?inline=20ALLOWED=5FSENDERS,=20rename=20SIDECAR=5FURL=20?= =?UTF-8?q?=E2=86=92=20WEBHOOK=5FURL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace wrangler.toml with wrangler.json (with $schema reference for editor validation). TOML and JSON are both first-class wrangler config formats; JSON keeps the worker package consistent with the rest of the repo's config files (package.json, tsconfig.json, webhooks.json). - Move ALLOWED_SENDERS out of wrangler vars and into a top-level TypeScript const at the head of src/index.ts. The allowlist is PR-reviewed code now (typed as readonly string[]), compiled once at module load (zero per-request parse overhead), and a malformed regex fails the deploy instead of silently dropping at runtime. The Env shape no longer needs an ALLOWED_SENDERS field. - Rename SIDECAR_URL → WEBHOOK_URL in wrangler.json vars, the Env interface, and the fetch call. 'Sidecar' was misleading anyway — the plugin is in-process with opencode, not a sidecar process. WEBHOOK_URL clearly names what it points at: the plugin's /webhooks/email endpoint. --- packages/cloudflare-email-worker/README.md | 6 +-- packages/cloudflare-email-worker/src/index.ts | 42 +++++++++---------- .../cloudflare-email-worker/wrangler.json | 9 ++++ .../cloudflare-email-worker/wrangler.toml | 36 ---------------- 4 files changed, 32 insertions(+), 61 deletions(-) create mode 100644 packages/cloudflare-email-worker/wrangler.json delete mode 100644 packages/cloudflare-email-worker/wrangler.toml diff --git a/packages/cloudflare-email-worker/README.md b/packages/cloudflare-email-worker/README.md index 3b9b5b4..bd8fa66 100644 --- a/packages/cloudflare-email-worker/README.md +++ b/packages/cloudflare-email-worker/README.md @@ -31,9 +31,9 @@ GitHub ──email──▶ gh@yourdomain.com - Cloudflare dashboard → your zone → **Email** → enable Email Routing. - Add a destination address (e.g. `gh@yourdomain.com`) and verify it via the email Cloudflare sends. -2. **Edit `wrangler.toml`**. - - `ALLOWED_SENDERS` — JSON-encoded string array. Exact strings are case-insensitive matches; strings of the form `/regex/` are treated as case-insensitive regex. Default allows `notifications@github.com` and any `*@github.com`. - - `SIDECAR_URL` — public URL of your opencode-webhooks endpoint, e.g. `https://your-opencode.example.com:5050/webhooks/email`. Must be reachable from the Cloudflare worker network. +2. **Edit config**. + - `wrangler.json` → `vars.WEBHOOK_URL` — public URL of your opencode-webhooks endpoint, e.g. `https://your-opencode.example.com:5050/webhooks/email`. Must be reachable from the Cloudflare worker network. + - `src/index.ts` → `ALLOWED_SENDERS` — TypeScript const at the top of the file. Exact strings are case-insensitive matches; strings of the form `/regex/` are treated as case-insensitive regex. Default allows `notifications@github.com` and any `*@github.com`. Compiles once at module load (zero per-request overhead). 3. **Set the shared HMAC secret**: ```sh diff --git a/packages/cloudflare-email-worker/src/index.ts b/packages/cloudflare-email-worker/src/index.ts index 25a9dca..823968f 100644 --- a/packages/cloudflare-email-worker/src/index.ts +++ b/packages/cloudflare-email-worker/src/index.ts @@ -6,13 +6,21 @@ // Drop unmatched mail without forwarding (Cloudflare won't bounce). // 3. Buffer the raw RFC822 body. // 4. HMAC-sha256 sign the body with EMAIL_WEBHOOK_SECRET. -// 5. POST to SIDECAR_URL with the signature + envelope headers. +// 5. POST to WEBHOOK_URL with the signature + envelope headers. // 6. On 5xx, throw so Cloudflare retries the email later. On 4xx, log // and accept (those are permanent — bad signature, dedup hit, etc.). +// Sender allowlist. Strings starting and ending with "/" are treated +// as case-insensitive regex (slashes are delimiters); everything else +// is exact-match (case-insensitive) against the bare address parsed +// out of an RFC5322 From header. Edit + redeploy to change. +const ALLOWED_SENDERS: readonly string[] = [ + "notifications@github.com", + "/^.*@github\\.com$/", +] + export interface Env { - ALLOWED_SENDERS: string // JSON-encoded string[] - SIDECAR_URL: string + WEBHOOK_URL: string EMAIL_WEBHOOK_SECRET: string } @@ -20,10 +28,11 @@ type Pattern = | { kind: "exact"; value: string } | { kind: "regex"; re: RegExp } +const COMPILED_PATTERNS: Pattern[] = compilePatterns(ALLOWED_SENDERS) + export default { async email(message, env, _ctx) { - const patterns = parsePatterns(env.ALLOWED_SENDERS) - if (!matchesAnyPattern(message.from, patterns)) { + if (!matchesAnyPattern(message.from, COMPILED_PATTERNS)) { console.log( `drop: from=${message.from} to=${message.to} (no allowlist match)`, ) @@ -37,7 +46,7 @@ export default { const sig = await hmacSha256Hex(env.EMAIL_WEBHOOK_SECRET, body) const messageId = message.headers.get("message-id") ?? "" - const res = await fetch(env.SIDECAR_URL, { + const res = await fetch(env.WEBHOOK_URL, { method: "POST", headers: { "content-type": "message/rfc822", @@ -56,29 +65,18 @@ export default { ) // Re-throw on 5xx so Cloudflare retries; on 4xx accept (permanent). if (res.status >= 500) { - throw new Error(`sidecar ${res.status}`) + throw new Error(`webhook ${res.status}`) } } }, } satisfies ExportedHandler -function parsePatterns(raw: string): Pattern[] { - let arr: unknown - try { - arr = JSON.parse(raw) - } catch { - return [] - } - if (!Array.isArray(arr)) return [] +function compilePatterns(raw: readonly string[]): Pattern[] { const out: Pattern[] = [] - for (const s of arr) { - if (typeof s !== "string" || s.length === 0) continue + for (const s of raw) { + if (s.length === 0) continue if (s.length >= 2 && s.startsWith("/") && s.endsWith("/")) { - try { - out.push({ kind: "regex", re: new RegExp(s.slice(1, -1), "i") }) - } catch { - // bad regex — skip - } + out.push({ kind: "regex", re: new RegExp(s.slice(1, -1), "i") }) continue } out.push({ kind: "exact", value: s.toLowerCase() }) diff --git a/packages/cloudflare-email-worker/wrangler.json b/packages/cloudflare-email-worker/wrangler.json new file mode 100644 index 0000000..10e8d60 --- /dev/null +++ b/packages/cloudflare-email-worker/wrangler.json @@ -0,0 +1,9 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "opencode-email-worker", + "main": "src/index.ts", + "compatibility_date": "2025-01-01", + "vars": { + "WEBHOOK_URL": "https://your-opencode-host.example.com:5050/webhooks/email" + } +} diff --git a/packages/cloudflare-email-worker/wrangler.toml b/packages/cloudflare-email-worker/wrangler.toml deleted file mode 100644 index 8dca157..0000000 --- a/packages/cloudflare-email-worker/wrangler.toml +++ /dev/null @@ -1,36 +0,0 @@ -# Cloudflare Email Worker for opencode-webhooks. -# -# Receives inbound mail via Cloudflare Email Routing, filters the From -# address against ALLOWED_SENDERS, HMAC-signs the raw RFC822 body, and -# POSTs it to the opencode-webhooks plugin's /webhooks/email endpoint. -# -# Set up checklist (see README.md for the long version): -# 1. Cloudflare dashboard → Email → enable Email Routing on your zone. -# 2. Add a destination address (e.g. gh@yourdomain.com) and verify it. -# 3. Edit ALLOWED_SENDERS + SIDECAR_URL below to your values. -# 4. wrangler secret put EMAIL_WEBHOOK_SECRET (paste a random hex string; -# put the same value in the plugin's EMAIL_WEBHOOK_SECRET env var) -# 5. bun run deploy -# 6. Cloudflare → Email Routing → Catch-all (or specific rule) → -# "Send to a Worker" → choose opencode-email-worker. - -name = "opencode-email-worker" -main = "src/index.ts" -compatibility_date = "2025-01-01" - -[vars] -# Public, non-sensitive config. Edit and redeploy to change. -# -# JSON-encoded array of sender patterns. Strings starting and ending with -# "/" are treated as case-insensitive regex (slashes are delimiters); -# everything else is exact-match (case-insensitive) against the bare -# address parsed out of an RFC5322 From header. -ALLOWED_SENDERS = '["notifications@github.com", "/^.*@github\\.com$/"]' - -# Public URL of the opencode-webhooks plugin's email endpoint. Must be -# reachable from Cloudflare's worker network (so a public ingress, not -# private-only). Replace with your actual URL. -SIDECAR_URL = "https://your-opencode-host.example.com:5050/webhooks/email" - -# Secrets (set via `wrangler secret put`): -# EMAIL_WEBHOOK_SECRET — HMAC sha256 secret shared with the plugin From 5340c7e9124cc3c2d7feda28a52bae4659ee0108 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Fri, 1 May 2026 21:34:54 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(email):=20worker=20becomes=20dumb=20pi?= =?UTF-8?q?pe=20=E2=80=94=20unconditional=20forward=20+=20JSON=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cloudflare Email Worker now does two things per inbound email: 1. message.forward(env.FORWARD_TO) unconditionally (if set), so every email reaches the operator's real inbox preserving DKIM. Wrapped in try/catch so a misconfigured FORWARD_TO doesn't block webhook dispatch — failure is logged loudly and we continue. 2. If the From address is in ALLOWED_SENDERS, build a small JSON event from the headers we route on (from, to, subject, message_id, in_reply_to, references, list_id, x_github_reason, x_github_sender), HMAC-sign it, and POST as application/json to WEBHOOK_URL. The worker no longer parses RFC822 in the plugin path — it just hands Cloudflare's already-parsed headers to the plugin verbatim. The body is never sent at all (it never was used; canonical state comes from the GitHub API). Plugin side: - handlers/email.ts: reads req.json() instead of parsing RFC822; validates the event shape; everything downstream (identity, synth, dispatch) is unchanged. - email/identity.ts: accepts the EmailEvent JSON shape instead of an EmailHeaders object. Same regex matchers; same in-reply-to / references fallback chain. - email/synthesize.ts: reads metadata directly from the event object. - email/parse.ts: deleted (no more RFC822 parsing). Other changes: - wrangler.json: added FORWARD_TO var (optional) and observability.logs.enabled = true so 'wrangler tail' and the Cloudflare dashboard show structured logs. - READMEs: updated wire format, architecture diagrams, failure-mode table on the worker side. Verified: bun run typecheck passes for both packages; wrangler deploy --dry-run accepts the new config with both vars bound. --- .env.example | 11 +- AGENTS.md | 9 ++ packages/cloudflare-email-worker/README.md | 42 ++++-- packages/cloudflare-email-worker/src/index.ts | 88 +++++++++---- .../cloudflare-email-worker/wrangler.json | 8 +- packages/opencode-webhooks/README.md | 30 +++-- .../opencode-webhooks/src/email/identity.ts | 52 ++++---- packages/opencode-webhooks/src/email/parse.ts | 55 -------- .../opencode-webhooks/src/email/synthesize.ts | 42 +++--- .../opencode-webhooks/src/handlers/email.ts | 124 ++++++++++-------- 10 files changed, 257 insertions(+), 204 deletions(-) delete mode 100644 packages/opencode-webhooks/src/email/parse.ts diff --git a/.env.example b/.env.example index cf45588..2e49303 100644 --- a/.env.example +++ b/.env.example @@ -86,11 +86,12 @@ GITHUB_WEBHOOK_SECRET= # === Optional: Cloudflare Email Worker ingest === # In addition to GitHub webhooks, the plugin can receive GitHub -# notification emails forwarded by a Cloudflare Email Worker (see -# packages/cloudflare-email-worker). The worker HMAC-signs the raw -# RFC822 body and POSTs it to /webhooks/email; the plugin verifies the -# signature, parses headers, fetches canonical state from the GitHub -# API via gh, and dispatches through the same trigger system. +# notification emails relayed by a Cloudflare Email Worker (see +# packages/cloudflare-email-worker). The worker HMAC-signs a small JSON +# event (header metadata only — never the email body) and POSTs it to +# /webhooks/email; the plugin verifies the signature, identifies the +# referenced issue/PR, fetches canonical state from the GitHub API via +# gh, and dispatches through the same trigger system. # # Required only if you have at least one trigger with source: "email" # in webhooks.json. Without it, /webhooks/email rejects every delivery diff --git a/AGENTS.md b/AGENTS.md index fc1315f..5745c8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,9 @@ * **Bundled agents copied to ~/.config/opencode/agents at image build time**: Agent \`.md\` files in \`agents/\` are COPYed to \`/home/developer/.config/opencode/agents/\` at build time. Five agents: \`github-issue-resolver\` (primary, issue→draft PR), \`pr-reviewer\` (primary, delegates to fix-applier for bot-authored PRs), \`pr-fix-applier\` (subagent, \`task: deny\` to prevent recursion), \`ci-fixer\` (primary, 3-attempt budget via sentinel PR comments), \`pr-comment-responder\` (primary, triage inline/top-level comments). All use \`mode: primary\` except \`pr-fix-applier\` (\`mode: subagent\`). Agent name passed to \`runOpencode\` must match filename without \`.md\`. + +* **Cloudflare Email Worker: dumb pipe pattern with unconditional forward + gated webhook**: Email Worker is a dumb pipe: (1) unconditionally \`message.forward(FORWARD\_TO)\` if env var set, (2) check \`ALLOWED\_SENDERS\` allowlist, (3) if allowlisted, POST small JSON payload to WEBHOOK\_URL (not raw RFC822). Fields: from, to, subject, message\_id, in\_reply\_to, references, list\_id, x\_github\_reason, x\_github\_sender. HMAC-signed with \`x-email-signature-256\` header. Forward wrapped in try/catch so a bad FORWARD\_TO doesn't block webhook dispatch. Order: forward first, then POST. Plugin side reads \`req.json()\` directly — no header parsing step. + * **docker-entrypoint.sh skills bootstrap pattern**: Skills are baked into the image via Dockerfile \`COPY skills/ /home/developer/.config/opencode/skills/\` — no \`npx skills add\` calls at runtime. \`docker-entrypoint.sh\` does NOT run any skills bootstrap or cron scheduler; it only: chowns the dev volume if root-owned, inits git, configures \`gh auth setup-git\` + git user identity from \`gh api user\`, then \`exec opencode web\`. The webhook plugin (opencode-webhooks) loads in-process — there is no sidecar process. @@ -35,9 +38,15 @@ * **GitHub CLI auth lost on server restart — symlink to persistent volume**: gh CLI auth is lost on Railway redeploy because it stores tokens in ~/.config/gh/ on the ephemeral rootfs. sentry-cli survives because it reads SENTRY\_AUTH\_TOKEN from env vars on every invocation. Two fixes: (1) \*\*Recommended\*\*: set GH\_TOKEN as a Railway env var (PAT with repo/workflow/read:org scopes) — gh auto-detects it, no disk state needed, matches sentry-cli pattern. (2) Symlink approach: in docker-entrypoint.sh, run \`mkdir -p ~/.config/gh ~/dev/.gh-config\` then \`ln -sfn ~/dev/.gh-config/$f ~/.config/gh/$f\` for hosts.yml and config.yml. Use \`GH\_TOKEN\` not \`GITHUB\_TOKEN\` — Railway/Actions can override the latter. + +* **HMAC over JSON: plugin must verify raw bytes, not re-serialized JSON**: When the email worker HMAC-signs a JSON body, the plugin must verify against the exact bytes received (\`req.text()\` before \`JSON.parse\`), not a re-serialized version. \`JSON.stringify\` key ordering is insertion-order-stable in JS/V8 but not spec-guaranteed cross-runtime. Re-serializing will produce matching output today but is fragile. Pattern: \`const raw = await req.text(); verify(raw); const payload = JSON.parse(raw)\`. + * **Hono middleware ordering: \`use('/')\` matches POST routes registered after it**: AGENTS.md contains stale lore from the abandoned \`.opencode/plans/add-hono-sidecar.md\` design: references to Hono framework, API\_TOKEN bearer gate, sidecar process supervision, \`npx skills add\` bootstrap, and 'one trigger' in webhooks.json are all outdated. Current implementation: in-process plugin with \`Bun.serve\` (no Hono), no API\_TOKEN, no sidecar, skills baked via Dockerfile \`COPY\`, and 9 triggers in webhooks.json. Do not rely on AGENTS.md architecture bullets without cross-checking against \`packages/opencode-webhooks/src/\` and \`docker-entrypoint.sh\`. + +* **message.forward() failure blocks entire email pipeline if not caught**: In a Cloudflare Email Worker, if \`message.forward()\` throws (e.g. unverified destination), the worker throws and CF retries the entire email — blocking all webhook dispatch until fixed. Always wrap \`message.forward()\` in try/catch and log the error, then continue to the POST. Otherwise a misconfigured \`FORWARD\_TO\` silently kills the whole pipeline. + * **opencode.json \`experimental\` key rejects unknown subkeys (additionalProperties: false)**: The published OpenCode config JSON schema defines \`experimental\` with \`additionalProperties: false\`. Adding a custom key like \`experimental.webhook\` will fail strict schema validation in editors. Workaround: store plugin-specific config in a separate file (e.g. \`~/.config/opencode/webhooks.json\`) read directly via \`Bun.file().json()\`, or drop the \`$schema\` reference from \`opencode.json\` to silence editor errors. Do NOT put custom plugin config under \`experimental\` expecting schema tolerance. diff --git a/packages/cloudflare-email-worker/README.md b/packages/cloudflare-email-worker/README.md index bd8fa66..dd682ab 100644 --- a/packages/cloudflare-email-worker/README.md +++ b/packages/cloudflare-email-worker/README.md @@ -1,12 +1,15 @@ # opencode-cloudflare-email-worker -Cloudflare Email Worker that forwards GitHub notification emails to the [`opencode-webhooks`](../opencode-webhooks/) plugin's `POST /webhooks/email` endpoint. +Cloudflare Email Worker that sits in front of the [`opencode-webhooks`](../opencode-webhooks/) plugin. It does two things per inbound email: -This is the **email ingest path**. The plugin parses RFC822 headers, identifies the referenced GitHub issue/PR via `Message-ID`, fetches canonical state via `gh`, and dispatches to OpenCode agents — same shape as a real GitHub webhook. +1. **Always forwards** the email verbatim to `FORWARD_TO` (your real inbox), if the env var is set. DKIM is preserved via Cloudflare's `message.forward()`. +2. **If the sender is in `ALLOWED_SENDERS`**, it builds a small JSON event from the headers (`from`, `to`, `subject`, `message_id`, `in_reply_to`, `references`, `list_id`, `x_github_reason`, `x_github_sender`), HMAC-signs it, and POSTs it to `WEBHOOK_URL` (the plugin's `/webhooks/email` endpoint). + +The worker is a **dumb pipe** — no header parsing, no body inspection, no allowlist beyond the simple From-address gate. The plugin owns identity resolution (Message-ID → owner/repo/issue#), GitHub API fetches, dedup, and dispatch. ## Why -GitHub doesn't expose a "send me everything that involves my user" webhook. But it already filters down to "stuff I care about" before sending notification emails — mentions, review requests, assignments, comments on PRs/issues you're involved in, across every repo your account touches. This worker lets you turn that mailbox into an event stream. +GitHub doesn't expose a "send me everything that involves my user" webhook. But it already filters down to "stuff I care about" before sending notification emails — mentions, review requests, assignments, comments on PRs/issues you're involved in, across every repo your account touches. This worker lets you turn that mailbox into an event stream **without** giving up the ability to read the same emails as a human. ## Architecture @@ -15,14 +18,20 @@ GitHub ──email──▶ gh@yourdomain.com │ │ Cloudflare Email Routing ▼ - Email Worker - ├─ allowlist (static + /regex/) - └─ HMAC-sign + POST + Email Worker (dumb pipe) + ├─ message.forward(FORWARD_TO) ──▶ your real inbox (always) + └─ if From ∈ ALLOWED_SENDERS: + POST {from, to, subject, + message_id, in_reply_to, + references, list_id, + x_github_reason, + x_github_sender} + HMAC-signed application/json │ ▼ https://your-host/webhooks/email opencode-webhooks plugin - (parse → synthesize → dispatch) + (verify → identify → gh fetch → dispatch) ``` ## Setup @@ -33,7 +42,9 @@ GitHub ──email──▶ gh@yourdomain.com 2. **Edit config**. - `wrangler.json` → `vars.WEBHOOK_URL` — public URL of your opencode-webhooks endpoint, e.g. `https://your-opencode.example.com:5050/webhooks/email`. Must be reachable from the Cloudflare worker network. + - `wrangler.json` → `vars.FORWARD_TO` — destination address for the verbatim forward (e.g. `you@yourdomain.com`). **Must be verified in Cloudflare Email Routing first** (Email Routing → Destination addresses → Add). Leave unset (or remove the key) to skip forwarding entirely. - `src/index.ts` → `ALLOWED_SENDERS` — TypeScript const at the top of the file. Exact strings are case-insensitive matches; strings of the form `/regex/` are treated as case-insensitive regex. Default allows `notifications@github.com` and any `*@github.com`. Compiles once at module load (zero per-request overhead). + - `wrangler.json` → `observability.logs.enabled` — set to `true` (default in this repo) so you can `wrangler tail` and see structured logs in the Cloudflare dashboard. 3. **Set the shared HMAC secret**: ```sh @@ -69,8 +80,19 @@ GitHub ──email──▶ gh@yourdomain.com ## Test - Send yourself a mention/review request from another GitHub account. -- Watch the worker: `bun run tail`. Should log a successful POST. +- Watch the worker: `bun run tail`. Should log a successful webhook POST and (if `FORWARD_TO` is set) a successful forward. - Watch the container's stdout. Should log `[opencode-webhooks] trigger 'email-mention' → session ...`. +- Check your `FORWARD_TO` inbox — the original email should have arrived with DKIM intact. + +## Failure modes + +| Scenario | Behavior | +|---|---| +| `FORWARD_TO` unset | Skipped, no error. Webhook still fires for allowlisted senders. | +| `FORWARD_TO` set but unverified | `message.forward()` throws — caught and logged; webhook still fires. Verify the address in Cloudflare Email Routing → Destination addresses. | +| Sender not in `ALLOWED_SENDERS` | Forward still happens. Webhook is skipped (logged at info). | +| Plugin returns 5xx | Cloudflare retries the email later. Forward already happened, so retries don't double-forward. | +| Plugin returns 4xx (bad signature, dedup, etc.) | Logged, accepted (no retry). Forward already happened. | ## Security model @@ -80,8 +102,8 @@ GitHub ──email──▶ gh@yourdomain.com | Worker `ALLOWED_SENDERS` | Drops any From not in the allowlist (defense vs. spoofs that pass DMARC because the attacker controls `*.github.com`-adjacent domains). | | `EMAIL_WEBHOOK_SECRET` HMAC | Authenticates the worker → plugin link. Without it, the plugin returns 503. | | Plugin re-checks `email_allowed_senders` | Same allowlist applied server-side as defense in depth (and lets you tighten without redeploying the worker). | -| Plugin never reads body | The email body never reaches the LLM. Only RFC822 headers (Message-ID, X-GitHub-*) drive routing; the canonical issue/PR/comment is fetched from the GitHub API. Eliminates prompt-injection from email content. | -| Self-loop guard | The plugin drops emails whose `X-GitHub-Sender` matches the bot's own login. | +| Plugin never sees the body | The worker only sends the headers it cares about — never the body. The canonical issue/PR/comment is fetched from the GitHub API instead. Eliminates prompt-injection from email content. | +| Self-loop guard | The plugin drops emails whose `x_github_sender` matches the bot's own login. | ## Cost diff --git a/packages/cloudflare-email-worker/src/index.ts b/packages/cloudflare-email-worker/src/index.ts index 823968f..13bde2e 100644 --- a/packages/cloudflare-email-worker/src/index.ts +++ b/packages/cloudflare-email-worker/src/index.ts @@ -1,19 +1,22 @@ -// Cloudflare Email Worker: GitHub notification → /webhooks/email. +// Cloudflare Email Worker: dumb pipe in front of opencode-webhooks. // -// Pipeline: -// 1. Read message.from / message.to / Message-ID for observability. -// 2. Match message.from against ALLOWED_SENDERS (static + /regex/). -// Drop unmatched mail without forwarding (Cloudflare won't bounce). -// 3. Buffer the raw RFC822 body. -// 4. HMAC-sha256 sign the body with EMAIL_WEBHOOK_SECRET. -// 5. POST to WEBHOOK_URL with the signature + envelope headers. -// 6. On 5xx, throw so Cloudflare retries the email later. On 4xx, log -// and accept (those are permanent — bad signature, dedup hit, etc.). - -// Sender allowlist. Strings starting and ending with "/" are treated -// as case-insensitive regex (slashes are delimiters); everything else -// is exact-match (case-insensitive) against the bare address parsed -// out of an RFC5322 From header. Edit + redeploy to change. +// Pipeline per inbound email: +// 1. message.forward(FORWARD_TO) — unconditional, every email reaches +// the operator's real inbox so nothing is silently swallowed. +// 2. If message.from is in ALLOWED_SENDERS, build a small JSON event +// from the headers we care about, HMAC-sign it, and POST to +// WEBHOOK_URL. Non-allowlisted mail is forward-only (no agent). +// 3. On 5xx from the webhook, throw so Cloudflare retries the email. +// 4xx is permanent (signature rejected, dedup hit, etc.) — accept. +// +// All RFC822 parsing stays out of the worker: the plugin already has +// gh-api access for canonical state, and Cloudflare hands us a parsed +// `message.headers` so we don't need to re-implement header decoding. + +// Sender allowlist for the webhook gate. Strings starting and ending +// with "/" are treated as case-insensitive regex; everything else is +// case-insensitive exact match against the bare address parsed out of +// the From header. const ALLOWED_SENDERS: readonly string[] = [ "notifications@github.com", "/^.*@github\\.com$/", @@ -22,6 +25,10 @@ const ALLOWED_SENDERS: readonly string[] = [ export interface Env { WEBHOOK_URL: string EMAIL_WEBHOOK_SECRET: string + // Optional. If set, every inbound email is forwarded here verbatim + // (DKIM-preserving via Cloudflare's message.forward()). The address + // must be verified in Cloudflare Email Routing first. + FORWARD_TO?: string } type Pattern = @@ -32,28 +39,56 @@ const COMPILED_PATTERNS: Pattern[] = compilePatterns(ALLOWED_SENDERS) export default { async email(message, env, _ctx) { + const messageId = message.headers.get("message-id") ?? "" + + // 1. Always forward to the operator's inbox if configured. Wrap in + // try/catch so a bad FORWARD_TO (unverified destination, etc.) + // doesn't block webhook dispatch — log loudly and continue. + if (env.FORWARD_TO) { + try { + await message.forward(env.FORWARD_TO) + } catch (err) { + console.error( + `forward failed: to=${env.FORWARD_TO} message-id=${messageId} err=${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + // 2. Webhook gate: only allowlisted senders trigger an agent run. if (!matchesAnyPattern(message.from, COMPILED_PATTERNS)) { console.log( - `drop: from=${message.from} to=${message.to} (no allowlist match)`, + `webhook skipped: from=${message.from} (not in allowlist)`, ) return } - const body = new Uint8Array( - await new Response(message.raw).arrayBuffer(), - ) + const references = (message.headers.get("references") ?? "") + .split(/\s+/) + .filter(Boolean) - const sig = await hmacSha256Hex(env.EMAIL_WEBHOOK_SECRET, body) - const messageId = message.headers.get("message-id") ?? "" + const payload = { + from: message.from, + to: message.to, + subject: message.headers.get("subject") ?? "", + message_id: messageId, + in_reply_to: message.headers.get("in-reply-to") ?? null, + references, + list_id: message.headers.get("list-id") ?? null, + x_github_reason: message.headers.get("x-github-reason") ?? null, + x_github_sender: message.headers.get("x-github-sender") ?? null, + } + + const body = JSON.stringify(payload) + const sig = await hmacSha256Hex( + env.EMAIL_WEBHOOK_SECRET, + new TextEncoder().encode(body), + ) const res = await fetch(env.WEBHOOK_URL, { method: "POST", headers: { - "content-type": "message/rfc822", + "content-type": "application/json", "x-email-signature-256": `sha256=${sig}`, - "x-email-from": message.from, - "x-email-to": message.to, - "x-email-message-id": messageId, }, body, }) @@ -61,9 +96,8 @@ export default { if (!res.ok) { const text = await res.text().catch(() => "") console.error( - `forward failed: status=${res.status} from=${message.from} message-id=${messageId} body=${text.slice(0, 200)}`, + `webhook failed: status=${res.status} from=${message.from} message-id=${messageId} body=${text.slice(0, 200)}`, ) - // Re-throw on 5xx so Cloudflare retries; on 4xx accept (permanent). if (res.status >= 500) { throw new Error(`webhook ${res.status}`) } diff --git a/packages/cloudflare-email-worker/wrangler.json b/packages/cloudflare-email-worker/wrangler.json index 10e8d60..0f2fef6 100644 --- a/packages/cloudflare-email-worker/wrangler.json +++ b/packages/cloudflare-email-worker/wrangler.json @@ -3,7 +3,13 @@ "name": "opencode-email-worker", "main": "src/index.ts", "compatibility_date": "2025-01-01", + "observability": { + "logs": { + "enabled": true + } + }, "vars": { - "WEBHOOK_URL": "https://your-opencode-host.example.com:5050/webhooks/email" + "WEBHOOK_URL": "https://your-opencode-host.example.com:5050/webhooks/email", + "FORWARD_TO": "you@yourdomain.com" } } diff --git a/packages/opencode-webhooks/README.md b/packages/opencode-webhooks/README.md index 943190a..8d3a69f 100644 --- a/packages/opencode-webhooks/README.md +++ b/packages/opencode-webhooks/README.md @@ -7,7 +7,7 @@ When a configured webhook arrives, the plugin verifies the HMAC signature, dedup Two ingest sources are supported: - **`source: "github_webhook"`** (default) — classic GitHub webhook deliveries to `POST /webhooks/github`. Standard event/action matching against `X-GitHub-Event`. -- **`source: "email"`** — GitHub notification emails forwarded by a Cloudflare Email Worker to `POST /webhooks/email`. The plugin parses RFC822 headers, identifies the referenced issue/PR via `Message-ID`, fetches canonical state from the GitHub API via `gh`, and dispatches the synthesized payload — same shape an actual webhook would produce, so existing agents work unchanged. +- **`source: "email"`** — GitHub notification emails relayed by a Cloudflare Email Worker. The worker forwards a small JSON event (the headers we care about — `from`, `to`, `subject`, `message_id`, `in_reply_to`, `references`, `list_id`, `x_github_reason`, `x_github_sender`) to `POST /webhooks/email`. The plugin identifies the referenced issue/PR from `message_id`/`in_reply_to`/`references`, fetches canonical state from the GitHub API via `gh`, and dispatches the synthesized payload — same shape an actual webhook would produce, so existing agents work unchanged. > **Runtime: Bun ≥ 1.2.** Uses `Bun.serve`, `Bun.spawn`, and `bun:sqlite`. @@ -131,16 +131,30 @@ Body: GitHub event JSON. ### `POST /webhooks/email` -Forwarded by the Cloudflare email worker. Required headers: +Posted by the Cloudflare email worker. -- `X-Email-Signature-256` — `sha256=` HMAC of the raw body using `EMAIL_WEBHOOK_SECRET`. -- `X-Email-From` — RFC5322 `From` value of the email. -- `X-Email-To` — RFC5322 `To` value. -- `X-Email-Message-ID` — Message-ID (also re-parsed from the body). +- Header `X-Email-Signature-256` — `sha256=` HMAC of the raw body using `EMAIL_WEBHOOK_SECRET`. +- `Content-Type: application/json`. -Body: raw RFC822 message (`Content-Type: message/rfc822`). Only headers are read; the body is never passed to the LLM. Canonical state for the referenced issue/PR/comment is fetched from the GitHub API via `gh`. +Body: a small JSON event with the headers the plugin actually uses: -Both endpoints return 200 on accept (including drops/duplicates with a `dropped` or `duplicate` field), 401 on bad signature, 403 on email allowlist mismatch, 404 on path mismatch, 413 on oversized body, 503 if the corresponding secret is unconfigured. +```json +{ + "from": "notifications@github.com", + "to": "gh@yourdomain.com", + "subject": "Re: [owner/repo] feat: ...", + "message_id": "", + "in_reply_to": "", + "references": ["<...>", "<...>"], + "list_id": "", + "x_github_reason": "mention", + "x_github_sender": "octocat" +} +``` + +The body of the email itself is never sent — canonical state for the referenced issue/PR/comment is fetched from the GitHub API via `gh`. Eliminates prompt-injection from email content. + +Both endpoints return 200 on accept (including drops/duplicates with a `dropped` or `duplicate` field), 400 on a malformed event body, 401 on bad signature, 403 on email allowlist mismatch, 404 on path mismatch, 413 on oversized body, 503 if the corresponding secret is unconfigured. ## Limitations diff --git a/packages/opencode-webhooks/src/email/identity.ts b/packages/opencode-webhooks/src/email/identity.ts index dc74eb0..87aa3c5 100644 --- a/packages/opencode-webhooks/src/email/identity.ts +++ b/packages/opencode-webhooks/src/email/identity.ts @@ -1,21 +1,34 @@ -// Map a GitHub notification email's headers to the GitHub entity it -// references: (owner, repo, kind, number[, comment]). +// Map a GitHub notification email to the GitHub entity it references: +// (owner, repo, kind, number[, comment]). // // GitHub's notification Message-IDs follow predictable patterns: // // // // -// @github.com> (issue-comment on PR) -// @github.com> -// @github.com> (inline review comment) -// @github.com> (push notification — ignored) +// (issue-style comment on PR) +// (review summary) +// (inline review comment) +// @github.com> (push notification — ignored) // -// We resolve the entity via Message-ID + List-ID. The `In-Reply-To` and -// `References` headers can also help when the Message-ID is for a -// reply, but the patterns above are enough for the v1 surface. +// We try Message-ID first, then In-Reply-To, then each References token — +// per-event Message-IDs often don't match but the parent <…/issues/N> +// or <…/pull/N> form usually shows up in In-Reply-To. -import type { EmailHeaders } from "./parse" +// Wire shape posted by the Cloudflare email worker. Mirrors +// EmailEvent in the worker; kept as a structural type so we don't +// share TypeScript files across packages. +export type EmailEvent = { + from: string + to: string + subject: string + message_id: string + in_reply_to: string | null + references: string[] + list_id: string | null + x_github_reason: string | null + x_github_sender: string | null +} export type EmailIdentity = | { @@ -41,21 +54,12 @@ const ISSUE_RE = const PULL_RE = /^?$/i -export function identifyEmail(headers: EmailHeaders): EmailIdentity { - // Candidates in priority order: Message-ID, In-Reply-To, then each - // token of References. GitHub's per-event Message-ID often doesn't - // match our regexes but In-Reply-To/References point at the canonical - // parent. +export function identifyEmail(event: EmailEvent): EmailIdentity { const candidates: string[] = [] - const messageId = headers.get("message-id") - if (messageId) candidates.push(messageId) - const inReplyTo = headers.get("in-reply-to") - if (inReplyTo) candidates.push(inReplyTo) - const references = headers.get("references") - if (references) { - for (const tok of references.split(/\s+/)) { - if (tok.length > 0) candidates.push(tok) - } + if (event.message_id) candidates.push(event.message_id) + if (event.in_reply_to) candidates.push(event.in_reply_to) + for (const tok of event.references) { + if (tok.length > 0) candidates.push(tok) } for (const candidate of candidates) { diff --git a/packages/opencode-webhooks/src/email/parse.ts b/packages/opencode-webhooks/src/email/parse.ts deleted file mode 100644 index be8cdf7..0000000 --- a/packages/opencode-webhooks/src/email/parse.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Minimal RFC822/5322 header parser. We deliberately do NOT touch the -// message body — only headers reach the LLM (via synthesize.ts), and the -// canonical state is fetched from the GitHub API. This keeps the parser -// tiny and removes any prompt-injection surface from email content. - -export type EmailHeaders = { - // Lowercase header name → values (multi-valued for Received etc.). - get(name: string): string | undefined - getAll(name: string): string[] -} - -export function parseHeaders(raw: string): EmailHeaders { - // Header block ends at the first blank line. Accept both CRLF and LF. - const headerEnd = findHeaderEnd(raw) - const headerBlock = headerEnd >= 0 ? raw.slice(0, headerEnd) : raw - - // Unfold continuation lines: any line starting with whitespace is a - // continuation of the previous header (RFC 5322 §2.2.3). - const unfolded: string[] = [] - for (const line of headerBlock.split(/\r?\n/)) { - if (line.length === 0) continue - if (/^[ \t]/.test(line) && unfolded.length > 0) { - unfolded[unfolded.length - 1] += " " + line.replace(/^[ \t]+/, "") - } else { - unfolded.push(line) - } - } - - const map = new Map() - for (const line of unfolded) { - const colon = line.indexOf(":") - if (colon <= 0) continue - const name = line.slice(0, colon).trim().toLowerCase() - const value = line.slice(colon + 1).trim() - const arr = map.get(name) - if (arr) arr.push(value) - else map.set(name, [value]) - } - - return { - get(name) { - return map.get(name.toLowerCase())?.[0] - }, - getAll(name) { - return map.get(name.toLowerCase()) ?? [] - }, - } -} - -function findHeaderEnd(raw: string): number { - const crlf = raw.indexOf("\r\n\r\n") - if (crlf >= 0) return crlf - const lf = raw.indexOf("\n\n") - return lf -} diff --git a/packages/opencode-webhooks/src/email/synthesize.ts b/packages/opencode-webhooks/src/email/synthesize.ts index dd54869..08aa006 100644 --- a/packages/opencode-webhooks/src/email/synthesize.ts +++ b/packages/opencode-webhooks/src/email/synthesize.ts @@ -1,14 +1,15 @@ -// Build a GitHub-shaped payload from an email, by fetching canonical -// state via the GitHub API. Existing agents/triggers see the same JSON -// shape they'd get from a real webhook delivery, so they don't need -// any email-specific handling. +// Build a GitHub-shaped payload from an email event, by fetching +// canonical state via the GitHub API. Existing agents/triggers see the +// same JSON shape they'd get from a real webhook delivery, so they +// don't need any email-specific handling. // -// We never read the email body — only headers. The body of an issue or -// PR comment comes from the API, which is the source of truth. +// The email body never reaches us — the Cloudflare worker only POSTs +// metadata (from/to/subject + the headers we use for routing). The +// body of an issue or PR comment is fetched from the API, which is +// the source of truth. import { ghApi } from "./github-api" -import type { EmailIdentity } from "./identity" -import type { EmailHeaders } from "./parse" +import type { EmailEvent, EmailIdentity } from "./identity" export type SyntheticPayload = Record & { repository: { @@ -22,7 +23,7 @@ export type SyntheticPayload = Record & { message_id: string from: string to: string - list_id: string + list_id: string | null kind: string } } @@ -33,30 +34,27 @@ export type SynthesisResult = export async function synthesizePayload( identity: EmailIdentity, - headers: EmailHeaders, - envelope: { from: string; to: string; reason: string }, + event: EmailEvent, + reason: string, ): Promise { if (identity.kind === "unknown") { return { ok: false, error: "unknown-message-id" } } - const sender = headers.get("x-github-sender") ?? null - const messageId = headers.get("message-id") ?? "" - const listId = headers.get("list-id") ?? "" - const repository = { full_name: `${identity.owner}/${identity.repo}`, owner: { login: identity.owner }, name: identity.repo, } const emailMeta = { - reason: envelope.reason, - message_id: messageId, - from: envelope.from, - to: envelope.to, - list_id: listId, + reason, + message_id: event.message_id, + from: event.from, + to: event.to, + list_id: event.list_id, kind: identity.kind, } + const sender = { login: event.x_github_sender } if (identity.kind === "issue") { const issue = await ghApi>( @@ -77,7 +75,7 @@ export async function synthesizePayload( repository, issue, ...(comment ? { comment } : {}), - sender: { login: sender }, + sender, _email: emailMeta, }, } @@ -115,7 +113,7 @@ export async function synthesizePayload( pull_request: pull, ...(comment ? { comment } : {}), ...(review ? { review } : {}), - sender: { login: sender }, + sender, _email: emailMeta, }, } diff --git a/packages/opencode-webhooks/src/handlers/email.ts b/packages/opencode-webhooks/src/handlers/email.ts index 21e8184..233e4ef 100644 --- a/packages/opencode-webhooks/src/handlers/email.ts +++ b/packages/opencode-webhooks/src/handlers/email.ts @@ -1,8 +1,9 @@ // Fetch handler for POST /webhooks/email. The Cloudflare email worker -// HMAC-signs and forwards the raw RFC822 message; we re-verify the -// allowlist (defense-in-depth), parse only headers, identify the -// referenced GitHub entity, fetch canonical state via `gh`, and drive -// the same dispatcher the github handler uses. +// HMAC-signs a small JSON event (see EmailEvent in email/identity.ts) +// and POSTs it here. We re-verify the allowlist (defense-in-depth), +// identify the referenced GitHub entity from Message-ID/In-Reply-To/ +// References, fetch canonical state via `gh`, and drive the same +// dispatcher the github handler uses. // // Synthesized event is "email." where is the // X-GitHub-Reason header (lowercased): mention, review_requested, @@ -14,8 +15,7 @@ import { extractAddress, matchesAllowlist, } from "../email/allowlist" -import { identifyEmail } from "../email/identity" -import { parseHeaders } from "../email/parse" +import { type EmailEvent, identifyEmail } from "../email/identity" import { synthesizePayload } from "../email/synthesize" import { verifySha256Signature } from "../hmac" import { computeSynthetics, readBodyBytes } from "../http" @@ -51,55 +51,53 @@ export function makeEmailFetchHandler(opts: { ) } - // Read body as raw bytes so HMAC matches what the worker signed. - // UTF-8-decoding via req.text() would replace 8-bit sequences and - // break signature verification on RFC822 messages with non-UTF-8 - // content (rare for GitHub notifications but possible). + // Read body as raw bytes — JSON.stringify in the worker produces + // bytes, and HMAC must be over the exact bytes received (not a + // re-serialized JSON.stringify(JSON.parse(…)) which is allowed to + // re-order keys). const body = await readBodyBytes(req) if (!body.ok) return body.response - const rawBytes = body.bytes const signature = req.headers.get("x-email-signature-256") - if (!verifySha256Signature(rawBytes, signature, emailSecret)) { + if (!verifySha256Signature(body.bytes, signature, emailSecret)) { return Response.json({ error: "invalid signature" }, { status: 401 }) } - // Headers are 7-bit ASCII per RFC 5322; parseHeaders only scans up - // to the first blank line, so a UTF-8 decode of the full body is - // safe to feed in. - const rawBody = new TextDecoder("utf-8").decode(rawBytes) - const envelopeFrom = req.headers.get("x-email-from") ?? "" - const envelopeTo = req.headers.get("x-email-to") ?? "" - const headerMessageId = req.headers.get("x-email-message-id") ?? "" - - if (!envelopeFrom) { + let event: EmailEvent + try { + event = parseEmailEvent(new TextDecoder("utf-8").decode(body.bytes)) + } catch (err) { return Response.json( - { error: "missing x-email-from header" }, + { error: "invalid event body", detail: String(err) }, { status: 400 }, ) } - // Defense-in-depth: re-check the worker's allowlist on the server. - if (allowlist.length > 0 && !matchesAllowlist(envelopeFrom, allowlist)) { + if (!event.from) { return Response.json( - { error: "sender not in allowlist", from: extractAddress(envelopeFrom) }, - { status: 403 }, + { error: "missing 'from' in event" }, + { status: 400 }, ) } - - const headers = parseHeaders(rawBody) - const messageId = headers.get("message-id") ?? headerMessageId - if (!messageId) { + if (!event.message_id) { return Response.json( - { error: "missing message-id" }, + { error: "missing 'message_id' in event" }, { status: 400 }, ) } + // Defense-in-depth: re-check the worker's allowlist on the server. + if (allowlist.length > 0 && !matchesAllowlist(event.from, allowlist)) { + return Response.json( + { error: "sender not in allowlist", from: extractAddress(event.from) }, + { status: 403 }, + ) + } + // Self-loop guard: drop notifications about the bot's own activity - // before doing any GitHub API work. X-GitHub-Sender is the github + // before doing any GitHub API work. x_github_sender is the github // login of whoever performed the action. - const ghSender = headers.get("x-github-sender") ?? null + const ghSender = event.x_github_sender if ( botLogin && ghSender && @@ -107,57 +105,53 @@ export function makeEmailFetchHandler(opts: { ) { return Response.json({ ok: true, - message_id: messageId, + message_id: event.message_id, dropped: "self-loop", sender: ghSender, }) } - const identity = identifyEmail(headers) + const identity = identifyEmail(event) if (identity.kind === "unknown") { return Response.json({ ok: true, - message_id: messageId, + message_id: event.message_id, dropped: "unknown-message-id", }) } - const reason = (headers.get("x-github-reason") ?? "subscribed").toLowerCase() - const event = `email.${reason}` - const dedupKey = `email:${messageId}` + const reason = (event.x_github_reason ?? "subscribed").toLowerCase() + const triggerEvent = `email.${reason}` + const dedupKey = `email:${event.message_id}` // Synthesize BEFORE dedup: if `gh api` fails (network blip, rate // limit), we need Cloudflare to retry. Inserting the dedup row // first would both swallow the retry AND return 200, losing the // email permanently. - const synth = await synthesizePayload(identity, headers, { - from: envelopeFrom, - to: envelopeTo, - reason, - }) + const synth = await synthesizePayload(identity, event, reason) if (!synth.ok) { return Response.json({ ok: true, - message_id: messageId, + message_id: event.message_id, dropped: synth.error, }) } // Idempotency: dedup by Message-ID, namespaced so it can never // collide with GitHub's UUID delivery_ids. - const inserted = store.insert(dedupKey, event, null) + const inserted = store.insert(dedupKey, triggerEvent, null) if (inserted) store.trim(retention) if (!inserted) { return Response.json({ ok: true, - message_id: messageId, + message_id: event.message_id, duplicate: true, dispatched: [], }) } // API-fetched login is the trustworthy source for self-loop - // suppression; X-GitHub-Sender header is only the fallback. + // suppression; the email-header fallback is best-effort. const senderForIgnore = lookupString(synth.payload, "comment.user.login") ?? lookupString(synth.payload, "review.user.login") ?? @@ -166,14 +160,14 @@ export function makeEmailFetchHandler(opts: { const synthetics = computeSynthetics(synth.payload) const { dispatched, skipped } = evaluateAndDispatch({ triggers, - event, + event: triggerEvent, action: null, payload: synth.payload, sender: senderForIgnore, botLogin, deliveryId: dedupKey, templateContext: { - event, + event: triggerEvent, action: null, delivery_id: dedupKey, payload: synth.payload, @@ -184,11 +178,37 @@ export function makeEmailFetchHandler(opts: { return Response.json({ ok: true, - message_id: messageId, - event, + message_id: event.message_id, + event: triggerEvent, duplicate: false, dispatched, ...(skipped.length > 0 ? { skipped } : {}), }) } } + +// Validate + normalize the JSON body the worker posts. Throws on a +// shape mismatch so the handler can return 400 with a useful detail. +function parseEmailEvent(raw: string): EmailEvent { + const obj = JSON.parse(raw) as unknown + if (typeof obj !== "object" || obj === null) { + throw new Error("body is not an object") + } + const o = obj as Record + const str = (v: unknown): string => (typeof v === "string" ? v : "") + const strOrNull = (v: unknown): string | null => + typeof v === "string" ? v : null + return { + from: str(o.from), + to: str(o.to), + subject: str(o.subject), + message_id: str(o.message_id), + in_reply_to: strOrNull(o.in_reply_to), + references: Array.isArray(o.references) + ? o.references.filter((s): s is string => typeof s === "string") + : [], + list_id: strOrNull(o.list_id), + x_github_reason: strOrNull(o.x_github_reason), + x_github_sender: strOrNull(o.x_github_sender), + } +} From cfaf98a9da4494858cdee45c757f01f57c33ad77 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Fri, 1 May 2026 21:41:49 +0000 Subject: [PATCH 3/3] fix: review findings on PR #13 (dumb-pipe + wrangler.json) - opencode-webhooks README: replace stale `x-email-from` header reference with JSON `from` field (finding #1). - http.ts: rewrite MAX_BODY_BYTES comment (no longer about RFC822); add MAX_EMAIL_BODY_BYTES = 64 KB and an optional maxBytes arg to readBodyBytes; email handler now uses the tighter cap (finding #2). - cloudflare-email-worker README: note that a malformed regex literal in ALLOWED_SENDERS will throw at module init and the worker won't start (finding #3). - email handler: parseEmailEvent's `str` now throws on non-string for required fields (from/to/subject/message_id) so the 400 detail names the offending field instead of misleading 'missing' messages (finding #4). - Test gap for email identity fallback chain tracked in #14; no tests added in this commit (finding #5). --- packages/cloudflare-email-worker/README.md | 2 +- packages/opencode-webhooks/README.md | 2 +- .../opencode-webhooks/src/handlers/email.ts | 19 ++++++++++++------- packages/opencode-webhooks/src/http.ts | 16 ++++++++++++---- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/cloudflare-email-worker/README.md b/packages/cloudflare-email-worker/README.md index dd682ab..dd90369 100644 --- a/packages/cloudflare-email-worker/README.md +++ b/packages/cloudflare-email-worker/README.md @@ -43,7 +43,7 @@ GitHub ──email──▶ gh@yourdomain.com 2. **Edit config**. - `wrangler.json` → `vars.WEBHOOK_URL` — public URL of your opencode-webhooks endpoint, e.g. `https://your-opencode.example.com:5050/webhooks/email`. Must be reachable from the Cloudflare worker network. - `wrangler.json` → `vars.FORWARD_TO` — destination address for the verbatim forward (e.g. `you@yourdomain.com`). **Must be verified in Cloudflare Email Routing first** (Email Routing → Destination addresses → Add). Leave unset (or remove the key) to skip forwarding entirely. - - `src/index.ts` → `ALLOWED_SENDERS` — TypeScript const at the top of the file. Exact strings are case-insensitive matches; strings of the form `/regex/` are treated as case-insensitive regex. Default allows `notifications@github.com` and any `*@github.com`. Compiles once at module load (zero per-request overhead). + - `src/index.ts` → `ALLOWED_SENDERS` — TypeScript const at the top of the file. Exact strings are case-insensitive matches; strings of the form `/regex/` are treated as case-insensitive regex. Default allows `notifications@github.com` and any `*@github.com`. Compiles once at module load (zero per-request overhead). A malformed regex literal will throw at module init and the worker won't start — fix the literal and redeploy. - `wrangler.json` → `observability.logs.enabled` — set to `true` (default in this repo) so you can `wrangler tail` and see structured logs in the Cloudflare dashboard. 3. **Set the shared HMAC secret**: diff --git a/packages/opencode-webhooks/README.md b/packages/opencode-webhooks/README.md index 8d3a69f..3d2d143 100644 --- a/packages/opencode-webhooks/README.md +++ b/packages/opencode-webhooks/README.md @@ -66,7 +66,7 @@ Minimal config: | `port` | `5050` (or `WEBHOOK_PORT`) | TCP port for the listener. | | `secret` | `GITHUB_WEBHOOK_SECRET` env | GitHub HMAC secret. Without it, `/webhooks/github` rejects every delivery with 503. | | `email_secret` | `EMAIL_WEBHOOK_SECRET` env | Shared HMAC secret with the Cloudflare email worker. Without it, `/webhooks/email` rejects every delivery with 503. Only required if you have at least one `source: "email"` trigger. | -| `email_allowed_senders` | `[]` | Defense-in-depth re-check of the email worker's `ALLOWED_SENDERS` list. Array of exact strings (case-insensitive) or `/regex/` patterns, e.g. `["notifications@github.com", "/^.*@github\\.com$/"]`. When non-empty, the email handler rejects requests whose `x-email-from` doesn't match. | +| `email_allowed_senders` | `[]` | Defense-in-depth re-check of the email worker's `ALLOWED_SENDERS` list. Array of exact strings (case-insensitive) or `/regex/` patterns, e.g. `["notifications@github.com", "/^.*@github\\.com$/"]`. When non-empty, the email handler rejects requests whose JSON `from` field doesn't match. | | `timeout_ms` | `1800000` (30 min) | Per-session abort budget. | | `max_concurrent` | `2` | Concurrency cap across all triggers. | | `default_cwd` | OpenCode project root | Fallback session cwd when a trigger doesn't override. | diff --git a/packages/opencode-webhooks/src/handlers/email.ts b/packages/opencode-webhooks/src/handlers/email.ts index 233e4ef..5b0afcc 100644 --- a/packages/opencode-webhooks/src/handlers/email.ts +++ b/packages/opencode-webhooks/src/handlers/email.ts @@ -18,7 +18,7 @@ import { import { type EmailEvent, identifyEmail } from "../email/identity" import { synthesizePayload } from "../email/synthesize" import { verifySha256Signature } from "../hmac" -import { computeSynthetics, readBodyBytes } from "../http" +import { MAX_EMAIL_BODY_BYTES, computeSynthetics, readBodyBytes } from "../http" import { evaluateAndDispatch } from "../matchers" import type { DeliveryStore } from "../storage" import { lookupString } from "../template" @@ -55,7 +55,7 @@ export function makeEmailFetchHandler(opts: { // bytes, and HMAC must be over the exact bytes received (not a // re-serialized JSON.stringify(JSON.parse(…)) which is allowed to // re-order keys). - const body = await readBodyBytes(req) + const body = await readBodyBytes(req, MAX_EMAIL_BODY_BYTES) if (!body.ok) return body.response const signature = req.headers.get("x-email-signature-256") @@ -195,14 +195,19 @@ function parseEmailEvent(raw: string): EmailEvent { throw new Error("body is not an object") } const o = obj as Record - const str = (v: unknown): string => (typeof v === "string" ? v : "") + const str = (v: unknown, name: string): string => { + if (typeof v !== "string") { + throw new Error(`field '${name}' must be a string, got ${typeof v}`) + } + return v + } const strOrNull = (v: unknown): string | null => typeof v === "string" ? v : null return { - from: str(o.from), - to: str(o.to), - subject: str(o.subject), - message_id: str(o.message_id), + from: str(o.from, "from"), + to: str(o.to, "to"), + subject: str(o.subject, "subject"), + message_id: str(o.message_id, "message_id"), in_reply_to: strOrNull(o.in_reply_to), references: Array.isArray(o.references) ? o.references.filter((s): s is string => typeof s === "string") diff --git a/packages/opencode-webhooks/src/http.ts b/packages/opencode-webhooks/src/http.ts index 31b274d..b075e52 100644 --- a/packages/opencode-webhooks/src/http.ts +++ b/packages/opencode-webhooks/src/http.ts @@ -2,28 +2,36 @@ import { lookupString } from "./template" -// GitHub's webhook payload cap. Same value bounds the email path so -// the listener never buffers an arbitrarily large RFC822 message. +// Shared upper-bound sized for GitHub's 25 MB webhook payload cap. +// The email path uses a tighter cap (see MAX_EMAIL_BODY_BYTES) since +// the worker now POSTs a small JSON event, not a raw RFC822 message. export const MAX_BODY_BYTES = 25 * 1024 * 1024 +// Tighter cap for /webhooks/email — the JSON event the Cloudflare +// worker posts is well under 5 KB; 64 KB leaves slack for long +// References headers without letting a malicious client buffer +// megabytes by hitting this endpoint directly. +export const MAX_EMAIL_BODY_BYTES = 64 * 1024 + // Read the request body as raw bytes with a size cap enforced both // against the declared Content-Length and the actual buffered size // (defends against lying clients). export async function readBodyBytes( req: Request, + maxBytes: number = MAX_BODY_BYTES, ): Promise< | { ok: true; bytes: Uint8Array } | { ok: false; response: Response } > { const declaredLength = Number(req.headers.get("content-length") ?? "0") - if (declaredLength > MAX_BODY_BYTES) { + if (declaredLength > maxBytes) { return { ok: false, response: Response.json({ error: "payload too large" }, { status: 413 }), } } const bytes = new Uint8Array(await req.arrayBuffer()) - if (bytes.byteLength > MAX_BODY_BYTES) { + if (bytes.byteLength > maxBytes) { return { ok: false, response: Response.json({ error: "payload too large" }, { status: 413 }),