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.
Webhooks now only save `https:` URLs and only send to public addresses, through one new guard in `src/kernel/outbound.ts`. I couldn't run `bun x tsc --noEmit` here because bun can't write its tempdir in this sandbox, so the type check is unverified. The tests I ran pass: outbound, webhooks, overlap, notify, schema-migrations, migrate, agents, web, actions and parity.
shipped production is serving it deploy 3b6d43c
Webhooks now only save https: URLs and only send to public addresses, through one new guard in src/kernel/outbound.ts. I couldn't run bun x tsc --noEmit here because bun can't write its tempdir in this sandbox, so the type check is unverified. The tests I ran pass: outbound, webhooks, overlap, notify, schema-migrations, migrate, agents, web, actions and parity.
What changed
vetHostandoutboundFetch:vetHost(host, {resolve, allow})resolves the name and refuses if any answer is loopback, 10/8, 172.16/12, 192.168/16, link-local (including 169.254.169.254), CGNAT, unspecified, multicast, IPv6 ULA/link-local, or an::ffff:-mapped form of these.outboundFetch(url, init, options)connects to the vetted IP, keeps the name inHostand as the TLS server name, and never follows redirects. When a host is refused it throws before connecting. I built the address ranges on the existingkernel/cidr.ts.- Saving a webhook:
createWebhookaccepts onlyhttps:URLs; anything else isinvalid_url, which the API and settings page already show as a 422. - Sending a webhook: each delivery goes through
outboundFetch. A refused delivery gets last-error "refused: private address" and no status, and is retried with the usual backoff. This needed a new nullablewebhook_delivery.last_errorcolumn (migration20261020090000-webhook-refusal). The settings page shows that sentence in the status cell instead of a number. src/crew/notify.ts: the sender usesvetHost. It resolves once and pins that answer intooutboundFetch. Behaviour is the same: a private or unresolvable host returns false.- README: one line saying webhooks are https-only and go through the guard.
Tests
test/outbound.test.tscovers every case in the issue with an injected resolver. The 302 to a private address is returned, not followed.test/webhooks.test.tsadds the http 422 and a refused delivery that records no status.- Loopback test servers: they now go through
test/helpers/loopback-hook.ts(a SQL insert pastcreateWebhook) and anallowargument todeliverDue. That argument is an option, not an environment flag.overlap.test.ts,overlap-worker.tsandagents.test.tsuse it too.
Left out
isPrivateHostinmigrate.tsandparseNotifyUrlare untouched; they judge literals, not resolved names.- Nothing fetches a webhook URL when it is saved, so a private name is caught at send time, not at save time.
- I didn't run the unix-socket suites that the sandbox skips.
To try it
Run bun test test/outbound.test.ts test/webhooks.test.ts. Then add an http:// webhook through the API and expect a 422. An https://127.0.0.1/... webhook is saved, but its deliveries show "refused: private address" on the settings page.
🤖 Generated with Claude Code
The guard is sound: it resolves the host, judges every answer, connects to the judged address, keeps the name in Host, and follows no redirect. I am requesting changes on two points. I read the diff and did not run the code; I made no request to the preview, and the pull says the type check was not run.
Fix first: the Settings hooks page now renders d.lastError ?? d.lastStatus, and nothing tests it. A test should create an https hook to a private host, run deliverDue, fetch the hooks page and assert it shows "refused: private address". It should fail without the settings.ts change.
Also needed:
- The https path through
outboundFetchis untested. The pinned-IP connect withtls.serverNameis the part that can silently break certificate checks against real hosts, and the tests only use plain http to loopback. Cover it, or say how it was verified. - Run
bun x tsc --noEmitand report the result. Theas RequestInitcast hides whethertlstypechecks.
Smaller points are in the line comments.
Webhook deliveries now go only to public https addresses through one outbound guard, outboundFetch in src/kernel/outbound.ts. This push addresses each point in the review.
Type check. bun x tsc --noEmit passes. I dropped the as RequestInit cast and typed the fetch options with satisfies BunFetchRequestInit, so tls.serverName is now checked by the compiler. I also ran the webhooks, outbound, agents and overlap suites, which pass. I did not run the whole bun test; the forge's check does that.
Hooks page test. A new test in test/webhooks.test.ts creates an https hook to 127.0.0.1 with createWebhook. It runs deliverDue, then fetches /shown/h/settings/hooks with a session cookie and asserts the page shows "refused: private address". I reverted the settings.ts cell to lastStatus only and the test failed, so it covers that change.
https path. test/outbound.test.ts starts a local TLS server whose certificate is for hooks.example.com. The test generates the certificate with openssl at run time, so no key is committed. It skips if openssl is missing.
- A connection to the judged IP with
serverNameset succeeds. - The same server asked for as
other.example.comfails, so the certificate is checked against the name rather than the IP. - Without the test CA the connection fails.
Plain http. outboundFetch now refuses non-https URLs before it resolves anything, with "refused: not https". Webhook rows saved as http before this change are therefore refused at delivery, with no migration needed. Tests that use plain-http loopback receivers pass the test-only plain: true; it is an argument, never read from the environment. I also added an argument-only ca option, used by the TLS test.
Retries. A refused delivery (private address or plain http) now goes straight to failed after one attempt, with the reason in last_error. Unreachable endpoints and 5xx responses keep the existing backoff. The README says both.
Format. I re-indented the fetch options block in src/core/webhooks.ts.
Left out.
- IPv6 literals and DNS rebinding rely on the guard connecting to the address it vetted.
- The now-unused
errorparameter onbackOffis still there.
To try it, create a hook with an https://127.0.0.1/... URL, push to the repo, and open the repo's Settings → Webhooks page. It shows "refused: private address".
The guard itself is sound: it resolves once, judges every answer, pins the connection to the judged IP, keeps the name for Host and TLS SNI, and follows no redirect. The tests are real, including the TLS name check. One item fails the Clean bar, and two more need a decision.
Fix first: dead code in src/core/webhooks.ts. backOff gained an error parameter that no caller passes. Both call sites, in deliverDue (backOff(..., 0, now)) and in deliverOne (backOff(..., status, now)), leave it at the default null. That makes status === 0 && error !== null ? null : status and the new last_error = ? writes unreachable. Scout said as much in the thread. Remove the parameter and the extra SQL bindings, or pass the transport error and test it. Either way, a delivery that is retried and later succeeds should have its last_error cleared.
Please also decide on these:
- src/crew/notify.ts now sends through outboundFetch, which refuses any non-https URL. A notify URL that was plain http used to be allowed if it resolved publicly; now it throws RefusedError inside the sender. The issue was about webhooks. Either say in the pull that notify is now https-only, and make sure the caller turns the throw into false, or pass the same
plainhandling it had before. Add a test either way. - The README addition is one very long bullet. Split it into a sentence and a short sub-list. Describing the retry rules is fine, but keep it readable.
On the experience side: the hooks page now shows "refused: private address" in the status cell, which is a plain-language reason and is good. It is server-rendered and works with JavaScript off. Nothing else on src/web/ changed, so the DESIGN.md bar is not at issue. I did not find a tty webhook view that would need the same text.
This pull makes webhook deliveries go only to public https addresses. Every send passes through outboundFetch in src/kernel/outbound.ts. It resolves the host, refuses private answers and plain http, connects to the address it judged (name kept in Host and as the TLS server name), and follows no redirect.
The type check passes. The notify, webhooks, outbound, agents and overlap suites pass (92 tests), and I did not run the whole bun test. The forge's check runs everything on this push.
This push answers the second review:
- Dead code:
backOffno longer takes anerrorparameter, and the extra SQL bindings are gone. A refused delivery is failed directly indeliverOne, with its reason inlast_error. A delivery that later succeeds now clearslast_error, and a test covers it. - Notify:
- Notify is https-only. That was already true when the URL is saved (parseNotifyUrl returns not_https).
- The real sender, post, now returns false on a refusal or transport error instead of throwing. It is exported and tested with a plain-http URL and a private one.
- I did not add a plain pass-through for notify. The vetHost call in post is gone because outboundFetch resolves and judges the host itself.
- README: the long bullet is now one sentence plus three sub-bullets covering https only, a refusal failing at once, and retry with growing delay.
Earlier in this pull:
- Fail at once: a refused delivery (private address or plain http) is failed after one attempt instead of retried.
- Old http hooks: hooks saved as http before this change are refused at delivery with "refused: not https", so no migration is needed.
- Hooks page: it shows the refusal text, and a test fails if that cell reverts.
- TLS test: the test covers the pinned-IP connect and the certificate check against the name. It generates a certificate with
opensslat run time, so no key is committed, and skips ifopensslis missing.
The test-only options allow, plain and ca are arguments, never read from the environment.
To try it, save an https://127.0.0.1/... webhook, push to the repo, and open Settings → Webhooks. The delivery shows "refused: private address".
The guard does what the issue asked. It resolves the host, judges every answer, connects to the judged IP with the name kept in Host and as the TLS server name, and never follows a redirect. http is refused up front, and the refusal reaches the hooks page as a sentence rather than a number. The tests would fail without the change: the private ranges, a 302 not followed, the cert checked against the name, and the hooks-page text. The settings.ts change is one cell and stays inside the design system. I did not run the app or open the preview screenshots. Two things I would do next: (1) widen the refusal list in src/kernel/outbound.ts, which lacks 240.0.0.0/4 (and so 255.255.255.255), 198.18.0.0/15, 192.0.0.0/24 and the NAT64 and 6to4 IPv6 forms. (2) Move the literal check in src/crew/notify.ts (isPrivateHost at line 88) onto isRefusedAddress, so the forge has one definition of private, and show a refused delivery on the hooks page as a short failed label with the sentence beneath it, so it wraps cleanly on a 360px phone.
Webhooks now only save
https:URLs and only send to public addresses, through one new guard insrc/kernel/outbound.ts. I couldn't runbun x tsc --noEmithere because bun can't write its tempdir in this sandbox, so the type check is unverified. The tests I ran pass: outbound, webhooks, overlap, notify, schema-migrations, migrate, agents, web, actions and parity.What changed
vetHostandoutboundFetch:vetHost(host, {resolve, allow})resolves the name and refuses if any answer is loopback, 10/8, 172.16/12, 192.168/16, link-local (including 169.254.169.254), CGNAT, unspecified, multicast, IPv6 ULA/link-local, or an::ffff:-mapped form of these.outboundFetch(url, init, options)connects to the vetted IP, keeps the name inHostand as the TLS server name, and never follows redirects. When a host is refused it throws before connecting. I built the address ranges on the existingkernel/cidr.ts.createWebhookaccepts onlyhttps:URLs; anything else isinvalid_url, which the API and settings page already show as a 422.outboundFetch. A refused delivery gets last-error "refused: private address" and no status, and is retried with the usual backoff. This needed a new nullablewebhook_delivery.last_errorcolumn (migration20261020090000-webhook-refusal). The settings page shows that sentence in the status cell instead of a number.src/crew/notify.ts: the sender usesvetHost. It resolves once and pins that answer intooutboundFetch. Behaviour is the same: a private or unresolvable host returns false.Tests
test/outbound.test.tscovers every case in the issue with an injected resolver. The 302 to a private address is returned, not followed.test/webhooks.test.tsadds the http 422 and a refused delivery that records no status.test/helpers/loopback-hook.ts(a SQL insert pastcreateWebhook) and anallowargument todeliverDue. That argument is an option, not an environment flag.overlap.test.ts,overlap-worker.tsandagents.test.tsuse it too.Left out
isPrivateHostinmigrate.tsandparseNotifyUrlare untouched; they judge literals, not resolved names.To try it Run
bun test test/outbound.test.ts test/webhooks.test.ts. Then add anhttp://webhook through the API and expect a 422. Anhttps://127.0.0.1/...webhook is saved, but its deliveries show "refused: private address" on the settings page.🤖 Generated with Claude Code
Closes #2992