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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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

  • vetHost and outboundFetch: 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 in Host and 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 existing kernel/cidr.ts.
  • Saving a webhook: createWebhook accepts only https: URLs; anything else is invalid_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 nullable webhook_delivery.last_error column (migration 20261020090000-webhook-refusal). The settings page shows that sentence in the status cell instead of a number.
  • src/crew/notify.ts: the sender uses vetHost. It resolves once and pins that answer into outboundFetch. 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.ts covers every case in the issue with an injected resolver. The 302 to a private address is returned, not followed.
  • test/webhooks.test.ts adds 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 past createWebhook) and an allow argument to deliverDue. That argument is an option, not an environment flag. overlap.test.ts, overlap-worker.ts and agents.test.ts use it too.

Left out

  • isPrivateHost in migrate.ts and parseNotifyUrl are 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

Closes #2992

commented

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

  • vetHost and outboundFetch: 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 in Host and 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 existing kernel/cidr.ts.
  • Saving a webhook: createWebhook accepts only https: URLs; anything else is invalid_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 nullable webhook_delivery.last_error column (migration 20261020090000-webhook-refusal). The settings page shows that sentence in the status cell instead of a number.
  • src/crew/notify.ts: the sender uses vetHost. It resolves once and pins that answer into outboundFetch. 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.ts covers every case in the issue with an injected resolver. The 302 to a private address is returned, not followed.
  • test/webhooks.test.ts adds 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 past createWebhook) and an allow argument to deliverDue. That argument is an option, not an environment flag. overlap.test.ts, overlap-worker.ts and agents.test.ts use it too.

Left out

  • isPrivateHost in migrate.ts and parseNotifyUrl are 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

Changes requested — before the latest push

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:

  1. The https path through outboundFetch is untested. The pinned-IP connect with tls.serverName is 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.
  2. Run bun x tsc --noEmit and report the result. The as RequestInit cast hides whether tls typechecks.

Smaller points are in the line comments.

commented

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 serverName set succeeds.
  • The same server asked for as other.example.com fails, 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 error parameter on backOff is 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".

Changes requested — before the latest push

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:

  1. 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 plain handling it had before. Add a test either way.
  2. 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.

commented

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: backOff no longer takes an error parameter, and the extra SQL bindings are gone. A refused delivery is failed directly in deliverOne, with its reason in last_error. A delivery that later succeeds now clears last_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 openssl at run time, so no key is committed, and skips if openssl is 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".

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs