Skip to content

fix(webhooks): validate webhook URLs on delivery#1571

Merged
riderx merged 2 commits into
mainfrom
riderx/webhook-ssrf-fix
Feb 4, 2026
Merged

fix(webhooks): validate webhook URLs on delivery#1571
riderx merged 2 commits into
mainfrom
riderx/webhook-ssrf-fix

Conversation

@riderx
Copy link
Copy Markdown
Member

@riderx riderx commented Feb 4, 2026

Summary (AI generated)

  • Block webhook deliveries for invalid or private URLs
  • Centralize webhook URL validation for create/update/test/retry/dispatcher

Motivation (AI generated)

  • Reduce SSRF risk in webhook dispatch

Business Impact (AI generated)

  • Safer webhook execution with clearer error handling

Test plan (AI generated)

  • bun run lint:backend

Screenshots (AI generated)

  • N/A

Checklist (AI generated)

  • My code follows the code style of this project and passes bun run lint:backend && bun run lint
  • My change requires a change to the documentation
  • I have updated the documentation accordingly
  • My change has adequate E2E test coverage
  • I have tested my code manually, and I have provided steps how to reproduce my tests

Summary by CodeRabbit

  • Bug Fixes
    • Improved webhook URL validation across all webhook operations to be consistent and more reliable.
    • Invalid webhook URLs are now caught and rejected earlier with clearer error messages.
    • Enhanced validation enforces proper HTTPS URLs and hostname formats, with support for localhost in development environments.

Copilot AI review requested due to automatic review settings February 4, 2026 00:32
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 4, 2026

Warning

Rate limit exceeded

@riderx has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 20 minutes and 0 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

This PR introduces centralized URL validation for webhooks by creating a getWebhookUrlValidationError utility function that validates webhook URLs across multiple handler operations. The function allows localhost only in local environments and enforces HTTPS for production URLs. The validation is integrated into webhook creation, update, test, retry, and dispatch operations.

Changes

Cohort / File(s) Summary
Centralized URL Validation Utility
supabase/functions/_backend/utils/webhook.ts
Added getWebhookUrlValidationError() function with environment-aware validation logic (localhost allowed locally, HTTPS enforced in production). Integrated early validation in deliverWebhook() and updated error response handling.
Webhook Handlers
supabase/functions/_backend/public/webhooks/post.ts, put.ts, test.ts, deliveries.ts
Replaced inline URL validation with centralized getWebhookUrlValidationError() utility calls. Each handler now imports and invokes the validator before processing, throwing standardized invalid_url errors on validation failure.
Webhook Dispatcher
supabase/functions/_backend/triggers/webhook_dispatcher.ts
Added runtime URL validation during webhook dispatch. Invalid URLs now fail delivery immediately with logged error and updated delivery result, preventing further processing.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

Poem

🐰 A hop and a check before we send,
URLs validated from start to end,
HTTPS enforced with mighty care,
Localhost welcome if dev's somewhere,
Invalid ones won't pass the gate,
Validating webhooks, we celebrate! 🔗

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Description check ❓ Inconclusive The PR description includes a summary, motivation, and test plan. However, the Test plan only references linting, and all checklist items remain unchecked, suggesting incomplete due diligence on manual testing and documentation updates. Clarify the test plan with concrete steps beyond linting; confirm whether manual testing was performed and documentation updates are needed for the new validation behavior.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(webhooks): validate webhook URLs on delivery' is clear, concise, and accurately reflects the main change: adding URL validation to webhook delivery flows.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch riderx/webhook-ssrf-fix

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.

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: daf8804786

ℹ️ 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 +84 to +85
if (isIpLiteral(hostname))
return 'Webhook URL must use a hostname, not an IP address'
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid breaking existing webhook URLs that use IPs

