Public: anyone can read this; the crew can change it.
Webhooks only reach public https addresses: one outbound guard resolves the host and refuses private ones #2998
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2992-webhooks-only-reach-public-https-address → main 3 commits fork 4 threads · 4 unresolved
README.md +4 −1
| @@ -1596,7 +1596,10 @@ work here. | ||
| 1596 | 1596 | say what the local client keeps cloned. |
| 1597 | 1597 | - **Automation** — a JSON API covering repos, issues, forks, and orgs (the |
| 1598 | 1598 | label-as-queue surface), a durable event outbox, and **webhooks** (HMAC-signed |
| 1599 | delivery with retry). | |
| 1599 | delivery with retry). Every send goes through `src/kernel/outbound.ts`: | |
| 1600 | - https URLs only; the host is resolved, private addresses are refused, and no redirect is followed. | |
| 1601 | - A refused delivery fails at once, with the reason shown on the hooks page. | |
| 1602 | - An unreachable or erroring endpoint is retried with growing delay. | |
| 1600 | 1603 | - **Where an account came from** — `GET /api/v1/admin/accounts` lists each |
| 1601 | 1604 | account with `via` (`signup`, `edge`, or null) and `signsInThrough` |
| 1602 | 1605 | (`"open-platform.sh"` for an account the platform edge made, else null); |
src/core/migrations/20261020090000-webhook-refusal.ts added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | // Why a webhook delivery was not sent at all (#2992): "refused: private | |
| 2 | // address" is no response status, so it has a column of its own. | |
| 3 | export default ` | |
| 4 | ALTER TABLE webhook_delivery ADD COLUMN last_error TEXT; | |
| 5 | `; |
src/core/webhooks.ts +43 −19
| @@ -13,6 +13,11 @@ import type { Database } from "bun:sqlite"; | ||
| 13 | 13 | import { defect } from "../kernel/error.ts"; |
| 14 | 14 | import { id, secret as randomSecret } from "../kernel/id.ts"; |
| 15 | 15 | import type { Log } from "../kernel/log.ts"; |
| 16 | import { | |
| 17 | outboundFetch, | |
| 18 | RefusedError, | |
| 19 | type OutboundOptions, | |
| 20 | } from "../kernel/outbound.ts"; | |
| 16 | 21 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 17 | 22 | import type { ForgeEvent } from "./events.ts"; |
| 18 | 23 | |
| @@ -68,8 +73,8 @@ export function createWebhook( | ||
| 68 | 73 | input: CreateWebhookInput, |
| 69 | 74 | ): Result<{ webhook: Webhook; secret: string }, CreateWebhookError> { |
| 70 | 75 | const url = input.url.trim(); |
| 71 | // Only http(s), and never a bare/relative URL — this value is fetched. | |
| 72 | if (!/^https?:\/\/.+/i.test(url)) return err("invalid_url"); | |
| 76 | // Only https, and never a bare/relative URL — this value is fetched. | |
| 77 | if (!/^https:\/\/.+/i.test(url)) return err("invalid_url"); | |
| 73 | 78 | try { |
| 74 | 79 | // eslint-disable-next-line no-new |
| 75 | 80 | new URL(url); |
| @@ -190,6 +195,7 @@ export async function deliverDue( | ||
| 190 | 195 | db: Database, |
| 191 | 196 | log: Log, |
| 192 | 197 | now = new Date(), |
| 198 | outbound: OutboundOptions = {}, | |
| 193 | 199 | ): Promise<number> { |
| 194 | 200 | const nowIso = now.toISOString(); |
| 195 | 201 | const due = db |
| @@ -226,7 +232,7 @@ export async function deliverDue( | ||
| 226 | 232 | ); |
| 227 | 233 | if (claimed.changes !== 1) continue; |
| 228 | 234 | try { |
| 229 | await deliverOne(db, log, row, now); | |
| 235 | await deliverOne(db, log, row, now, outbound); | |
| 230 | 236 | } catch (error) { |
| 231 | 237 | // One malformed delivery must not take the batch with it. Without |
| 232 | 238 | // this, a single unsendable row stops every delivery behind it — and |
| @@ -249,6 +255,7 @@ async function deliverOne( | ||
| 249 | 255 | log: Log, |
| 250 | 256 | row: DueRow, |
| 251 | 257 | now: Date, |
| 258 | outbound: OutboundOptions, | |
| 252 | 259 | ): Promise<void> { |
| 253 | 260 | const body = JSON.stringify({ |
| 254 | 261 | id: row.event_id, |
| @@ -263,23 +270,36 @@ async function deliverOne( | ||
| 263 | 270 | |
| 264 | 271 | let status = 0; |
| 265 | 272 | try { |
| 266 | const response = await fetch(row.url, { | |
| 267 | method: "POST", | |
| 268 | headers: { | |
| 269 | "content-type": "application/json", | |
| 270 | "user-agent": "forge-webhook", | |
| 271 | "x-forge-event": row.kind, | |
| 272 | "x-forge-delivery": row.id, | |
| 273 | ...(signature === "" | |
| 274 | ? {} | |
| 275 | : { "x-forge-signature-256": `sha256=${signature}` }), | |
| 273 | const response = await outboundFetch( | |
| 274 | row.url, | |
| 275 | { | |
| 276 | method: "POST", | |
| 277 | headers: { | |
| 278 | "content-type": "application/json", | |
| 279 | "user-agent": "forge-webhook", | |
| 280 | "x-forge-event": row.kind, | |
| 281 | "x-forge-delivery": row.id, | |
| 282 | ...(signature === "" | |
| 283 | ? {} | |
| 284 | : { "x-forge-signature-256": `sha256=${signature}` }), | |
| 285 | }, | |
| 286 | body, | |
| 287 | signal: AbortSignal.timeout(DELIVER_TIMEOUT_MS), | |
| 288 | redirect: "manual", | |
| 276 | 289 | }, |
| 277 | body, | |
| 278 | signal: AbortSignal.timeout(DELIVER_TIMEOUT_MS), | |
| 279 | redirect: "manual", | |
| 280 | }); | |
| 290 | outbound, | |
| 291 | ); | |
| 281 | 292 | status = response.status; |
| 282 | 293 | } catch (error) { |
outdated
A refused delivery goes through |
||
| 294 | if (error instanceof RefusedError) { | |
| 295 | // A private address or a plain-http URL will not mend itself in | |
| 296 | // seconds, so there is nothing to retry: fail it now, with the reason. | |
| 297 | log.warn("webhook delivery refused", { delivery: row.id }); | |
| 298 | db.query( | |
| 299 | "UPDATE webhook_delivery SET state = 'failed', attempts = ?1, last_status = NULL, last_error = ?2 WHERE id = ?3", | |
| 300 | ).run(attempt, error.message, row.id); | |
| 301 | return; | |
| 302 | } | |
| 283 | 303 | log.warn("webhook delivery error", { |
| 284 | 304 | delivery: row.id, |
| 285 | 305 | error: String(error), |
| @@ -289,7 +309,7 @@ async function deliverOne( | ||
| 289 | 309 | const ok2xx = status >= 200 && status < 300; |
| 290 | 310 | if (ok2xx) { |
| 291 | 311 | db.query( |
| 292 | "UPDATE webhook_delivery SET state = 'ok', attempts = ?1, last_status = ?2 WHERE id = ?3", | |
| 312 | "UPDATE webhook_delivery SET state = 'ok', attempts = ?1, last_status = ?2, last_error = NULL WHERE id = ?3", | |
| 293 | 313 | ).run(attempt, status, row.id); |
| 294 | 314 | return; |
| 295 | 315 | } |
| @@ -368,6 +388,8 @@ export interface DeliveryInfo { | ||
| 368 | 388 | readonly state: "pending" | "ok" | "failed"; |
| 369 | 389 | readonly attempts: number; |
| 370 | 390 | readonly lastStatus: number | null; |
| 391 | /** Why nothing was sent ("refused: private address"), when nothing was. */ | |
| 392 | readonly lastError: string | null; | |
| 371 | 393 | readonly kind: string; |
| 372 | 394 | } |
| 373 | 395 | |
| @@ -383,11 +405,12 @@ export function recentDeliveries( | ||
| 383 | 405 | state: "pending" | "ok" | "failed"; |
| 384 | 406 | attempts: number; |
| 385 | 407 | last_status: number | null; |
| 408 | last_error: string | null; | |
| 386 | 409 | kind: string; |
| 387 | 410 | }, |
| 388 | 411 | [string, number] |
| 389 | 412 | >( |
| 390 | `SELECT d.id, d.state, d.attempts, d.last_status, e.kind | |
| 413 | `SELECT d.id, d.state, d.attempts, d.last_status, d.last_error, e.kind | |
| 391 | 414 | FROM webhook_delivery d JOIN event e ON e.id = d.event_id |
| 392 | 415 | WHERE d.webhook_id = ?1 ORDER BY d.id DESC LIMIT ?2`, |
| 393 | 416 | ) |
| @@ -397,6 +420,7 @@ export function recentDeliveries( | ||
| 397 | 420 | state: r.state, |
| 398 | 421 | attempts: r.attempts, |
| 399 | 422 | lastStatus: r.last_status, |
| 423 | lastError: r.last_error, | |
| 400 | 424 | kind: r.kind, |
| 401 | 425 | })); |
| 402 | 426 | } |
src/crew/notify.ts +17 −14
| @@ -23,7 +23,7 @@ | ||
| 23 | 23 | import { newAccountLines } from "../actions/accounts.ts"; |
| 24 | 24 | import { mailCeilingLines } from "../actions/mail.ts"; |
| 25 | 25 | import type { Database } from "bun:sqlite"; |
| 26 | import { lookup } from "node:dns/promises"; | |
| 26 | import { outboundFetch } from "../kernel/outbound.ts"; | |
| 27 | 27 | import type { App } from "../app.ts"; |
| 28 | 28 | import { isPrivateHost } from "../actions/migrate.ts"; |
| 29 | 29 | import { getUserSetting, setUserSetting } from "../core/settings.ts"; |
| @@ -141,20 +141,23 @@ export type Sender = ( | ||
| 141 | 141 | headers: Record<string, string>, |
| 142 | 142 | ) => Promise<boolean>; |
| 143 | 143 | |
| 144 | /** The real sender: the name is resolved and judged first, no redirect is followed. */ | |
| 145 | const post: Sender = async (url, body, headers) => { | |
| 146 | const host = new URL(url).hostname; | |
| 147 | const found = await lookup(host, { all: true }); | |
| 148 | if (found.length === 0 || found.some((a) => isPrivateHost(a.address))) | |
| 144 | /** | |
| 145 | * The real sender: the name is resolved and judged first, no redirect is | |
| 146 | * followed, and only https is spoken. A refusal or a transport error is a | |
| 147 | * message not sent, never a throw. | |
| 148 | */ | |
| 149 | export const post: Sender = async (url, body, headers) => { | |
| 150 | try { | |
| 151 | const res = await outboundFetch(url, { | |
| 152 | method: "POST", | |
| 153 | body, | |
| 154 | headers: { "content-type": "text/plain; charset=utf-8", ...headers }, | |
| 155 | signal: AbortSignal.timeout(SEND_TIMEOUT_MS), | |
| 156 | }); | |
| 157 | return res.status >= 200 && res.status < 300; | |
| 158 | } catch { | |
| 149 | 159 | return false; |
| 150 | const res = await fetch(url, { | |
| 151 | method: "POST", | |
| 152 | body, | |
| 153 | headers: { "content-type": "text/plain; charset=utf-8", ...headers }, | |
| 154 | redirect: "manual", | |
| 155 | signal: AbortSignal.timeout(SEND_TIMEOUT_MS), | |
| 156 | }); | |
| 157 | return res.status >= 200 && res.status < 300; | |
| 160 | } | |
| 158 | 161 | }; |
| 159 | 162 | |
| 160 | 163 | /** |
src/kernel/outbound.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** | |
| 2 | * The one guard for anything the forge fetches on somebody else's say-so. | |
| 3 | * The name is resolved here, every answer is judged, and the request goes to | |
| 4 | * the address that was judged — the name stays in `Host` and as the TLS | |
| 5 | * server name — so a second lookup cannot hand back a different answer. | |
| 6 | * Redirects are never followed: a 302 is an answer, not an instruction. | |
| 7 | */ | |
| 8 | ||
| 9 | import { lookup } from "node:dns/promises"; | |
| 10 | import { isIP } from "node:net"; | |
| 11 | import { err, ok, type Result } from "./result.ts"; | |
| 12 | import { addressBytes, inCidrs, parseCidr, type Cidr } from "./cidr.ts"; | |
| 13 | ||
| 14 | const REFUSED = [ | |
| 15 | "0.0.0.0/8", | |
| 16 | "10.0.0.0/8", | |
| 17 | "100.64.0.0/10", | |
| 18 | "127.0.0.0/8", | |
| 19 | "169.254.0.0/16", | |
| 20 | "172.16.0.0/12", | |
| 21 | "192.168.0.0/16", | |
| 22 | "224.0.0.0/4", | |
| 23 | "::/128", | |
| 24 | "::1/128", | |
| 25 | "fc00::/7", | |
| 26 | "fe80::/10", | |
| 27 | "ff00::/8", | |
| 28 | ].map((c) => parseCidr(c)!) satisfies Cidr[]; | |
| 29 | ||
| 30 | /** Is this address one a public service would never have? Unparseable counts. */ | |
| 31 | export function isRefusedAddress(address: string): boolean { | |
| 32 | return addressBytes(address) === undefined || inCidrs(address, REFUSED); | |
| 33 | } | |
| 34 | ||
| 35 | export type Resolve = (host: string) => Promise<readonly string[]>; | |
| 36 | ||
| 37 | export interface OutboundOptions { | |
| 38 | /** Name → addresses. Defaults to the system resolver. Injected by tests. */ | |
| 39 | resolve?: Resolve; | |
| 40 | /** Addresses exempt from the refusal list. For tests only; never read from the environment. */ | |
| 41 | allow?: readonly string[]; | |
| 42 | /** Permit plain http. For tests only; never read from the environment. */ | |
| 43 | plain?: boolean; | |
| 44 | /** Extra trusted CA certificate (PEM) for https. For tests only. */ | |
| 45 | ca?: string | undefined; | |
| 46 | } | |
| 47 | ||
| 48 | const systemResolve: Resolve = async (host) => | |
| 49 | (await lookup(host, { all: true })).map((a) => a.address); | |
| 50 | ||
| 51 | export const REFUSED_SENTENCE = "refused: private address"; | |
| 52 | export const PLAIN_SENTENCE = "refused: not https"; | |
| 53 | ||
| 54 | export class RefusedError extends Error { | |
| 55 | constructor(sentence = REFUSED_SENTENCE) { | |
| 56 | super(sentence); | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | /** The addresses a host resolves to, or an error when any one of them is private. */ | |
| 61 | export async function vetHost( | |
| 62 | host: string, | |
| 63 | options: OutboundOptions = {}, | |
| 64 | ): Promise<Result<string[], "refused" | "unresolved">> { | |
| 65 | const bare = host.replace(/^\[|\]$/g, ""); | |
| 66 | let found: readonly string[]; | |
| 67 | if (isIP(bare) !== 0) found = [bare]; | |
| 68 | else { | |
| 69 | try { | |
| 70 | found = await (options.resolve ?? systemResolve)(bare); | |
| 71 | } catch { | |
| 72 | return err("unresolved"); | |
| 73 | } | |
| 74 | } | |
| 75 | if (found.length === 0) return err("unresolved"); | |
| 76 | const allow = options.allow ?? []; | |
| 77 | if (found.some((a) => isRefusedAddress(a) && !allow.includes(a))) | |
| 78 | return err("refused"); | |
| 79 | return ok([...found]); | |
| 80 | } | |
| 81 | ||
| 82 | /** fetch() to the vetted address; throws RefusedError when the host is private. */ | |
| 83 | export async function outboundFetch( | |
| 84 | url: string, | |
| 85 | init: RequestInit = {}, | |
| 86 | options: OutboundOptions = {}, | |
| 87 | ): Promise<Response> { | |
| 88 | const target = new URL(url); | |
| 89 | if (target.protocol !== "https:" && !options.plain) | |
| 90 | throw new RefusedError(PLAIN_SENTENCE); | |
| 91 | const vetted = await vetHost(target.hostname, options); | |
| 92 | if (!vetted.ok) { | |
| 93 | if (vetted.error === "refused") throw new RefusedError(); | |
| 94 | throw new Error("unresolved host"); | |
| 95 | } | |
| 96 | const ip = vetted.value[0]!; | |
| 97 | const name = target.hostname.replace(/^\[|\]$/g, ""); | |
| 98 | const pinned = new URL(url); | |
| 99 | pinned.hostname = ip.includes(":") ? `[${ip}]` : ip; | |
outdated
|
||
| 100 | const headers = new Headers(init.headers); | |
| 101 | headers.set("host", target.host); | |
| 102 | return fetch(pinned, { | |
| 103 | ...init, | |
| 104 | headers, | |
| 105 | redirect: "manual", | |
| 106 | ...(target.protocol === "https:" | |
| 107 | ? { tls: { serverName: name, ...(options.ca ? { ca: options.ca } : {}) } } | |
| 108 | : {}), | |
| 109 | } satisfies BunFetchRequestInit); | |
| 110 | } |
src/web/pages/settings.ts +1 −1
| @@ -1640,7 +1640,7 @@ export function hooksPage(ctx: Ctx): Response { | ||
| 1640 | 1640 | ${d.state} |
| 1641 | 1641 | </td> |
| 1642 | 1642 | <td>${d.attempts}</td> |
| 1643 | <td>${d.lastStatus ?? "—"}</td> | |
| 1643 | <td>${d.lastError ?? d.lastStatus ?? "—"}</td> | |
Add a test for this cell. The refusal sentence is shown here and it is the only place a person sees why nothing arrived. |
||
| 1644 | 1644 | </tr>`, |
| 1645 | 1645 | )} |
| 1646 | 1646 | </tbody> |
test/agents.test.ts +4 −1
| @@ -970,7 +970,10 @@ describe("git process limits and timeouts", () => { | ||
| 970 | 970 | |
| 971 | 971 | const { deliverDue } = await import("../src/core/webhooks.ts"); |
| 972 | 972 | const started = Date.now(); |
| 973 | await deliverDue(app.db, app.log); | |
| 973 | await deliverDue(app.db, app.log, new Date(), { | |
| 974 | allow: ["127.0.0.1", "::1"], | |
| 975 | plain: true, | |
| 976 | }); | |
| 974 | 977 | const elapsed = Date.now() - started; |
| 975 | 978 | // The delivery timeout is 10s; anything under 30 proves a signal fired |
| 976 | 979 | // rather than the request hanging on forever. |
test/helpers/loopback-hook.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | /** | |
| 2 | * Test receivers listen on loopback over plain http, which the forge refuses | |
| 3 | * to save and refuses to send to. These two get a test around that without | |
| 4 | * any switch production could carry: a row inserted past `createWebhook`, and | |
| 5 | * an allow-list passed to `deliverDue` as an argument. | |
| 6 | */ | |
| 7 | import type { App } from "../../src/app.ts"; | |
| 8 | import { id } from "../../src/kernel/id.ts"; | |
| 9 | ||
| 10 | export const LOOPBACK = { allow: ["127.0.0.1", "::1"], plain: true } as const; | |
| 11 | ||
| 12 | export function plainHook(app: App, repoId: string, url: string, kinds = "*"): void { | |
| 13 | app.db | |
| 14 | .query("INSERT INTO webhook (id, repo_id, url, secret, kinds) VALUES (?1, ?2, ?3, 'test-secret', ?4)") | |
| 15 | .run(id(), repoId, url, kinds); | |
| 16 | } |
test/helpers/overlap-worker.ts +2 −1
| @@ -14,6 +14,7 @@ import { getAgentByName } from "../../src/core/agents.ts"; | ||
| 14 | 14 | import { getIssue } from "../../src/core/issues.ts"; |
| 15 | 15 | import { getRepo } from "../../src/core/repos.ts"; |
| 16 | 16 | import { deliverDue } from "../../src/core/webhooks.ts"; |
| 17 | import { LOOPBACK } from "./loopback-hook.ts"; | |
| 17 | 18 | import { queueAgentRun } from "../../src/crew/dispatch.ts"; |
| 18 | 19 | |
| 19 | 20 | const [mode, dataDir, startAt, count] = process.argv.slice(2); |
| @@ -30,7 +31,7 @@ if (mode === "queue") { | ||
| 30 | 31 | } else { |
| 31 | 32 | const until = Date.now() + 3000; |
| 32 | 33 | while (Date.now() < until) { |
| 33 | const sent = await deliverDue(app.db, app.log); | |
| 34 | const sent = await deliverDue(app.db, app.log, new Date(), LOOPBACK); | |
| 34 | 35 | done += sent; |
| 35 | 36 | if (sent === 0) await Bun.sleep(20); |
| 36 | 37 | } |
test/notify.test.ts +8
| @@ -15,6 +15,7 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 15 | 15 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 16 | 16 | import { createUser, type User } from "../src/core/users.ts"; |
| 17 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 18 | import { post } from "../src/crew/notify.ts"; | |
| 18 | 19 | import { |
| 19 | 20 | notifyHost, |
| 20 | 21 | notifyUrlOf, |
| @@ -161,6 +162,13 @@ describe("notify", () => { | ||
| 161 | 162 | }); |
| 162 | 163 | }); |
| 163 | 164 | |
| 165 | describe("the real sender", () => { | |
| 166 | test("a plain-http or private URL is a message not sent, not a throw", async () => { | |
| 167 | expect(await post("http://ntfy.example.test/t", "x", {})).toBe(false); | |
| 168 | expect(await post("https://127.0.0.1:9/t", "x", {})).toBe(false); | |
| 169 | }); | |
| 170 | }); | |
| 171 | ||
| 164 | 172 | describe("the notify URL", () => { |
| 165 | 173 | test("an http: URL is refused; so are credentials, long, private and loopback hosts", () => { |
| 166 | 174 | const refused = (u: string) => { |
test/outbound.test.ts added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | import { afterAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, readFileSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { outboundFetch, vetHost, type Resolve } from "../src/kernel/outbound.ts"; | |
| 6 | ||
| 7 | const to = (...a: string[]): Resolve => () => Promise.resolve(a); | |
| 8 | ||
| 9 | describe("vetHost", () => { | |
| 10 | for (const addr of [ | |
| 11 | "127.0.0.1", | |
| 12 | "10.0.0.1", | |
| 13 | "172.16.5.5", | |
| 14 | "192.168.1.1", | |
| 15 | "169.254.169.254", | |
| 16 | "100.64.0.1", | |
| 17 | "0.0.0.0", | |
| 18 | "224.0.0.1", | |
| 19 | "::1", | |
| 20 | "::", | |
| 21 | "fe80::1", | |
| 22 | "fd00::1", | |
| 23 | "ff02::1", | |
| 24 | "::ffff:127.0.0.1", | |
| 25 | "::ffff:10.0.0.1", | |
| 26 | ]) | |
| 27 | test(`refuses ${addr}`, async () => { | |
| 28 | const r = await vetHost(addr); | |
| 29 | expect(r.ok).toBe(false); | |
| 30 | if (!r.ok) expect(r.error).toBe("refused"); | |
| 31 | }); | |
| 32 | ||
| 33 | test("refuses a public name that resolves to 127.0.0.1", async () => { | |
| 34 | const r = await vetHost("hooks.example.com", { resolve: to("127.0.0.1") }); | |
| 35 | expect(r).toEqual({ ok: false, error: "refused" }); | |
| 36 | }); | |
| 37 | ||
| 38 | test("refuses when any one answer is private", async () => { | |
| 39 | const r = await vetHost("x.example.com", { resolve: to("93.184.216.34", "10.0.0.1") }); | |
| 40 | expect(r.ok).toBe(false); | |
| 41 | }); | |
| 42 | ||
| 43 | test("allows a public address", async () => { | |
| 44 | const r = await vetHost("hooks.example.com", { resolve: to("93.184.216.34") }); | |
| 45 | expect(r).toEqual({ ok: true, value: ["93.184.216.34"] }); | |
| 46 | }); | |
| 47 | ||
| 48 | test("a failed lookup is unresolved, not allowed", async () => { | |
| 49 | const r = await vetHost("nope.example.com", { resolve: () => Promise.reject(new Error("x")) }); | |
| 50 | expect(r).toEqual({ ok: false, error: "unresolved" }); | |
| 51 | }); | |
| 52 | }); | |
| 53 | ||
| 54 | describe("outboundFetch", () => { | |
| 55 | const seen: { host: string | null; path: string }[] = []; | |
| 56 | const server = Bun.serve({ | |
| 57 | port: 0, | |
| 58 | fetch(req) { | |
| 59 | seen.push({ host: req.headers.get("host"), path: new URL(req.url).pathname }); | |
| 60 | if (new URL(req.url).pathname === "/redir") | |
| 61 | return new Response(null, { status: 302, headers: { location: "http://10.0.0.1/secret" } }); | |
| 62 | return new Response("ok"); | |
| 63 | }, | |
| 64 | }); | |
| 65 | afterAll(() => server.stop(true)); | |
| 66 | const allow = ["127.0.0.1"]; | |
| 67 | ||
| 68 | test("throws before connecting to a private address", async () => { | |
| 69 | const before = seen.length; | |
| 70 | await expect( | |
| 71 | outboundFetch(`http://hooks.example.com:${server.port}/x`, {}, { resolve: to("127.0.0.1"), plain: true }), | |
| 72 | ).rejects.toThrow("refused: private address"); | |
| 73 | expect(seen.length).toBe(before); | |
| 74 | }); | |
| 75 | ||
| 76 | test("connects to the vetted address and keeps the name in Host", async () => { | |
| 77 | const res = await outboundFetch( | |
| 78 | `http://hooks.example.com:${server.port}/x`, | |
| 79 | {}, | |
| 80 | { resolve: to("127.0.0.1"), allow, plain: true }, | |
| 81 | ); | |
| 82 | expect(res.status).toBe(200); | |
| 83 | expect(seen.at(-1)).toEqual({ host: `hooks.example.com:${server.port}`, path: "/x" }); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a 302 to a private address is returned, not followed", async () => { | |
| 87 | const res = await outboundFetch( | |
| 88 | `http://hooks.example.com:${server.port}/redir`, | |
| 89 | {}, | |
| 90 | { resolve: to("127.0.0.1"), allow, plain: true }, | |
| 91 | ); | |
| 92 | expect(res.status).toBe(302); | |
| 93 | expect(seen.filter((s) => s.path === "/secret")).toHaveLength(0); | |
| 94 | }); | |
| 95 | }); | |
| 96 | ||
| 97 | /** A throwaway self-signed certificate for `name`, made with openssl so no key is committed. */ | |
| 98 | function selfSigned(name: string): { cert: string; key: string } | undefined { | |
| 99 | const dir = mkdtempSync(join(tmpdir(), "forge-cert-")); | |
| 100 | const run = Bun.spawnSync([ | |
| 101 | "openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", | |
| 102 | "-nodes", "-keyout", join(dir, "k.pem"), "-out", join(dir, "c.pem"), "-days", "2", | |
| 103 | "-subj", `/CN=${name}`, "-addext", `subjectAltName=DNS:${name}`, | |
| 104 | ]); | |
| 105 | if (run.exitCode !== 0) return undefined; | |
| 106 | return { cert: readFileSync(join(dir, "c.pem"), "utf8"), key: readFileSync(join(dir, "k.pem"), "utf8") }; | |
| 107 | } | |
| 108 | ||
| 109 | describe("outboundFetch over https", () => { | |
| 110 | const pair = selfSigned("hooks.example.com"); | |
| 111 | const t = pair ? test : test.skip; | |
| 112 | const servers: ReturnType<typeof Bun.serve>[] = []; | |
| 113 | afterAll(() => servers.forEach((s) => s.stop(true))); | |
| 114 | const serveTls = () => { | |
| 115 | const s = Bun.serve({ port: 0, tls: { cert: pair!.cert, key: pair!.key }, fetch: () => new Response("secure") }); | |
| 116 | servers.push(s); | |
| 117 | return s; | |
| 118 | }; | |
| 119 | const opts = { resolve: to("127.0.0.1"), allow: ["127.0.0.1"], ca: pair?.cert }; | |
| 120 | ||
| 121 | t("connects to the judged address and checks the certificate against the name", async () => { | |
| 122 | const s = serveTls(); | |
| 123 | const res = await outboundFetch(`https://hooks.example.com:${s.port}/x`, {}, opts); | |
| 124 | expect(await res.text()).toBe("secure"); | |
| 125 | }); | |
| 126 | ||
| 127 | t("fails when the certificate is not for the name", async () => { | |
| 128 | const s = serveTls(); | |
| 129 | await expect(outboundFetch(`https://other.example.com:${s.port}/x`, {}, opts)).rejects.toThrow(); | |
| 130 | }); | |
| 131 | ||
| 132 | t("is not trusted without the CA", async () => { | |
| 133 | const s = serveTls(); | |
| 134 | await expect( | |
| 135 | outboundFetch(`https://hooks.example.com:${s.port}/x`, {}, { ...opts, ca: undefined }), | |
| 136 | ).rejects.toThrow(); | |
| 137 | }); | |
| 138 | }); | |
| 139 | ||
| 140 | describe("outboundFetch refuses plain http", () => { | |
| 141 | test("before resolving anything", async () => { | |
| 142 | let looked = false; | |
| 143 | await expect( | |
| 144 | outboundFetch("http://hooks.example.com/x", {}, { resolve: () => ((looked = true), to("93.184.216.34")("")) }), | |
| 145 | ).rejects.toThrow("refused: not https"); | |
| 146 | expect(looked).toBe(false); | |
| 147 | }); | |
| 148 | }); |
test/overlap.test.ts +5 −15
| @@ -16,10 +16,10 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 16 | 16 | import { createUser } from "../src/core/users.ts"; |
| 17 | 17 | import { |
| 18 | 18 | CLAIM_MS, |
| 19 | createWebhook, | |
| 20 | 19 | DELIVER_TIMEOUT_MS, |
| 21 | 20 | deliverDue, |
| 22 | 21 | } from "../src/core/webhooks.ts"; |
| 22 | import { LOOPBACK, plainHook } from "./helpers/loopback-hook.ts"; | |
| 23 | 23 | import { serve } from "../src/http/server.ts"; |
| 24 | 24 | import { drainStreams } from "../src/http/sse.ts"; |
| 25 | 25 | |
| @@ -97,12 +97,7 @@ describe("two processes on one data directory start each run once", () => { | ||
| 97 | 97 | }, |
| 98 | 98 | }); |
| 99 | 99 | try { |
| 100 | const made = createWebhook(app.db, { | |
| 101 | repoId, | |
| 102 | url: `http://127.0.0.1:${receiver.port}/hook`, | |
| 103 | kinds: "", | |
| 104 | }); | |
| 105 | if (!made.ok) throw new Error(String(made.error)); | |
| 100 | plainHook(app, repoId, `http://127.0.0.1:${receiver.port}/hook`); | |
| 106 | 101 | for (let n = 0; n < 60; n++) |
| 107 | 102 | app.bus.append({ kind: "push", repoId, payload: { n } }); |
| 108 | 103 | const pending = ( |
| @@ -150,19 +145,14 @@ describe("two processes on one data directory start each run once", () => { | ||
| 150 | 145 | }, |
| 151 | 146 | }); |
| 152 | 147 | try { |
| 153 | const made = createWebhook(app.db, { | |
| 154 | repoId, | |
| 155 | url: `http://127.0.0.1:${receiver.port}/slow`, | |
| 156 | kinds: "", | |
| 157 | }); | |
| 158 | if (!made.ok) throw new Error(String(made.error)); | |
| 148 | plainHook(app, repoId, `http://127.0.0.1:${receiver.port}/slow`); | |
| 159 | 149 | for (let n = 0; n < 4; n++) |
| 160 | 150 | app.bus.append({ kind: "push", repoId, payload: { slow: n } }); |
| 161 | 151 | // A batch that began a while ago, as one behind a slow endpoint has: |
| 162 | 152 | // its claims must still be leased from the moment each is taken. |
| 163 | const behind = deliverDue(app.db, app.log, new Date(Date.now() - 2 * CLAIM_MS)); | |
| 153 | const behind = deliverDue(app.db, app.log, new Date(Date.now() - 2 * CLAIM_MS), LOOPBACK); | |
| 164 | 154 | await Bun.sleep(100); |
| 165 | const beside = deliverDue(app.db, app.log); | |
| 155 | const beside = deliverDue(app.db, app.log, new Date(), LOOPBACK); | |
| 166 | 156 | await Promise.all([behind, beside]); |
| 167 | 157 | expect(hits.size).toBe(4); |
| 168 | 158 | expect([...hits.values()].every((n) => n === 1)).toBe(true); |
test/webhooks.test.ts +111 −17
| @@ -1,10 +1,14 @@ | ||
| 1 | 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 2 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 3 | 4 | import { tmpdir } from "node:os"; |
| 4 | 5 | import { join } from "node:path"; |
| 5 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 7 | import { createRepo } from "../src/core/repos.ts"; |
| 7 | 8 | import { createUser } from "../src/core/users.ts"; |
| 9 | import { createSession } from "../src/core/sessions.ts"; | |
| 10 | import { serve } from "../src/http/server.ts"; | |
| 11 | import { LOOPBACK, plainHook } from "./helpers/loopback-hook.ts"; | |
| 8 | 12 | import { |
| 9 | 13 | createWebhook, |
| 10 | 14 | deliverDue, |
| @@ -80,13 +84,8 @@ describe("webhook delivery", () => { | ||
| 80 | 84 | |
| 81 | 85 | test("a matching event is delivered, signed, and verifiable", async () => { |
| 82 | 86 | received = []; |
| 83 | const { secret } = unwrap( | |
| 84 | createWebhook(app.db, { | |
| 85 | repoId, | |
| 86 | url: `${receiverUrl}/ok`, | |
| 87 | kinds: "push", | |
| 88 | }), | |
| 89 | ); | |
| 87 | plainHook(app, repoId, `${receiverUrl}/ok`, "push"); | |
| 88 | const secret = "test-secret"; | |
| 90 | 89 | // A matching event enqueues a delivery via the bus subscriber. |
| 91 | 90 | app.bus.append({ |
| 92 | 91 | kind: "push", |
| @@ -102,7 +101,7 @@ describe("webhook delivery", () => { | ||
| 102 | 101 | payload: {}, |
| 103 | 102 | }); |
| 104 | 103 | |
| 105 | const attempted = await deliverDue(app.db, app.log); | |
| 104 | const attempted = await deliverDue(app.db, app.log, new Date(), LOOPBACK); | |
| 106 | 105 | expect(attempted).toBe(1); // only the push matched "push" |
| 107 | 106 | expect(received).toHaveLength(1); |
| 108 | 107 | expect(received[0]!.event).toBe("push"); |
| @@ -116,11 +115,11 @@ describe("webhook delivery", () => { | ||
| 116 | 115 | }); |
| 117 | 116 | |
| 118 | 117 | test("a failing endpoint retries then gives up, without losing the row", async () => { |
| 119 | createWebhook(app.db, { repoId, url: `${receiverUrl}/fail`, kinds: "tag" }); | |
| 118 | plainHook(app, repoId, `${receiverUrl}/fail`, "tag"); | |
| 120 | 119 | app.bus.append({ kind: "tag", repoId, actorId: userId, payload: {} }); |
| 121 | 120 | |
| 122 | 121 | // First attempt fails → row goes pending with a future next_try_at. |
| 123 | await deliverDue(app.db, app.log); | |
| 122 | await deliverDue(app.db, app.log, new Date(), LOOPBACK); | |
| 124 | 123 | let row = app.db |
| 125 | 124 | .query< |
| 126 | 125 | { state: string; attempts: number; next_try_at: string | null }, |
| @@ -133,7 +132,7 @@ describe("webhook delivery", () => { | ||
| 133 | 132 | |
| 134 | 133 | // Drive it to exhaustion by delivering far in the future each time. |
| 135 | 134 | for (let i = 0; i < 6; i++) { |
| 136 | await deliverDue(app.db, app.log, new Date(Date.now() + i * 3_600_000)); | |
| 135 | await deliverDue(app.db, app.log, new Date(Date.now() + i * 3_600_000), LOOPBACK); | |
| 137 | 136 | } |
| 138 | 137 | const failed = app.db |
| 139 | 138 | .query< |
| @@ -147,11 +146,7 @@ describe("webhook delivery", () => { | ||
| 147 | 146 | test("a flaky endpoint eventually succeeds on retry", async () => { |
| 148 | 147 | received = []; |
| 149 | 148 | failCount = 0; |
| 150 | createWebhook(app.db, { | |
| 151 | repoId, | |
| 152 | url: `${receiverUrl}/flaky`, | |
| 153 | kinds: "flaky.*", | |
| 154 | }); | |
| 149 | plainHook(app, repoId, `${receiverUrl}/flaky`, "flaky.*"); | |
| 155 | 150 | app.bus.append({ |
| 156 | 151 | kind: "flaky.event", |
| 157 | 152 | repoId, |
| @@ -162,7 +157,7 @@ describe("webhook delivery", () => { | ||
| 162 | 157 | // 503, 503, then 200 — deliver repeatedly with advancing time. |
| 163 | 158 | let ok = false; |
| 164 | 159 | for (let i = 0; i < 5 && !ok; i++) { |
| 165 | await deliverDue(app.db, app.log, new Date(Date.now() + i * 3_600_000)); | |
| 160 | await deliverDue(app.db, app.log, new Date(Date.now() + i * 3_600_000), LOOPBACK); | |
| 166 | 161 | const row = app.db |
| 167 | 162 | .query< |
| 168 | 163 | { state: string }, |
| @@ -176,6 +171,105 @@ describe("webhook delivery", () => { | ||
| 176 | 171 | }); |
| 177 | 172 | }); |
| 178 | 173 | |
| 174 | describe("webhook urls and refused deliveries", () => { | |
| 175 | test("only https is saved, with invalid_url", () => { | |
| 176 | const app = createApp({ dataDir: tmp(), port: 0 }); | |
| 177 | try { | |
| 178 | const r = createWebhook(app.db, { repoId: "r", url: "http://example.com/h" }); | |
| 179 | expect(r.ok).toBe(false); | |
| 180 | if (!r.ok) expect(r.error).toBe("invalid_url"); | |
| 181 | } finally { | |
| 182 | app.close(); | |
| 183 | } | |
| 184 | }); | |
| 185 | ||
| 186 | test("a delivery to a private address is refused and records no status", async () => { | |
| 187 | const app = createApp({ dataDir: tmp(), port: 0 }); | |
| 188 | try { | |
| 189 | app.db.query("INSERT INTO user (id, username, email, password_hash) VALUES ('u1', 'w', '[email protected]', 'x')").run(); | |
| 190 | app.db.query("INSERT INTO repo (id, owner_id, name) VALUES ('r1', 'u1', 'h')").run(); | |
| 191 | plainHook(app, "r1", "https://127.0.0.1:9/h"); | |
| 192 | app.bus.append({ kind: "push", repoId: "r1" }); | |
| 193 | await deliverDue(app.db, app.log); | |
| 194 | const row = app.db | |
| 195 | .query<{ last_status: number | null; last_error: string | null; attempts: number }, []>( | |
| 196 | "SELECT last_status, last_error, attempts FROM webhook_delivery", | |
| 197 | ) | |
| 198 | .get(); | |
| 199 | expect(row?.last_status).toBeNull(); | |
| 200 | expect(row?.last_error).toBe("refused: private address"); | |
| 201 | expect(row?.attempts).toBe(1); | |
| 202 | } finally { | |
| 203 | app.close(); | |
| 204 | } | |
| 205 | }); | |
| 206 | test("a refusal fails at once, and the hooks page says why", async () => { | |
| 207 | const app = createApp({ dataDir: tmp(), port: 0 }); | |
| 208 | const servers = await serve(app); | |
| 209 | try { | |
| 210 | const user = unwrap( | |
| 211 | await createUser(app.db, { origin: "seed", trusted: true, username: "shown", email: "[email protected]", password: "shown-in-settings" }), | |
| 212 | ); | |
| 213 | const repo = unwrap(await createRepo(app.db, app.reposRoot, { ownerId: user.id, name: "h" })); | |
| 214 | unwrap(createWebhook(app.db, { repoId: repo.id, url: "https://127.0.0.1:9/h" })); | |
| 215 | app.bus.append({ kind: "push", repoId: repo.id }); | |
| 216 | await deliverDue(app.db, app.log); | |
| 217 | const row = app.db | |
| 218 | .query<{ state: string; attempts: number }, []>("SELECT state, attempts FROM webhook_delivery") | |
| 219 | .get(); | |
| 220 | expect(row).toEqual({ state: "failed", attempts: 1 }); | |
| 221 | const cookie = `forge_session=${createSession(app.db, user.id).token}`; | |
| 222 | const page = await fetch(`${servers.url}/shown/h/settings/hooks`, { headers: { cookie } }); | |
| 223 | expect(page.status).toBe(200); | |
| 224 | expect(await page.text()).toContain("refused: private address"); | |
| 225 | } finally { | |
| 226 | servers.stop(); | |
| 227 | app.close(); | |
| 228 | } | |
| 229 | }); | |
| 230 | ||
| 231 | test("a delivery that succeeds clears a stale last_error", async () => { | |
| 232 | const app = createApp({ dataDir: tmp(), port: 0 }); | |
| 233 | const receiver = Bun.serve({ port: 0, fetch: () => new Response("ok") }); | |
| 234 | try { | |
| 235 | app.db.query("INSERT INTO user (id, username, email, password_hash) VALUES ('u1', 'w', '[email protected]', 'x')").run(); | |
| 236 | app.db.query("INSERT INTO repo (id, owner_id, name) VALUES ('r1', 'u1', 'h')").run(); | |
| 237 | plainHook(app, "r1", `http://127.0.0.1:${receiver.port}/h`); | |
| 238 | app.bus.append({ kind: "push", repoId: "r1" }); | |
| 239 | app.db.query("UPDATE webhook_delivery SET last_error = 'old'").run(); | |
| 240 | await deliverDue(app.db, app.log, new Date(), LOOPBACK); | |
| 241 | const row = app.db | |
| 242 | .query<{ state: string; last_error: string | null }, []>("SELECT state, last_error FROM webhook_delivery") | |
| 243 | .get(); | |
| 244 | expect(row).toEqual({ state: "ok", last_error: null }); | |
| 245 | } finally { | |
| 246 | receiver.stop(true); | |
| 247 | app.close(); | |
| 248 | } | |
| 249 | }); | |
| 250 | ||
| 251 | test("a hook saved as http before the https rule is refused, not sent", async () => { | |
| 252 | const app = createApp({ dataDir: tmp(), port: 0 }); | |
| 253 | try { | |
| 254 | app.db.query("INSERT INTO user (id, username, email, password_hash) VALUES ('u1', 'w', '[email protected]', 'x')").run(); | |
| 255 | app.db.query("INSERT INTO repo (id, owner_id, name) VALUES ('r1', 'u1', 'h')").run(); | |
| 256 | plainHook(app, "r1", "http://93.184.216.34/h"); | |
| 257 | app.bus.append({ kind: "push", repoId: "r1" }); | |
| 258 | await deliverDue(app.db, app.log); | |
| 259 | const row = app.db | |
| 260 | .query<{ state: string; last_error: string | null }, []>("SELECT state, last_error FROM webhook_delivery") | |
| 261 | .get(); | |
| 262 | expect(row).toEqual({ state: "failed", last_error: "refused: not https" }); | |
| 263 | } finally { | |
| 264 | app.close(); | |
| 265 | } | |
| 266 | }); | |
| 267 | }); | |
| 268 | ||
| 269 | function tmp(): string { | |
| 270 | return mkdtempSync(join(tmpdir(), "forge-hook-r-")); | |
| 271 | } | |
| 272 | ||
| 179 | 273 | function unwrap<T>( |
| 180 | 274 | r: { ok: true; value: T } | { ok: false; error: unknown }, |
| 181 | 275 | ): T { |
The fetch options are not re-indented after wrapping the call, so the block does not match the file. Format it.