Skip to content

fix(portal): validate webhook URLs to block SSRF write path (VULN-6369) - #2121

Merged
Gr1dlock merged 3 commits into
mainfrom
Gr1dlock/surabaya
Jul 30, 2026
Merged

fix(portal): validate webhook URLs to block SSRF write path (VULN-6369)#2121
Gr1dlock merged 3 commits into
mainfrom
Gr1dlock/surabaya

Conversation

@Gr1dlock

Copy link
Copy Markdown
Contributor

PR Type

  • Regular Task
  • Bug Fix
  • QA Tests

Description

Partner action webhook_uri is fetched server-side by app-backend, so an attacker-controlled value is a blind SSRF sink (VULN-6369 / CE25-C014). The api_key Hasura write path was already closed (PR #2077); this closes the remaining service-role UI write path, where the old check skipped validation entirely on the staging deployment and let any https:// host through — including 169.254.169.254, 127.0.0.1, and private/IPv6 ranges. This adds validateWebhookUrl() (HTTPS-only, plus a loopback / private / link-local / cloud-metadata / IPv6 / IPv4-mapped / alternate-encoding denylist) and wires it into the four action form-schemas (Portal + PortalV3, create + update), backed by unit, schema, and api_key-update regression tests. DNS-rebinding and resolution-time egress defense must still be added in app-backend-main (cross-repo), which is the service that actually resolves and fetches the URL.

Checklist

  • I have self-reviewed this PR.
  • I have left comments in the code for clarity.
  • I have added necessary unit tests.
  • I have updated the documentation as needed.

🤖 Generated with Claude Code

Partner action webhook_uri is fetched server-side by app-backend, so an attacker-controlled value is a blind SSRF sink. The api_key Hasura write path was already closed (PR #2077); this closes the service-role UI write path, where the old check skipped validation on staging and let any https host through (incl. 169.254.169.254, 127.0.0.1, and private/IPv6 ranges).

Add validateWebhookUrl() (HTTPS-only plus a loopback/private/link-local/metadata/IPv6/IPv4-mapped/alternate-encoding denylist), wire it into the 4 action form-schemas, and add unit + schema + api_key-update regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Gr1dlock
Gr1dlock requested a review from bin-umar as a code owner July 16, 2026 13:24

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

Copy link
Copy Markdown

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: 690b81b02a

ℹ️ 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 thread web/lib/utils.ts
if (!hostname) return false;

// Block localhost by name (and any *.localhost subdomain).
if (hostname === "localhost" || hostname.endsWith(".localhost")) {

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 Block root-qualified localhost names too

For webhook URLs such as https://localhost./hook or https://foo.localhost./hook, new URL(...).hostname preserves the trailing root dot (localhost.), so this condition does not match and the value falls through as an allowed DNS name. In environments where the backend resolver treats the root-qualified .localhost. special-use name as loopback, this leaves a direct SSRF bypass despite the new localhost denylist; normalize or strip a single trailing dot before this suffix check.

Useful? React with 👍 / 👎.

Comment thread web/lib/utils.ts
const dotted = addr.match(/^::(ffff:)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (dotted) return parseIPv4Octets(dotted[2]);

const mapped = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);

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 Handle canonicalized IPv4-compatible IPv6 literals

When the input is an IPv4-compatible literal like https://[::127.0.0.1]/hook, the URL parser canonicalizes the hostname to ::7f00:1; the dotted branch never sees it, and this regex only handles the ::ffff: mapped form. That means isBlockedIPv6() accepts deprecated IPv4-compatible loopback/link-local targets even though the comment says they are covered, so add handling for the ::hhhh:hhhh compatible form or block ::/96 before allowing the IPv6 literal.

Useful? React with 👍 / 👎.

…SSRF bypasses (VULN-6369)

Codex review of PR #2121 found two write-layer bypasses in validateWebhookUrl: (1) a trailing root dot (localhost., foo.localhost.) is preserved by URL.hostname and slipped past the localhost denylist; (2) IPv4-compatible IPv6 (::127.0.0.1) canonicalizes to ::7f00:1, which the ::ffff:-only mapped check missed. Strip a trailing dot during host normalization and block the whole ::/96 range; add regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Gr1dlock

Copy link
Copy Markdown
Contributor Author

@codex review

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

Copy link
Copy Markdown

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

ℹ️ 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 thread web/lib/utils.ts
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private

return false;

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 Block the rest of the non-public IPv4 ranges

At this fallthrough, any literal IPv4 address outside the handful of ranges above is treated as a public webhook target, so values like https://198.18.0.1/, https://224.0.0.1/, or https://255.255.255.255/ still pass the new SSRF guard even though they are not public unicast addresses and may be routed or handled inside the local network. For this write-layer guard to match its “non-public/internal” contract, use a complete special-purpose IPv4 range check instead of only RFC1918/link-local/CGNAT.

Useful? React with 👍 / 👎.

Comment thread web/lib/utils.ts Outdated
* app-backend, which is the component that performs the request.
*/
export const validateWebhookUrl = (candidate: string): boolean => {
const value = candidate?.trim();

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 Check control characters before trimming

Because the value is trimmed before containsControlCharacter() runs, a webhook URL with leading or trailing controls such as https://collector.example.com/ passes validation, while the form schema still stores the original untrimmed string. That leaves the request/header-smuggling case this helper is trying to reject dependent on app-backend normalizing the value in exactly the same way; validate the raw candidate for controls before trimming or persist the normalized URL.

Useful? React with 👍 / 👎.

…m (VULN-6369)

Address Codex re-review of PR #2121: (1) isBlockedIPv4 now rejects all non-public-unicast ranges (multicast 224/4, reserved+broadcast 240/4, benchmarking 198.18/15, TEST-NETs, 192.0.0/24, 6to4) instead of only RFC1918/loopback/link-local/CGNAT; (2) control-character check runs on the raw candidate before trim() so a leading/trailing newline can no longer be stripped past the smuggling guard while the schema persists the untrimmed value. Adds regression tests for both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Gr1dlock

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: a57d478126

ℹ️ 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".

@Gr1dlock
Gr1dlock merged commit f94a711 into main Jul 30, 2026
13 checks passed
@Gr1dlock
Gr1dlock deleted the Gr1dlock/surabaya branch July 30, 2026 13:56
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.

2 participants