This new validation hard-rejects any IP-literal hostname (e.g., https://203.0.113.10/webhook), but those URLs were previously accepted as long as they were HTTPS. Because the same check now runs on create/update/test/retry/dispatcher, existing customers who configured webhooks with a public IP will see deliveries fail and be unable to retry without changing their URL. That is a backward‑compatibility regression for a public API, and the change is not version‑gated or flagged.

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: 1

🤖 Fix all issues with AI agents
In `@supabase/functions/_backend/utils/webhook.ts`:
- Line 5: The webhook URL hostname validation currently only checks literal IPs
and "localhost" strings; update the validation logic (the function that
validates webhook endpoints in this file — e.g., the webhook URL validation
helper around lines ~48-90) to resolve hostnames and reject any addresses in
private/loopback/link-local/reserved ranges for both IPv4 and IPv6: parse the
URL to get the hostname, if it's an IP validate against
RFC1918/loopback/link-local CIDRs, otherwise perform a DNS resolution
(dns.promises.lookup with {all:true}) and check every returned address against
the same private/reserved CIDRs; use a reliable IP/CIDR library (e.g., ipaddr.js
or a small utility using net.isIP + CIDR checks) to perform the range checks and
fail validation if any resolved address is private, or alternatively enforce a
strict hostname allowlist. Ensure the check runs wherever the current
hostname-only checks live (the existing literal IP/localhost branch) so SSRF via
hostnames is blocked.

import { cloudlog, cloudlogErr, serializeError } from './logging.ts'
import { closeClient, getPgClient } from './pg.ts'
import { supabaseAdmin } from './supabase.ts'
import { getEnv } from './utils.ts'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Block hostnames that resolve to private/loopback ranges.

Current checks only reject literal IPs and localhost/.localhost, so hostnames that resolve to private RFC1918/loopback/link‑local ranges (or common local suffixes like .local, localhost.localdomain) can still pass and enable SSRF. Consider resolving the hostname and rejecting private/reserved ranges (IPv4 + IPv6), or adopting a strict allowlist.

Also applies to: 48-90

🤖 Prompt for AI Agents
In `@supabase/functions/_backend/utils/webhook.ts` at line 5, The webhook URL
hostname validation currently only checks literal IPs and "localhost" strings;
update the validation logic (the function that validates webhook endpoints in
this file — e.g., the webhook URL validation helper around lines ~48-90) to
resolve hostnames and reject any addresses in
private/loopback/link-local/reserved ranges for both IPv4 and IPv6: parse the
URL to get the hostname, if it's an IP validate against
RFC1918/loopback/link-local CIDRs, otherwise perform a DNS resolution
(dns.promises.lookup with {all:true}) and check every returned address against
the same private/reserved CIDRs; use a reliable IP/CIDR library (e.g., ipaddr.js
or a small utility using net.isIP + CIDR checks) to perform the range checks and
fail validation if any resolved address is private, or alternatively enforce a
strict hostname allowlist. Ensure the check runs wherever the current
hostname-only checks live (the existing literal IP/localhost branch) so SSRF via
hostnames is blocked.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR centralizes webhook URL validation and applies it across webhook creation/update, test/retry, dispatcher queuing, and delivery-time execution to reduce SSRF risk and improve consistency.

Changes:

  • Added a shared getWebhookUrlValidationError() helper and used it across webhook endpoints and trigger handlers.
  • Blocked deliveries early (dispatcher + delivery) when URL validation fails, and persisted failure results to webhook_deliveries.
  • Adjusted stored delivery failure messaging to be more generic.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
supabase/functions/_backend/utils/webhook.ts Introduces centralized URL validation and enforces it at delivery-time; adjusts delivery failure response body.
supabase/functions/_backend/triggers/webhook_dispatcher.ts Validates webhook URLs before queueing deliveries and marks invalid deliveries as failed immediately.
supabase/functions/_backend/public/webhooks/test.ts Validates webhook URL before attempting a test delivery.
supabase/functions/_backend/public/webhooks/put.ts Replaces inline URL validation with the shared validator for webhook updates.
supabase/functions/_backend/public/webhooks/post.ts Replaces inline URL validation with the shared validator for webhook creation.
supabase/functions/_backend/public/webhooks/deliveries.ts Validates webhook URL before allowing a retry to be queued.

Comment on lines +77 to +79
if (isLocalWebhookEnv(c))
return null

Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

getWebhookUrlValidationError fully bypasses all validation when SUPABASE_URL contains localhost/127.0.0.1. In local/dev/test environments this will allow non-HTTPS public URLs (e.g. http://example.com/...), which breaks existing API expectations/tests (see tests/webhooks.test.ts cases that require HTTP non-local URLs to be rejected) and weakens SSRF protections during development.

Instead of returning null for every URL in local env, keep enforcing the general rules and only relax them for explicitly local targets (e.g. allow http://localhost / loopback only), while still rejecting non-HTTPS for non-local hosts.

Copilot uses AI. Check for mistakes.
Comment on lines +216 to +220
const urlValidationError = getWebhookUrlValidationError(c, url)
if (urlValidationError) {
const duration = Date.now() - startTime
cloudlogErr({
requestId: c.get('requestId'),
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

The new URL validation blocks disallowed initial URLs, but fetch() will still follow redirects by default. A webhook URL can pass this validation and then redirect to a disallowed target (IP literal / localhost / non-HTTPS), effectively bypassing the protection and reintroducing SSRF risk.

To make this validation effective, consider disabling automatic redirects (redirect: 'manual') and failing on 3xx, or manually following redirects while re-validating each Location (with a hop limit).

Copilot uses AI. Check for mistakes.
Comment on lines 295 to 298
return {
success: false,
body: `Error: ${errorMessage}`,
body: 'Error: Webhook delivery failed',
duration,
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

This changes the stored/displayed delivery error from the actual exception message to a fully generic string ('Error: Webhook delivery failed'). Since response_body is surfaced in the UI (e.g. src/components/WebhookDeliveryLog.vue), this removes most actionable diagnostics for users and makes it hard to distinguish timeouts vs DNS vs TLS failures.

Consider returning/storing a safe, categorized error (e.g. timeout vs network vs non-2xx) or a sanitized subset of the original message, while keeping detailed errors only in logs if needed.

Copilot uses AI. Check for mistakes.
@riderx riderx force-pushed the riderx/webhook-ssrf-fix branch from 415a3cd to 63dc913 Compare February 4, 2026 03:21
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Feb 4, 2026

@riderx riderx merged commit 784c9cc into main Feb 4, 2026
11 checks passed
@riderx riderx deleted the riderx/webhook-ssrf-fix branch February 4, 2026 04:12
@riderx
Copy link
Copy Markdown
Member Author

riderx commented Feb 4, 2026

/tip @Judel777 $150 thanks for the report

@algora-pbc
Copy link
Copy Markdown

algora-pbc Bot commented Feb 4, 2026

🎉🎈 @Judel777 has been awarded $150 by Capgo! 🎈🎊

@Judel777
Copy link
Copy Markdown
Contributor

Judel777 commented Feb 4, 2026

I’ve re-tested this on production.

PUT /webhooks now correctly rejects:

  • https://localhost/ → 400 invalid_url (“must point to a public host”)
  • https://127.0.0.1/ → 400 invalid_url (“hostname, not an IP address”)
    The original issue is no longer reproducible. Fix looks good on my side.
    Thank you as well for the reward, I really appreciate it 🙏

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants