Skip to content

fix(webhooks): block private DNS targets#2200

Open
cleberrafael wants to merge 3 commits into
Cap-go:mainfrom
cleberrafael:codex/harden-webhook-url-validation
Open

fix(webhooks): block private DNS targets#2200
cleberrafael wants to merge 3 commits into
Cap-go:mainfrom
cleberrafael:codex/harden-webhook-url-validation

Conversation

@cleberrafael
Copy link
Copy Markdown

@cleberrafael cleberrafael commented May 11, 2026

Summary

  • add DNS-over-HTTPS resolution to webhook URL validation
  • reject webhook hosts that resolve to private or special-use addresses
  • apply the stricter validation when creating, updating, testing, retrying, dispatching, and delivering webhooks

Security context

The existing webhook guard blocked direct IP literals and localhost hostnames, but hostnames resolving to private network addresses were not checked. This hardens webhook delivery against SSRF-style private DNS targets while preserving the existing CAPGO_ALLOW_LOCAL_WEBHOOK_URLS local-development override.

Tests

  • npx vitest run tests/webhook-url-validation.test.ts
  • npx eslint "supabase/functions/_backend/utils/webhook.ts" "supabase/functions/_backend/public/webhooks/post.ts" "supabase/functions/_backend/public/webhooks/put.ts" "supabase/functions/_backend/public/webhooks/test.ts" "supabase/functions/_backend/public/webhooks/deliveries.ts" "supabase/functions/_backend/triggers/webhook_dispatcher.ts" "tests/webhook-url-validation.test.ts"

Summary by CodeRabbit

  • Bug Fixes

    • URL validation for webhooks and previews now includes DNS-based checks, blocking targets that resolve to private/unroutable IPs; webhook deliveries also avoid following redirects.
    • Website preview URL parsing and private-IP protections improved.
  • Tests

    • Added extensive tests covering sync/async URL validation, DNS failure modes, private-IP cases, and redirect behavior.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

📝 Walkthrough

Walkthrough

Webhook URL validation now performs async DNS A/AAAA resolution and private-IP checks. A new async validator is added and awaited across webhook create/update/test/retry handlers, the dispatcher, delivery path, and website preview; tests exercise DNS outcomes and redirect behavior.

Changes

Async DNS-Based Webhook URL Validation

Layer / File(s) Summary
DNS Validation Infrastructure
supabase/functions/_backend/utils/ip.ts
Adds DNS-over-HTTPS resolver, isIpLiteral, isPrivateIp, and resolveHostnameIps helpers for A/AAAA lookups and IP classification.
Async URL Validator Function and webhook utils
supabase/functions/_backend/utils/webhook.ts
Removes in-file IP detection, imports IP/DNS helpers, adds getWebhookUrlValidationErrorAsync which runs sync checks then resolves A/AAAA and blocks private/unresolvable hosts; delivery now calls this async validator.
Webhook Dispatcher
supabase/functions/_backend/triggers/webhook_dispatcher.ts
Imports and awaits async validator; validation error controls whether delivery is marked failed or queued.
Webhook Handlers (Create/Update/Test/Retry)
supabase/functions/_backend/public/webhooks/post.ts, put.ts, test.ts, deliveries.ts
Each handler imports and awaits async URL validator instead of synchronous validation before proceeding with webhook operations.
Website Preview reuse
supabase/functions/_backend/private/website_preview.ts
Replaces local IP/DNS logic with shared helpers and broadens candidate-name tokenization delimiters.
Async URL Validation Tests
tests/webhook-url-validation.test.ts
Vitest suite stubbing DNS responses validates direct IP rejection, private-IP resolution rejection, mixed answers behavior, fails-closed DNS, public-IP acceptance, and delivery redirect handling.

Sequence Diagram

sequenceDiagram
  participant Handler as webhook handler
  participant Validator as getWebhookUrlValidationErrorAsync
  participant DNS as resolveHostnameIps (DoH)
  participant Classifier as isPrivateIp
  participant Queue as queueWebhookDelivery

  Handler->>Validator: validate URL (await)
  Validator->>DNS: request A and AAAA records
  DNS-->>Validator: list of IPs
  Validator->>Classifier: check each IP
  Classifier-->>Validator: private/public verdict
  Validator-->>Handler: validation result (null or error)
  Handler->>Queue: queue delivery (when valid)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Cap-go/capgo#2199: Similar changes to webhook URL validation using async DNS-aware public-host checks.
  • Cap-go/capgo#2090: Prior edits touching webhook URL validation comments and boundaries; related to the validation area.

Poem

🐰 I hop through DNS fields, sniffing each host,

