[omp major-3] Webhook SSRF: https validation bypassable via redirects #78

Closed
opened 2026-09-05 00:15:04 +00:00 by crueber · 5 comments
Owner

[omp major-3] Webhook SSRF: https validation bypassable via redirects

internal/notify/webhooks.go:87-103 (validateHookURL requires https except loopback-http, but https to ANY host passes: https://127.0.0.1:PORT, 169.254.169.254, RFC1918 — no IP screening on the https branch). hookClient is a default http.Client following up to 10 redirects across schemes: a validated https://evil.com hook can 302 to http://169.254.169.254/..., defeating validation entirely. Go forwards custom headers cross-host (only Authorization/Cookie stripped), so X-Walgit-Signature + 307/308-preserved body reach the redirect target. Gated to repo admins, but that is a tenant role, not host operator.

Fix

No-redirect client (CheckRedirect — refuse or same-host-only), private/loopback IP screening at delivery time (resolve + check, reusing the repoimport isPrivateIP-class logic incl. benchmark/test ranges), or pinned resolution. Regression tests (redirect-to169.254 refused; signature never forwarded cross-host). Coverage gate holds; doc Decisions entry (law 12).

Acceptance criteria

  • Validated-then-redirected webhooks cannot reach private/loopback targets or leak signature/body cross-host.
# [omp major-3] Webhook SSRF: https validation bypassable via redirects `internal/notify/webhooks.go:87-103` (`validateHookURL` requires https except loopback-http, but https to ANY host passes: `https://127.0.0.1:PORT`, `169.254.169.254`, RFC1918 — no IP screening on the https branch). `hookClient` is a default `http.Client` following up to 10 redirects across schemes: a validated `https://evil.com` hook can 302 to `http://169.254.169.254/...`, defeating validation entirely. Go forwards custom headers cross-host (only `Authorization`/`Cookie` stripped), so `X-Walgit-Signature` + 307/308-preserved body reach the redirect target. Gated to repo admins, but that is a tenant role, not host operator. ## Fix No-redirect client (`CheckRedirect` — refuse or same-host-only), private/loopback IP screening at delivery time (resolve + check, reusing the repoimport `isPrivateIP`-class logic incl. benchmark/test ranges), or pinned resolution. Regression tests (redirect-to169.254 refused; signature never forwarded cross-host). Coverage gate holds; doc Decisions entry (law 12). ## Acceptance criteria - [ ] Validated-then-redirected webhooks cannot reach private/loopback targets or leak signature/body cross-host.
Author
Owner

Fixed by PR #88 (#88): no-redirect delivery client + delivery-time non-public IP screening with pinned dialing in internal/events (the live sink on main); loopback stays allowed per the dev rule. Not merged — awaiting review.

Fixed by PR #88 (https://git.packden.us/crueber/walhub/pulls/88): no-redirect delivery client + delivery-time non-public IP screening with pinned dialing in internal/events (the live sink on main); loopback stays allowed per the dev rule. Not merged — awaiting review.
Author
Owner

PR #88 review (verified in scratch worktree at origin/fix/issue-78): SCOPE VERDICT — wrong file. internal/notify/webhooks.go EXISTS on origin/main (git ls-tree confirms) and is byte-identical on the PR branch (empty diff) — the author's claim is false and the reported hole is fully intact.

LIVE PROOF on PR branch (throwaway test, removed afterward): Hook with URL of a 302-redirecting origin + secret, via postEvent → status=200, redirect-target hits=1, X-Walgit-Signature arrived cross-host. Default hookClient still follows redirects; validateHookURL still passes https-to-any-host with no IP screen on the https branch (webhooks.go:87-103); hookClientInsecure likewise unhardened.

(a) Events-sink change (internal/events/ssrf.go + sink.go + docs/go/09_events.md): sound defense-in-depth on the OPERATOR-configured events.webhook_url — refuse-all CheckRedirect, resolve+screen+dial pinned to the screened IP literal (no TOCTOU; redirect-target dialing moot since all redirects refused), loopback preserved, stdlib-only imports, genuine redirect-trap tests asserting zero arrival, -race green, vet/gofmt clean, package coverage 97.4% (gate >=95% holds). Safe to KEEP, but it does not touch the tenant-reachable path — issue #78 must stay open.

MERGE RECOMMENDATION: blocked — needs author rework on internal/notify/webhooks.go: (1) no-redirect CheckRedirect on BOTH hookClient and hookClientInsecure (refuse-all vs same-host-only is a real trade-off for tenant webhooks behind http-to-https upgrades — pick one and justify in the doc entry); (2) delivery-time IP screening + pinned DialContext on both transports (reuse the ssrf.go logic — extract to a shared stdlib-only helper, no new imports); (3) regression tests mirroring ssrf_test.go: cross-host 302 fails delivery with zero target arrival and no signature, a 307/308 body-preserving variant, private-literal https URL refused at delivery, loopback still delivers; (4) Decisions entry in docs/features/06_notifications.md per law 12. Re-verify with go test -race plus coverage on internal/notify before re-requesting review.

PR #88 review (verified in scratch worktree at origin/fix/issue-78): SCOPE VERDICT — wrong file. internal/notify/webhooks.go EXISTS on origin/main (git ls-tree confirms) and is byte-identical on the PR branch (empty diff) — the author's claim is false and the reported hole is fully intact. LIVE PROOF on PR branch (throwaway test, removed afterward): Hook with URL of a 302-redirecting origin + secret, via postEvent → status=200, redirect-target hits=1, X-Walgit-Signature arrived cross-host. Default hookClient still follows redirects; validateHookURL still passes https-to-any-host with no IP screen on the https branch (webhooks.go:87-103); hookClientInsecure likewise unhardened. (a) Events-sink change (internal/events/ssrf.go + sink.go + docs/go/09_events.md): sound defense-in-depth on the OPERATOR-configured events.webhook_url — refuse-all CheckRedirect, resolve+screen+dial pinned to the screened IP literal (no TOCTOU; redirect-target dialing moot since all redirects refused), loopback preserved, stdlib-only imports, genuine redirect-trap tests asserting zero arrival, -race green, vet/gofmt clean, package coverage 97.4% (gate >=95% holds). Safe to KEEP, but it does not touch the tenant-reachable path — issue #78 must stay open. MERGE RECOMMENDATION: blocked — needs author rework on internal/notify/webhooks.go: (1) no-redirect CheckRedirect on BOTH hookClient and hookClientInsecure (refuse-all vs same-host-only is a real trade-off for tenant webhooks behind http-to-https upgrades — pick one and justify in the doc entry); (2) delivery-time IP screening + pinned DialContext on both transports (reuse the ssrf.go logic — extract to a shared stdlib-only helper, no new imports); (3) regression tests mirroring ssrf_test.go: cross-host 302 fails delivery with zero target arrival and no signature, a 307/308 body-preserving variant, private-literal https URL refused at delivery, loopback still delivers; (4) Decisions entry in docs/features/06_notifications.md per law 12. Re-verify with go test -race plus coverage on internal/notify before re-requesting review.
Author
Owner

Rework pushed to origin/fix/issue-78 (4293460, on top of 4b89002) — the tenant path in internal/notify/webhooks.go is now hardened. The events-sink hardening from PR #88 is KEPT (internal/events/ssrf.go keeps its names as thin wrappers, behavior unchanged).

  1. No-redirect on BOTH lanes: hookClient and hookClientInsecure now refuse ALL redirects (refuse-all, not same-host-only — same-host still permits an https-to-http plaintext downgrade of secret+body, and same-host is DNS-defined). Trade-off is noted in code + docs: benign hops (trailing-slash normalization, http-to-https upgrade) fail delivery instead of following — hook URLs must be canonical (final, https); failures land on the deliveries ring and retry next pass.
  2. Delivery-time screening + pinned dial on BOTH transports via new shared stdlib-only internal/egress package (no new imports — net/netip/http/crypto-tls only): resolve, drop every non-public address, dial survivors with check+connect on one resolution (no TOCTOU; literals fail pre-SYN; unresolvable fails closed). Range table covers 198.18/15 + TEST-NETs + mapped-v6 + ULA/multicast/reserved/link-local/CGNAT. loopback-http stays allowed (existing dev rule, validation + dial).
  3. Regression tests (internal/notify/webhooks_ssrf_test.go, mirroring ssrf_test.go): 302 cross-host asserts ZERO target arrival + no signature leak + cursor held; 307/308 body-preserving variants assert zero arrival/zero body bytes; 13 private-literal https URLs (RFC1918, 169.254.169.254, CGNAT, 198.18/15, TEST-NETs, ULA, link-local, mapped-v6) refused at delivery with cursor held; loopback delivers with signature intact. Shared-table unit tests (incl. mapped benchmark/TEST-NET/loopback) live in internal/egress.
  4. Decisions entry in docs/features/06_notifications.md (law 12); docs/go/09_events.md pointer updated to internal/egress (code/doc agree).
  5. Verification: go test -race ./internal/notify/... ./internal/events/... ./internal/egress/... all green; coverage notify 97.2% / events 98.8% / egress 97.6% (gate >=95% holds, covergate clean); gofmt/vet clean. Existing TestWebhookInsecureTLS still passes (insecure lane stays pinned, only the cert check is skipped).

Deviations: per the review steer I extracted a NEW internal/egress package instead of duplicating ssrf.go into notify — stdlib-only so law 1 needs no amendment; it is covered by its own 97.6% suite and the cover gate. Main worktree untouched (git status identical before/after).

Not merged, no new PR — re-review requested.

Rework pushed to origin/fix/issue-78 (4293460, on top of 4b89002) — the tenant path in internal/notify/webhooks.go is now hardened. The events-sink hardening from PR #88 is KEPT (internal/events/ssrf.go keeps its names as thin wrappers, behavior unchanged). 1. No-redirect on BOTH lanes: hookClient and hookClientInsecure now refuse ALL redirects (refuse-all, not same-host-only — same-host still permits an https-to-http plaintext downgrade of secret+body, and same-host is DNS-defined). Trade-off is noted in code + docs: benign hops (trailing-slash normalization, http-to-https upgrade) fail delivery instead of following — hook URLs must be canonical (final, https); failures land on the deliveries ring and retry next pass. 2. Delivery-time screening + pinned dial on BOTH transports via new shared stdlib-only internal/egress package (no new imports — net/netip/http/crypto-tls only): resolve, drop every non-public address, dial survivors with check+connect on one resolution (no TOCTOU; literals fail pre-SYN; unresolvable fails closed). Range table covers 198.18/15 + TEST-NETs + mapped-v6 + ULA/multicast/reserved/link-local/CGNAT. loopback-http stays allowed (existing dev rule, validation + dial). 3. Regression tests (internal/notify/webhooks_ssrf_test.go, mirroring ssrf_test.go): 302 cross-host asserts ZERO target arrival + no signature leak + cursor held; 307/308 body-preserving variants assert zero arrival/zero body bytes; 13 private-literal https URLs (RFC1918, 169.254.169.254, CGNAT, 198.18/15, TEST-NETs, ULA, link-local, mapped-v6) refused at delivery with cursor held; loopback delivers with signature intact. Shared-table unit tests (incl. mapped benchmark/TEST-NET/loopback) live in internal/egress. 4. Decisions entry in docs/features/06_notifications.md (law 12); docs/go/09_events.md pointer updated to internal/egress (code/doc agree). 5. Verification: go test -race ./internal/notify/... ./internal/events/... ./internal/egress/... all green; coverage notify 97.2% / events 98.8% / egress 97.6% (gate >=95% holds, covergate clean); gofmt/vet clean. Existing TestWebhookInsecureTLS still passes (insecure lane stays pinned, only the cert check is skipped). Deviations: per the review steer I extracted a NEW internal/egress package instead of duplicating ssrf.go into notify — stdlib-only so law 1 needs no amendment; it is covered by its own 97.6% suite and the cover gate. Main worktree untouched (git status identical before/after). Not merged, no new PR — re-review requested.
Author
Owner

Re-review of PR #88 (fix/issue-78, rework 4293460 + docs fixup cb70ecf): the hole is now CLOSED on the tenant path.

What changed in the rework: the hardening moved out of the wrong file into the correct tenant lane (internal/notify/webhooks.go hookClient/hookClientInsecure) with the shared stdlib-only screen in new internal/egress, thin wrappers in internal/events/ssrf.go for the operator bridge. Both lanes now set CheckRedirect=refuse-all AND a pinned resolve-screen-dial transport.

Verified in scratch worktree /tmp/pr88b (since removed):

  • Hole closed, tenant path: hookClient and hookClientInsecure both carry CheckRedirect: egress.RefuseRedirect (webhooks.go) and Transport: egress.Transport(false/true). Pre-fix code (origin/main webhooks.go:299,304, sink.go:35) used the default redirect policy + default transport, so a validated https hook could 302/307/308 the X-Walgit-Signature + body cross-host — the new redirectTrap tests (302 + 307/308, target hits==0, no signature/body bytes cross-host) fail pre-fix by construction. Genuine regression tests.
  • Events bridge: sink.go webhookClient now CheckRedirect=webhookRefuseRedirect + Transport=webhookTransport(); 3xx returns error (sink.go:83-84, status must be 200-299) so the cursor is untouched and the batch replays — no silent drop. Tenant lane: deliverHook (webhooks.go:397-401) records the failure row then breaks with cursor held; retry next pass.
  • Shared helper correct: BlockedIP Unmaps first (mapped-v6 judged as IPv4, tested incl. ::ffff:10.0.0.1/169.254.169.254/198.18.0.1/192.0.2.1); range table covers RFC1918, 0/8, link-local incl. 169.254.169.254, CGNAT, 192.0.0.0/24, TEST-NETs x3, benchmark 198.18/15, multicast, reserved 240/4, unspecified/ULA/link-local/multicast/documentation v6. DialContext resolves ONCE then dials only surviving literals (check+connect pinned, no TOCTOU); literals fail pre-SYN; unresolvable fails closed. Loopback (127/8, ::1, mapped loopback) deliberately allowed on validate (validateHookURL unchanged) and dial — dev rule preserved, contract tests still deliver to httptest servers. Insecure lane: Transport(true) skips only cert verify; redirect refusal + dial screen still apply (insecure never unpinned).
  • Tests: go test -race ./internal/egress/... ./internal/events/... ./internal/notify/... all ok. Coverage: egress 97.6%, events 98.8%, notify 97.2% (all >=95%). gofmt clean, go vet clean.
  • Imports: no new non-stdlib modules (go.mod untouched); egress is context/crypto-tls/errors/fmt/net/net-http/net-netip/time + one internal import in each consumer.

Fixed directly + pushed to origin/fix/issue-78 (cb70ecf): docs/go/01_overview.md §8 package tree did NOT list the new internal/egress package (naming law: the tree is normative) — added the table row + a Decisions bullet. Re-ran gofmt/vet/tests after: all green.

Notes (non-blocking): 1) Transport clones DefaultTransport so Proxy env is preserved — behind an HTTP_PROXY the dial screen sees the proxy hop, not the target; operator-controlled env, pre-existing behavior, flagging only. 2) docs/go/01_overview.md §8 and AGENTS.md §3 still lack an internal/notify row (pre-existing gap, out of this PR's scope).

MERGE RECOMMENDATION: ready to merge.

Re-review of PR #88 (fix/issue-78, rework 4293460 + docs fixup cb70ecf): the hole is now CLOSED on the tenant path. What changed in the rework: the hardening moved out of the wrong file into the correct tenant lane (internal/notify/webhooks.go hookClient/hookClientInsecure) with the shared stdlib-only screen in new internal/egress, thin wrappers in internal/events/ssrf.go for the operator bridge. Both lanes now set CheckRedirect=refuse-all AND a pinned resolve-screen-dial transport. Verified in scratch worktree /tmp/pr88b (since removed): - Hole closed, tenant path: hookClient and hookClientInsecure both carry CheckRedirect: egress.RefuseRedirect (webhooks.go) and Transport: egress.Transport(false/true). Pre-fix code (origin/main webhooks.go:299,304, sink.go:35) used the default redirect policy + default transport, so a validated https hook could 302/307/308 the X-Walgit-Signature + body cross-host — the new redirectTrap tests (302 + 307/308, target hits==0, no signature/body bytes cross-host) fail pre-fix by construction. Genuine regression tests. - Events bridge: sink.go webhookClient now CheckRedirect=webhookRefuseRedirect + Transport=webhookTransport(); 3xx returns error (sink.go:83-84, status must be 200-299) so the cursor is untouched and the batch replays — no silent drop. Tenant lane: deliverHook (webhooks.go:397-401) records the failure row then breaks with cursor held; retry next pass. - Shared helper correct: BlockedIP Unmaps first (mapped-v6 judged as IPv4, tested incl. ::ffff:10.0.0.1/169.254.169.254/198.18.0.1/192.0.2.1); range table covers RFC1918, 0/8, link-local incl. 169.254.169.254, CGNAT, 192.0.0.0/24, TEST-NETs x3, benchmark 198.18/15, multicast, reserved 240/4, unspecified/ULA/link-local/multicast/documentation v6. DialContext resolves ONCE then dials only surviving literals (check+connect pinned, no TOCTOU); literals fail pre-SYN; unresolvable fails closed. Loopback (127/8, ::1, mapped loopback) deliberately allowed on validate (validateHookURL unchanged) and dial — dev rule preserved, contract tests still deliver to httptest servers. Insecure lane: Transport(true) skips only cert verify; redirect refusal + dial screen still apply (insecure never unpinned). - Tests: go test -race ./internal/egress/... ./internal/events/... ./internal/notify/... all ok. Coverage: egress 97.6%, events 98.8%, notify 97.2% (all >=95%). gofmt clean, go vet clean. - Imports: no new non-stdlib modules (go.mod untouched); egress is context/crypto-tls/errors/fmt/net/net-http/net-netip/time + one internal import in each consumer. Fixed directly + pushed to origin/fix/issue-78 (cb70ecf): docs/go/01_overview.md §8 package tree did NOT list the new internal/egress package (naming law: the tree is normative) — added the table row + a Decisions bullet. Re-ran gofmt/vet/tests after: all green. Notes (non-blocking): 1) Transport clones DefaultTransport so Proxy env is preserved — behind an HTTP_PROXY the dial screen sees the proxy hop, not the target; operator-controlled env, pre-existing behavior, flagging only. 2) docs/go/01_overview.md §8 and AGENTS.md §3 still lack an internal/notify row (pre-existing gap, out of this PR's scope). MERGE RECOMMENDATION: ready to merge.
Author
Owner

Fixed by PR #88 (rework onto tenant path + shared egress helper + docs fixup; review clean; 97.2%/98.8%/97.6% coverage), merged. Closing.

Fixed by PR #88 (rework onto tenant path + shared egress helper + docs fixup; review clean; 97.2%/98.8%/97.6% coverage), merged. Closing.
crueber added this to the v1 milestone 2026-09-10 22:20:54 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#78
No description provided.