webhooks-public.ts166 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | |
| 3 | import { hashIp } from '../services/audit.js'; |
| 4 | import { invoke } from '../services/invoke.js'; |
| 5 | import { |
| 6 | decryptEndpointSecret, |
| 7 | getWebhookRaw, |
| 8 | recordDelivery, |
| 9 | verifyWebhookSignature, |
| 10 | } from '../services/webhooks.js'; |
| 11 | import type { AppEnv } from '../types/app-env.js'; |
| 12 | |
| 13 | export const webhooksPublicRouter = new Hono<AppEnv>(); |
| 14 | |
| 15 | const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB — generous for most webhook payloads |
| 16 | const MAX_REQUEST_ID_LEN = 64; |
| 17 | |
| 18 | /** |
| 19 | * Public webhook ingestion. Authenticated via HMAC-SHA256 signature only |
| 20 | * — no Better Auth session, no api key, no project-role check. The |
| 21 | * signature IS the authorisation. |
| 22 | * |
| 23 | * POST /webhooks/:projectId/:endpointId |
| 24 | * X-Briven-Signature: v1=<hex> |
| 25 | * X-Briven-Timestamp: <unix-milliseconds> |
| 26 | * content-type: application/json |
| 27 | * |
| 28 | * Always inserts one row into webhook_deliveries — accepted, rejected, |
| 29 | * disabled, function 500 — so the operator can see every inbound attempt |
| 30 | * in the dashboard delivery log. Returns are conservative: signature |
| 31 | * failures get 401, replay failures 401, missing endpoint 404, disabled |
| 32 | * 410, function errors 502, success 200. |
| 33 | */ |
| 34 | webhooksPublicRouter.post('/webhooks/:projectId/:endpointId', async (c) => { |
| 35 | const projectId = c.req.param('projectId'); |
| 36 | const endpointId = c.req.param('endpointId'); |
| 37 | const sourceIpHash = hashIp( |
| 38 | c.req.raw.headers.get('cf-connecting-ip') ?? c.req.raw.headers.get('x-forwarded-for'), |
| 39 | ); |
| 40 | |
| 41 | // Body cap. Read as text first so we can HMAC-verify the EXACT bytes |
| 42 | // that arrived — JSON parsing happens after verification on the way |
| 43 | // into the function invoke. |
| 44 | const rawBody = await c.req.text(); |
| 45 | if (rawBody.length > MAX_BODY_BYTES) { |
| 46 | // Don't even try to record a delivery row — we don't know which |
| 47 | // endpoint this was for in a useful sense, and storing the rejected |
| 48 | // body opens a DoS vector. Just refuse. |
| 49 | return c.json({ code: 'body_too_large', message: 'body exceeds 1 MiB cap' }, 413); |
| 50 | } |
| 51 | |
| 52 | let endpoint; |
| 53 | try { |
| 54 | endpoint = await getWebhookRaw(endpointId, projectId); |
| 55 | } catch { |
| 56 | // Don't differentiate "wrong project" from "wrong endpoint" — both |
| 57 | // 404 so a probe can't enumerate live endpoints. |
| 58 | return c.json({ code: 'not_found' }, 404); |
| 59 | } |
| 60 | |
| 61 | if (!endpoint.enabled) { |
| 62 | await recordDelivery({ |
| 63 | endpointId: endpoint.id, |
| 64 | projectId: endpoint.projectId, |
| 65 | status: 'disabled', |
| 66 | sourceIpHash, |
| 67 | functionName: endpoint.functionName, |
| 68 | durationMs: null, |
| 69 | errorMessage: null, |
| 70 | }); |
| 71 | return c.json({ code: 'disabled', message: 'endpoint is disabled' }, 410); |
| 72 | } |
| 73 | |
| 74 | const signatureHeader = c.req.header('x-briven-signature') ?? null; |
| 75 | const timestampHeader = c.req.header('x-briven-timestamp') ?? null; |
| 76 | const verify = verifyWebhookSignature({ |
| 77 | rawBody, |
| 78 | signatureHeader, |
| 79 | timestampHeader, |
| 80 | plaintextSecret: decryptEndpointSecret(endpoint), |
| 81 | now: new Date(), |
| 82 | }); |
| 83 | |
| 84 | if (!verify.ok) { |
| 85 | await recordDelivery({ |
| 86 | endpointId: endpoint.id, |
| 87 | projectId: endpoint.projectId, |
| 88 | status: verify.status, |
| 89 | sourceIpHash, |
| 90 | functionName: endpoint.functionName, |
| 91 | durationMs: null, |
| 92 | errorMessage: verify.reason, |
| 93 | }); |
| 94 | // 401 for either rejection — don't leak which one to the source. |
| 95 | return c.json({ code: verify.status }, 401); |
| 96 | } |
| 97 | |
| 98 | // Body is verified. Parse and dispatch to the function. We pass the |
| 99 | // body straight through as `args` — the customer's function decides |
| 100 | // what to do with it. Headers aren't forwarded (Phase 3 work). |
| 101 | let args: unknown; |
| 102 | try { |
| 103 | args = rawBody.length === 0 ? {} : JSON.parse(rawBody); |
| 104 | } catch { |
| 105 | await recordDelivery({ |
| 106 | endpointId: endpoint.id, |
| 107 | projectId: endpoint.projectId, |
| 108 | status: 'invoke_error', |
| 109 | sourceIpHash, |
| 110 | functionName: endpoint.functionName, |
| 111 | durationMs: null, |
| 112 | errorMessage: 'request body is not valid json', |
| 113 | }); |
| 114 | return c.json({ code: 'invalid_json', message: 'body must be json' }, 400); |
| 115 | } |
| 116 | |
| 117 | const requestId = (c.get('requestId') ?? '').slice(0, MAX_REQUEST_ID_LEN); |
| 118 | const t0 = Date.now(); |
| 119 | try { |
| 120 | const result = await invoke({ |
| 121 | projectId: endpoint.projectId, |
| 122 | functionName: endpoint.functionName, |
| 123 | args, |
| 124 | requestId, |
| 125 | // Webhook deliveries carry no human identity. The runtime treats |
| 126 | // this the same as a scheduled invocation: no session, no api key. |
| 127 | auth: null, |
| 128 | }); |
| 129 | const durationMs = Date.now() - t0; |
| 130 | if (!result.ok) { |
| 131 | await recordDelivery({ |
| 132 | endpointId: endpoint.id, |
| 133 | projectId: endpoint.projectId, |
| 134 | status: 'invoke_error', |
| 135 | sourceIpHash, |
| 136 | functionName: endpoint.functionName, |
| 137 | durationMs, |
| 138 | errorMessage: `${result.code}: ${result.message}`.slice(0, 500), |
| 139 | }); |
| 140 | return c.json({ code: 'invoke_failed', message: result.message }, 502); |
| 141 | } |
| 142 | await recordDelivery({ |
| 143 | endpointId: endpoint.id, |
| 144 | projectId: endpoint.projectId, |
| 145 | status: 'ok', |
| 146 | sourceIpHash, |
| 147 | functionName: endpoint.functionName, |
| 148 | durationMs, |
| 149 | errorMessage: null, |
| 150 | }); |
| 151 | return c.json({ ok: true }); |
| 152 | } catch (err) { |
| 153 | const durationMs = Date.now() - t0; |
| 154 | const errorMessage = (err instanceof Error ? err.message : String(err)).slice(0, 500); |
| 155 | await recordDelivery({ |
| 156 | endpointId: endpoint.id, |
| 157 | projectId: endpoint.projectId, |
| 158 | status: 'invoke_error', |
| 159 | sourceIpHash, |
| 160 | functionName: endpoint.functionName, |
| 161 | durationMs, |
| 162 | errorMessage, |
| 163 | }); |
| 164 | return c.json({ code: 'internal_error', message: 'invoke threw' }, 500); |
| 165 | } |
| 166 | }); |