I skip the private burrows I fear the most.
I await the answers, check addresses twice,
Only public hooks get a carrot of spice.
Safe deliveries! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(webhooks): block private DNS targets' directly and concisely describes the main security hardening: rejecting webhook hosts that resolve to private IP addresses.
Description check ✅ Passed The PR description includes a clear summary of changes, security context, and test instructions, though it does not fully follow the repository's template structure (missing Screenshots and incomplete Checklist sections).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch codex/harden-webhook-url-validation

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codspeed-hq
Copy link
Copy Markdown
Contributor

codspeed-hq Bot commented May 11, 2026

Merging this PR will not alter performance

✅ 43 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing cleberrafael:codex/harden-webhook-url-validation (b767ba9) with main (1d10e6f)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b767ba9098

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +107 to +109
const response = await fetch(dnsUrl.toString(), {
headers: { Accept: 'application/dns-json' },
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Catch DNS lookup failures in webhook URL validation

Wrap the DNS-over-HTTPS fetch in resolveHostnameIps with error handling (and ideally return a validation error string) instead of letting rejections bubble. As written, any transient network/DNS failure to cloudflare-dns.com throws out of getWebhookUrlValidationErrorAsync, which turns webhook create/update/test/retry into 500s and can leave queued deliveries stuck in pending when dispatcher/delivery flows abort before updateDeliveryResult runs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
supabase/functions/_backend/utils/webhook.ts (1)

157-160: 💤 Low value

Resolve A and AAAA concurrently.

The two DoH lookups are awaited sequentially, doubling the latency on every validation call (and validation runs on webhook create/update/test, every dispatch, and every delivery). They're independent, so Promise.all halves the added latency.

♻️ Suggested change
-  const ips = [
-    ...await resolveHostnameIps(hostname, 'A'),
-    ...await resolveHostnameIps(hostname, 'AAAA'),
-  ]
+  const [aRecords, aaaaRecords] = await Promise.all([
+    resolveHostnameIps(hostname, 'A'),
+    resolveHostnameIps(hostname, 'AAAA'),
+  ])
+  const ips = [...aRecords, ...aaaaRecords]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/functions/_backend/utils/webhook.ts` around lines 157 - 160, The
current code builds ips by awaiting resolveHostnameIps(hostname, 'A') and then
resolveHostnameIps(hostname, 'AAAA') sequentially; change this to run both
DNS-over-HTTPS lookups concurrently with Promise.all (e.g., call both
resolveHostnameIps promises in an array and await Promise.all), then
flatten/concat the two results into the ips array so resolveHostnameIps and the
ips const remain the same but with parallel resolution to halve latency.
tests/webhook-url-validation.test.ts (1)

21-49: 💤 Low value

Consider adding a few high-value negative cases.

The three cases here exercise the happy paths well. Two cheap additions would meaningfully increase confidence in the new validator:

  1. DNS resolution failure → ensure 'Webhook URL host could not be resolved' is returned (mock fetch to throw or return !response.ok). This also locks in the fix for unhandled DNS fetch errors.
  2. Mixed A + AAAA where AAAA is private → ensure some(isPrivateIp) still rejects (catches regressions if a future change accidentally short-circuits on a public A record).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/webhook-url-validation.test.ts` around lines 21 - 49, Add two negative
tests for webhook URL validation: (1) simulate DNS/fetch failure for
getWebhookUrlValidationErrorAsync (mock fetch to throw or return a non-ok
response) and assert it resolves to 'Webhook URL host could not be resolved' to
cover unhandled DNS fetch errors; (2) add a test where mockDnsAnswers returns
mixed records (e.g., an A public address plus an AAAA private address) and
assert getWebhookUrlValidationErrorAsync still rejects with 'Webhook URL must
point to a public host' to ensure some(isPrivateIp) logic doesn't short-circuit
on the public A record; reference getWebhookUrlValidationErrorAsync,
getWebhookUrlValidationError and mockDnsAnswers when adding these cases.
supabase/functions/_backend/triggers/webhook_dispatcher.ts (1)

80-146: Dispatcher integration looks correct.

Per-webhook validation runs inside the Promise.all(map(...)) so multiple webhooks for the same event still validate in parallel; on failure the delivery row is marked failed with the validation message and the queue is bypassed cleanly. Note that the same hostname will now be DoH-resolved once here and again inside deliverWebhook — that's intentional defense-in-depth, but if you see DNS resolution become a hot path, a short-lived in-process cache (e.g., 30–60s LRU keyed by hostname) would dedupe both layers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/functions/_backend/triggers/webhook_dispatcher.ts` around lines 80 -
146, Per the review, validation and delivery both perform DoH resolution causing
duplicate lookups; add a small in-process LRU cache (30–60s TTL, keyed by
hostname) and consult it in the DNS resolution path used by
getWebhookUrlValidationErrorAsync and deliverWebhook so both functions check the
cache before performing DoH resolution, falling back to actual DoH and
populating the cache on miss; keep cache lifecycle short-lived and shared
module-scoped so the concurrent Promise.all in webhook_dispatcher.ts benefits
without changing the existing queueWebhookDelivery or delivery flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@supabase/functions/_backend/utils/webhook.ts`:
- Around line 147-168: Add an explicit inline comment in
getWebhookUrlValidationErrorAsync explaining the DNS-rebinding window between
resolving the hostname via resolveHostnameIps and the later network call in
deliverWebhook (i.e., fetch does its own DNS resolution), reference the
functions resolveHostnameIps, isPrivateIp and deliverWebhook, and state this
residual SSRF risk is not fully mitigated by this check; additionally, either
(1) move or repeat the same hostname/IP validation immediately inside
deliverWebhook just before its fetch call, or (2) note that outbound requests
should be enforced via an egress proxy/WAF that blocks private IPs — choose one
mitigation and document it in the code.
- Around line 68-85: The isPrivateIpv4 function needs to also treat IPv4
multicast/reserved/broadcast ranges as private; update the return expression in
isPrivateIpv4 (which uses octets and [a,b]) to additionally return true when the
first octet a is between 224 and 239 (multicast), when a is between 240 and 254
(reserved), and when the full address equals 255.255.255.255 (limited broadcast)
— you can check the exact broadcast by comparing octets to [255,255,255,255] or
join the parts, and keep these checks alongside the existing
RFC1918/loopback/link-local conditions.
- Around line 102-117: The resolveHostnameIps function lacks a timeout and error
handling around the fetch to DNS_LOOKUP_URL; wrap the fetch in a try/catch and
use an AbortController with a short timeout (e.g. few hundred ms) so stalled DoH
requests abort, and if any error/timeout occurs return an empty array (so
callers treat the host as unresolved). In practice, create an AbortController,
set a timer to call controller.abort(), pass controller.signal to
fetch(dnsUrl.toString(), { headers: {...}, signal }), await the response inside
try, fallback to returning [] on non-ok responses or any thrown error, and keep
the existing mapping/filtering logic (isIpLiteral) for successful responses.
- Around line 87-100: The IPv6 link-local check in isPrivateIpv6 is too narrow
(only matches 'fe80:'); replace the startsWith('fe80:') branch with a regex test
that matches the full fe80::/10 range (use the suggested /^fe[89ab][0-9a-f]?:/i)
so addresses from fe80:: through febf:: are detected as private; keep the
existing ::1/:: and fc/fd (ULA) checks, and ensure isPrivateIp continues to
dispatch to isPrivateIpv6 for colon-containing addresses.

---

Nitpick comments:
In `@supabase/functions/_backend/triggers/webhook_dispatcher.ts`:
- Around line 80-146: Per the review, validation and delivery both perform DoH
resolution causing duplicate lookups; add a small in-process LRU cache (30–60s
TTL, keyed by hostname) and consult it in the DNS resolution path used by
getWebhookUrlValidationErrorAsync and deliverWebhook so both functions check the
cache before performing DoH resolution, falling back to actual DoH and
populating the cache on miss; keep cache lifecycle short-lived and shared
module-scoped so the concurrent Promise.all in webhook_dispatcher.ts benefits
without changing the existing queueWebhookDelivery or delivery flow.

In `@supabase/functions/_backend/utils/webhook.ts`:
- Around line 157-160: The current code builds ips by awaiting
resolveHostnameIps(hostname, 'A') and then resolveHostnameIps(hostname, 'AAAA')
sequentially; change this to run both DNS-over-HTTPS lookups concurrently with
Promise.all (e.g., call both resolveHostnameIps promises in an array and await
Promise.all), then flatten/concat the two results into the ips array so
resolveHostnameIps and the ips const remain the same but with parallel
resolution to halve latency.

In `@tests/webhook-url-validation.test.ts`:
- Around line 21-49: Add two negative tests for webhook URL validation: (1)
simulate DNS/fetch failure for getWebhookUrlValidationErrorAsync (mock fetch to
throw or return a non-ok response) and assert it resolves to 'Webhook URL host
could not be resolved' to cover unhandled DNS fetch errors; (2) add a test where
mockDnsAnswers returns mixed records (e.g., an A public address plus an AAAA
private address) and assert getWebhookUrlValidationErrorAsync still rejects with
'Webhook URL must point to a public host' to ensure some(isPrivateIp) logic
doesn't short-circuit on the public A record; reference
getWebhookUrlValidationErrorAsync, getWebhookUrlValidationError and
mockDnsAnswers when adding these cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 397eafec-8e35-43c1-929b-c4db52d34026

📥 Commits

Reviewing files that changed from the base of the PR and between 1d10e6f and b767ba9.

📒 Files selected for processing (7)
  • supabase/functions/_backend/public/webhooks/deliveries.ts
  • supabase/functions/_backend/public/webhooks/post.ts
  • supabase/functions/_backend/public/webhooks/put.ts
  • supabase/functions/_backend/public/webhooks/test.ts
  • supabase/functions/_backend/triggers/webhook_dispatcher.ts
  • supabase/functions/_backend/utils/webhook.ts
  • tests/webhook-url-validation.test.ts

Comment thread supabase/functions/_backend/utils/webhook.ts Outdated
Comment thread supabase/functions/_backend/utils/webhook.ts Outdated
Comment thread supabase/functions/_backend/utils/webhook.ts Outdated
Comment thread supabase/functions/_backend/utils/webhook.ts
@michael-schvarcz
Copy link
Copy Markdown

Nice direction — closing the DNS-resolution gap in webhook validation is real protection. Few things I'd push back on before this merges, in roughly decreasing severity.

1. TOCTOU / DNS-rebinding bypass (highest impact)

getWebhookUrlValidationErrorAsync resolves the hostname and rejects if any IP is private, but deliverWebhook then does a separate fetch(url, ...) that resolves the same hostname again in the runtime's HTTP client. An attacker who controls the authoritative DNS for the webhook hostname can return a public IP on the first lookup (passes validation) and a private IP on the second (the actual delivery). This is the classic DNS rebinding pattern and survives this PR.

Two ways to close it:

  • After resolving in resolveHostnameIps, pick one of the returned public IPs and pass { resolved: { ... } }-style options to fetch (Cloudflare Workers and Deno both expose this), so the connection goes to the IP you actually validated.
  • Or, since the resolver returns the IPs anyway, build the request against the IP directly and set the Host header to the original hostname. Slightly fiddlier with TLS SNI but works.

PR #2106 (Harden website preview DNS IP validation) explicitly acknowledges the same residual risk in its body, so it's worth coordinating with that PR on a shared fix rather than punting it twice.

2. (a === 192 && b === 0) over-matches the IETF protocol block

This line is meant to block 192.0.0.0/24 (RFC 6890 § 2.2.5, IETF Protocol Assignments), but as written it matches a === 192 && b === 0 with no constraint on the third or fourth octet, i.e. all of 192.0.0.0/16. That accidentally covers TEST-NET-1 (192.0.2.0/24 — also listed below it as a separate clause, so this is double-covered) but also blocks 192.0.1.x, 192.0.3.x, 192.0.4.x etc., which are not reserved and could be legitimate webhook targets. Tighten to (a === 192 && b === 0 && octets[2] === 0) to match what the comment suggests.

3. Missing IPv4 special-use ranges

Beyond the /16 over-match, these RFC 6890 ranges are not covered:

  • 224.0.0.0/4 (multicast) — a >= 224 && a <= 239
  • 240.0.0.0/4 (class E / reserved, includes 255.255.255.255 limited broadcast) — a >= 240
  • Or, more conservatively and future-proof: a single a >= 224 clause.

PR #2106 takes the a >= 224 approach and consolidates into a shared utils/ip.ts — the natural follow-up is to have this PR import from there too, see #6.

4. IPv6 link-local: startsWith('fe80:') is too narrow

Link-local is fe80::/10, not fe80::/16. The top ten bits 1111111010 cover hextet values 0xfe80 through 0xfebf, so fe90::1, feab::1, etc. are also link-local and currently pass through. Two ways to fix:

// option a: prefix-only check for the four hextet values
;['fe80:', 'fe90:', 'fea0:', 'feb0:'].some(p => normalized.startsWith(p))
// option b (cleaner): parse the first hextet and mask
const firstHextet = parseInt(normalized.split(':')[0] || '0', 16)
if ((firstHextet & 0xFFC0) === 0xFE80) return true

Same issue, separately, for the deprecated site-local range fec0::/10 — currently uncovered and worth adding as defense-in-depth.

5. IPv4-mapped IPv6 in hex-pair form is rejected outright (usability)

This one's a usability gotcha, not a security bug, but it'll surprise someone.

if (normalized.startsWith('::ffff:'))
  return isPrivateIpv4(normalized.slice(7))

If the resolver returns the hex-pair encoding ::ffff:0808:0808 (= 8.8.8.8), slice(7) is 0808:0808. That string has no ., so isPrivateIpv4's octets.length !== 4 check trips and returns true (private). Result: a legitimate public IPv4-mapped IPv6 host is rejected as "must point to a public host." Fail-closed is the right side to err on, but if Cloudflare DoH ever returns this form for an AAAA query, it'll look like a confusing customer-facing bug. Either normalize the hex-pair form into dotted form before passing to isPrivateIpv4, or fall back to a structured IPv6 parse on the second branch.

6. Missing IPv6 ranges

  • ff00::/8 multicast
  • 64:ff9b::/96 NAT64 — this is the lever that lets an attacker embed a private IPv4 inside an IPv6 address and slip past the IPv6 check
  • 2001:db8::/32 documentation
  • 100::/64 discard-only

Again, PR #2106's utils/ip.ts already handles most of these. Strong case for sharing the implementation rather than keeping two divergent copies.

7. No timeout on the DNS-over-HTTPS lookup

const response = await fetch(dnsUrl.toString(), {
  headers: { Accept: 'application/dns-json' },
})

If cloudflare-dns.com is slow or hangs, every webhook validation hangs with it (you do two of these per validation — A and AAAA). Worth an AbortSignal.timeout(2_000) and a deliberate decision on failure mode — current behavior is fail-closed (returns [] → "host could not be resolved"), which is the safer side but does mean a Cloudflare DNS outage takes out webhook delivery entirely. At minimum, log it loudly when this happens so you don't get a silent outage.

8. Hardcoded resolver, no fallback

DNS_LOOKUP_URL = 'https://cloudflare-dns.com/dns-query' makes Capgo's webhook subsystem reliant on a single third party. Consider making it env-configurable so self-hosters can use Quad9 / Google / their own resolver, and consider falling through to a second resolver on the first one's failure. Operational rather than security, but it's a one-line change that buys real resilience.

9. Test coverage gaps

Current tests cover three happy paths. Worth adding:

  • DNS returns both a public and a private IP for the same host (CDN-style multi-record) — current code rejects, good, but no test pins it
  • AAAA returns an IPv4-mapped IPv6 in hex-pair form (see Allow set version by deviceId #5)
  • Link-local outside fe80:: prefix, e.g. fea0::1 (see allow channel creation from within the capgo app #4)
  • Non-200 response from the resolver
  • Empty Answer array
  • Multicast / class-E hits (whichever ranges land in #3)

Happy to spin any of these into a follow-up PR if useful. Thanks for the work on this.

@subhajitlucky
Copy link
Copy Markdown

Review note: this still allows an SSRF bypass through redirects.

getWebhookUrlValidationErrorAsync() validates the original webhook URL, but deliverWebhook() then calls fetch(url, ...) with the default redirect behavior. A webhook endpoint on a public host can return a 30x redirect to http://127.0.0.1/..., http://169.254.169.254/..., or another private-network host. The original URL passes DNS validation, then the runtime follows the redirect without validating the redirect target.

A safer shape is to set redirect: "manual" on webhook delivery fetches and either reject redirects entirely or resolve/validate each Location target before following it, with a strict redirect count. The retry/test delivery paths should use the same behavior so validation and delivery cannot diverge.

Copy link
Copy Markdown
Author

Thanks for the detailed reviews. I pushed a follow-up commit (b1de6c3) addressing the actionable items:

  • moved the DNS/IP checks into shared utils/ip.ts and reused it from webhook validation and website preview validation, which should reduce the Sonar duplication
  • tightened IPv4 special-use handling, including the 192.0.0.0/24 overmatch and 224.0.0.0/4+ reserved/broadcast space
  • expanded IPv6 blocking for fe80::/10, fec0::/10, fc00::/7, multicast, documentation/discard/NAT64 ranges, and IPv4-mapped hex-pair form
  • added timeout/error handling for DoH lookups, with fail-closed behavior
  • kept webhook delivery redirect: 'manual' and added a regression test for redirect-to-private SSRF
  • documented the remaining DNS-rebinding window inline since fully closing it needs connect-time IP pinning or egress enforcement

Verification run locally:

npx vitest run tests/webhook-url-validation.test.ts
npx eslint "supabase/functions/_backend/utils/ip.ts" "supabase/functions/_backend/utils/webhook.ts" "supabase/functions/_backend/private/website_preview.ts" "supabase/functions/_backend/public/webhooks/post.ts" "supabase/functions/_backend/public/webhooks/put.ts" "supabase/functions/_backend/public/webhooks/test.ts" "supabase/functions/_backend/public/webhooks/deliveries.ts" "supabase/functions/_backend/triggers/webhook_dispatcher.ts" "tests/webhook-url-validation.test.ts"

Comment thread supabase/functions/_backend/utils/ip.ts Fixed
Comment thread supabase/functions/_backend/utils/ip.ts Fixed
Comment thread supabase/functions/_backend/utils/ip.ts Fixed
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/webhook-url-validation.test.ts (1)

1-159: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Add test coverage for IPv6 special-use ranges with abbreviated forms, and use it.concurrent() for parallel execution.

The current webhook validation (ip.ts lines 85-89) has a real vulnerability: string prefix matching fails for IPv6 addresses with leading zeros. Addresses like 0064:ff9b::1234:5678 and 0100:: bypass the private IP checks because they don't match the string prefixes 64:ff9b: and 100::. This allows webhook URLs to resolve to reserved IPv6 ranges.

Add these test cases to expose and verify fixes for the vulnerability:

🧪 Suggested test cases
it.concurrent('blocks IPv6 discard-only prefix 100::/64 in abbreviated forms', async () => {
  mockDnsAnswers(['100::1', '0100::'])
  
  await expect(
    getWebhookUrlValidationErrorAsync(context, 'https://discard.example.com/webhook'),
  )
    .resolves
    .toBe('Webhook URL must point to a public host')
})

it.concurrent('blocks IPv6 NAT64 prefix 64:ff9b::/96 with leading zeros', async () => {
  mockDnsAnswers(['64:ff9b::1234:5678', '0064:ff9b::8888:8888'])
  
  await expect(
    getWebhookUrlValidationErrorAsync(context, 'https://nat64.example.com/webhook'),
  )
    .resolves
    .toBe('Webhook URL must point to a public host')
})

it.concurrent('blocks IPv6 documentation prefix 2001:db8::/32 with leading zeros', async () => {
  mockDnsAnswers(['2001:db8::1', '2001:0db8::'])
  
  await expect(
    getWebhookUrlValidationErrorAsync(context, 'https://docs.example.com/webhook'),
  )
    .resolves
    .toBe('Webhook URL must point to a public host')
})

it.concurrent('blocks IPv6 multicast addresses ff00::/8', async () => {
  mockDnsAnswers(['ff02::1', 'ff00::'])
  
  await expect(
    getWebhookUrlValidationErrorAsync(context, 'https://multicast.example.com/webhook'),
  )
    .resolves
    .toBe('Webhook URL must point to a public host')
})

Also update all existing tests to use it.concurrent() instead of it() per guidelines for parallel test execution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/webhook-url-validation.test.ts` around lines 1 - 159, Update the
webhook URL validation tests to cover IPv6 special-use ranges with
abbreviated/zero-padded forms and run tests in parallel: replace all occurrences
of it(...) with it.concurrent(...) in tests/webhook-url-validation.test.ts and
add the four new test cases that call getWebhookUrlValidationErrorAsync(context,
...) using mockDnsAnswers(...) for the following scenarios — discard-only prefix
(100::/64) with values like "100::1" and "0100::"; NAT64 prefix (64:ff9b::/96)
including "64:ff9b::1234:5678" and "0064:ff9b::8888:8888"; documentation prefix
(2001:db8::/32) with "2001:db8::1" and "2001:0db8::"; and multicast ff00::/8
with "ff02::1" and "ff00::"; ensure each new test asserts
getWebhookUrlValidationErrorAsync resolves to 'Webhook URL must point to a
public host'.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@supabase/functions/_backend/utils/ip.ts`:
- Around line 85-89: The current string-prefix checks against normalized
('100::' and '64:ff9b::') miss valid abbreviated IPv6 forms; replace them with
numeric prefix checks by parsing the IPv6 address into hextets (e.g., split into
an array of up to 8 16-bit words) and then test the prefix bits: for 100::/64
verify the first four hextets match [0x0100, 0x0000, 0x0000, 0x0000] (or
equivalently check first 64 bits), and for 64:ff9b::/96 verify the first six
hextets match [0x0064, 0xff9b, 0x0000, 0x0000, 0x0000, 0x0000] (or check first
96 bits); update the logic that currently checks normalized.startsWith('100::')
/ normalized.startsWith('64:ff9b:') to use this hextet/prefix comparison
(referencing the normalized variable and the IPv6 parsing helper you already use
or add one) so abbreviated forms are correctly detected.
- Line 89: The check using normalized.startsWith('2001:db8:') fails for
abbreviated forms like 2001:0db8::; update the same IPv6 prefix logic used for
the other reserved ranges to compare the first 32 bits (first four hextets) of
the normalized address instead of a raw startsWith string. In practice, in the
function where the variable normalized is tested (the same place that checks
other IPv6 prefixes), normalize each hextet by removing leading zeros or use the
existing normalization helper, then compare the first four hextets (or use a
regex that matches the 2001:db8::/32 pattern robustly) rather than relying on
normalized.startsWith('2001:db8:').

---

Outside diff comments:
In `@tests/webhook-url-validation.test.ts`:
- Around line 1-159: Update the webhook URL validation tests to cover IPv6
special-use ranges with abbreviated/zero-padded forms and run tests in parallel:
replace all occurrences of it(...) with it.concurrent(...) in
tests/webhook-url-validation.test.ts and add the four new test cases that call
getWebhookUrlValidationErrorAsync(context, ...) using mockDnsAnswers(...) for
the following scenarios — discard-only prefix (100::/64) with values like
"100::1" and "0100::"; NAT64 prefix (64:ff9b::/96) including
"64:ff9b::1234:5678" and "0064:ff9b::8888:8888"; documentation prefix
(2001:db8::/32) with "2001:db8::1" and "2001:0db8::"; and multicast ff00::/8
with "ff02::1" and "ff00::"; ensure each new test asserts
getWebhookUrlValidationErrorAsync resolves to 'Webhook URL must point to a
public host'.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c3ba0254-fa1a-4689-af99-7d9df40a8ae8

📥 Commits

Reviewing files that changed from the base of the PR and between b767ba9 and b1de6c3.

📒 Files selected for processing (4)
  • supabase/functions/_backend/private/website_preview.ts
  • supabase/functions/_backend/utils/ip.ts
  • supabase/functions/_backend/utils/webhook.ts
  • tests/webhook-url-validation.test.ts

Comment thread supabase/functions/_backend/utils/ip.ts Outdated
Comment thread supabase/functions/_backend/utils/ip.ts Outdated
@michael-schvarcz
Copy link
Copy Markdown

Thanks for the follow-up, this addresses most of the concerns I raised, especially the shared IP utilities, DoH timeout/error handling, redirect: 'manual', and broader IPv4/IPv6 special-use coverage.

The remaining CodeRabbit finding looks valid to me: the 100::/64, 64:ff9b::/96, and 2001:db8::/32 checks should avoid raw string-prefix matching because IPv6 allows equivalent zero-padded/abbreviated forms like 0100::, 0064:ff9b::..., and 2001:0db8::.

A small helper that expands IPv6 into 8 hextets and compares prefix bits would close that cleanly. After that and the merge conflict resolution, this looks much closer.

Copy link
Copy Markdown
Author

Pushed another follow-up commit (e44cd81) for the remaining IPv6 representation issue.

Changes:

  • replaced raw string-prefix checks for 100::/64, 64:ff9b::/96, and 2001:db8::/32 with expanded-hextet prefix comparisons
  • added regression coverage for zero-padded/equivalent forms like 0100::, 0064:ff9b::..., and 2001:0db8::
  • converted the webhook validation tests to it.concurrent() with hostname-routed fetch mocks so parallel execution does not race on global fetch

Verification run locally:

npx vitest run tests/webhook-url-validation.test.ts
npx eslint "supabase/functions/_backend/utils/ip.ts" "tests/webhook-url-validation.test.ts" "supabase/functions/_backend/utils/webhook.ts" "supabase/functions/_backend/private/website_preview.ts"
git diff --check

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/webhook-url-validation.test.ts (1)

6-19: ⚡ Quick win

Isolate shared DNS/delivery maps per test run

dnsAnswers and deliveryResponses are shared across all it.concurrent cases, so test state can bleed between cases as this file grows. Please clear both maps in a beforeEach to keep cases independent.

Suggested change
-import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@
 describe('webhook URL validation', () => {
+  beforeEach(() => {
+    dnsAnswers.clear()
+    deliveryResponses.clear()
+  })
+
   beforeAll(() => {

As per coding guidelines, tests in tests/**/*.test.ts should be designed for parallel execution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/webhook-url-validation.test.ts` around lines 6 - 19, The shared maps
dnsAnswers and deliveryResponses must be cleared before each test to avoid
cross-test state; add a beforeEach hook that calls dnsAnswers.clear() and
deliveryResponses.clear(), placing it near the top of the test file alongside
the existing mock helpers (mockDnsAnswers and mockDnsThenDelivery) so each
it.concurrent run starts with empty maps.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/webhook-url-validation.test.ts`:
- Around line 6-19: The shared maps dnsAnswers and deliveryResponses must be
cleared before each test to avoid cross-test state; add a beforeEach hook that
calls dnsAnswers.clear() and deliveryResponses.clear(), placing it near the top
of the test file alongside the existing mock helpers (mockDnsAnswers and
mockDnsThenDelivery) so each it.concurrent run starts with empty maps.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 301bdeec-76f8-40a2-bc5e-9982b95c0d24

📥 Commits

Reviewing files that changed from the base of the PR and between b1de6c3 and e44cd81.

📒 Files selected for processing (2)
  • supabase/functions/_backend/utils/ip.ts
  • tests/webhook-url-validation.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • supabase/functions/_backend/utils/ip.ts

@sonarqubecloud
Copy link
Copy Markdown

@digzrow-coder
Copy link
Copy Markdown

This still has a DNS TOCTOU gap. getWebhookUrlValidationErrorAsync() asks Cloudflare DoH for A/AAAA records and approves the hostname when those answers are public, but the actual fetch(url, ...) performs a separate resolution afterwards. A rebinding webhook host can return a public address to the validation lookup and then return 127.0.0.1 / RFC1918 / metadata IPs to the worker fetch, so the private-host block is not tied to the connection that is actually opened.

Since redirects are already manual, the remaining hard part is binding validation to delivery. I would either resolve and connect to the validated address while preserving the original Host/SNI semantics where the runtime supports it, or add an egress proxy/allowlist layer that enforces the resolved peer address at connect time. A regression can mock validation DNS as public while the delivery resolver points the same hostname at a private IP, and assert the delivery is blocked rather than posted.

@michael-schvarcz
Copy link
Copy Markdown

I pushed a follow-up branch with the remaining DNS TOCTOU fix and opened a PR into this PR's source branch:

Summary of the fix:

  • webhook delivery now validates DNS and then pins the outbound TLS connection to one of the validated public IPs
  • preserves the original hostname for SNI and the HTTP Host header
  • avoids the second runtime hostname resolution that allowed DNS rebinding between validation and delivery
  • keeps redirects manual/non-followed

Coverage added/updated:

  • DNS rebinding regression: validation DNS is public, delivery uses the validated IP, and runtime fetch() is not used for the POST
  • loopback, RFC1918, IPv4 link-local/metadata, reserved/multicast IPv4
  • IPv6 link-local, discard-only, NAT64, documentation, and multicast ranges
  • redirect responses remain unsuccessful and are not followed

Ran locally:

  • npx vitest run tests/webhook-url-validation.test.ts tests/webhook-delivery-security.unit.test.ts tests/webhook-delivery-redirect.unit.test.ts
  • npx eslint supabase/functions/_backend/utils/ip.ts supabase/functions/_backend/utils/webhook.ts tests/webhook-url-validation.test.ts tests/webhook-delivery-security.unit.test.ts tests/webhook-delivery-redirect.unit.test.ts
  • git diff --check

@riderx
Copy link
Copy Markdown
Member

riderx commented May 14, 2026

Fix conflict

@anansutiawan
Copy link
Copy Markdown

P2: IPv4-mapped IPv6 is only detected when the address string starts with ::ffff:. The new numeric IPv6 checks still miss equivalent expanded forms inside ::ffff:0:0/96, for example 0:0:0:0:0:ffff:7f00:1 for 127.0.0.1 and 0000:0000:0000:0000:0000:ffff:0a00:0001 for 10.0.0.1. In a local reproduction of the PR-head classifier, both returned false from isPrivateIp, so a hostname resolving only to such an AAAA answer can be classified as public by this guard. Please parse IPv6 hextets first and check hextets[0..5] == [0,0,0,0,0,0xffff], then pass the last 32 bits through the IPv4 classifier. I have local regression coverage for expanded mapped loopback/RFC1918 and a public mapped address like 0:0:0:0:0:ffff:0808:0808.

@albercr3
Copy link
Copy Markdown

P2: Partial DNS lookup failures can still pass validation when the other family returns a public address. resolveHostnameIps() collapses a non-OK response, timeout, or thrown lookup into [], and getWebhookUrlValidationErrorAsync() only fails closed when the combined ips array is empty. That means this sequence currently returns null: A lookup succeeds with 93.184.216.34, AAAA lookup times out or returns 503. For an SSRF guard, that leaves a validation gap because the failed family was never classified; a private AAAA answer hidden behind that transient failure would be ignored, and the later runtime fetch may still resolve/use it.

I would make lookup failure explicit instead of encoding it as an empty answer set, then reject if either A or AAAA lookup errors. Empty Answer with an OK DNS response can remain valid as "no records for this family", but resolver/network failure should probably produce Webhook URL host could not be resolved even when the other family returned public records. A regression test can mock A -> 93.184.216.34 and AAAA -> 503 and assert validation fails closed.

Copy link
Copy Markdown

@KCDaemon KCDaemon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked the current head (e44cd81).

This is much better than the earlier version, but I still see two SSRF validation gaps that should stay blocking before merge:

  1. IPv4-mapped IPv6 is only handled when the string starts with ::ffff:. Equivalent expanded forms inside ::ffff:0:0/96, such as 0:0:0:0:0:ffff:7f00:1 for loopback or 0000:0000:0000:0000:0000:ffff:0a00:0001 for RFC1918, are valid AAAA answers but do not hit that shortcut. Those should be parsed as IPv6 hextets first, detect the [0,0,0,0,0,0xffff] prefix, and then run the embedded last 32 bits through the IPv4 private-range classifier.

  2. resolveHostnameIps() still turns resolver/network failures into [], and getWebhookUrlValidationErrorAsync() only fails closed when the combined A+AAAA list is empty. So A can return a public address while AAAA times out/503s, and validation still passes without ever classifying the failed address family. For an SSRF guard, I would make resolver failure explicit and reject if either A or AAAA lookup fails; an empty successful answer can remain distinct from a failed lookup.

The PR also remains merge-conflicted (mergeStateStatus is DIRTY). I would keep this blocked until those edge cases and the conflict are fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants