Skip to content

fix(security): gate webhook creation through the public-host validator (#81)#92

Merged
kevincodex1 merged 1 commit into
mainfrom
fix/webhook-ssrf-public-host
Jun 25, 2026
Merged

fix(security): gate webhook creation through the public-host validator (#81)#92
kevincodex1 merged 1 commit into
mainfrom
fix/webhook-ssrf-public-host

Conversation

@beardthelion

@beardthelion beardthelion commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Webhook creation accepted any http(s) URL behind a scheme check and never re-validated the host, so any authenticated repo owner could register a webhook at http://127.0.0.1:<port>/ or http://169.254.169.254/latest/meta-data/ and turn the node's on-event POST into an SSRF probe against loopback, private, or cloud-metadata endpoints. This routes the registration URL through is_public_http_url, the hardened validator the peer announce path already uses, so the two SSRF surfaces share one definition of "public".

The other two prongs the issue raised are already closed on main: the owner check is present (require_repo_owner, full-and-short DID match), and delivery runs on the shared no-redirect client from #78, so a public URL that 302s inward is not followed.

The validator gap this surfaced

Wiring the validator into webhooks exposed a real bypass in it: it folded IPv4-mapped and IPv4-compatible IPv6 but not 6to4 or NAT64, so http://[2002:7f00:1::]/ (which decodes to 127.0.0.1) sailed past. The webhook gate is only as strong as the validator behind it, so this hardens the shared function rather than the webhook path alone:

  • Fold 6to4 (2002::/16) and the NAT64 well-known prefix (64:ff9b::/96) down to their embedded v4 and re-run the range checks.
  • Reject the NAT64 local-use prefix (64:ff9b:1::/48, RFC 8215) and the rest of 64:ff9b::/32 outright. The embedded v4 sits at a prefix-length-dependent offset (RFC 6052 §2.2), so attempting to decode every variant is error-prone; refusing the block is the safe call, and a NAT64 literal is never a legitimate webhook or peer target.
  • Reject the RFC 1122 0.0.0.0/8 "this host" block.

The change is tightening-only (it never accepts an address it previously rejected), so all four callers of the validator move in the same direction: the webhook gate, the peer announce handler, the DB-boundary upsert_peer writer, and the boot-time prune. A 6to4 or NAT64 literal that wraps a genuinely public v4 still passes, so no legitimate peer or webhook is rejected or pruned.

Tests

Extended the peer validator's reject and accept sets for the 6to4 / NAT64 / local-use / 0.0.0.0/8 cases, and added a webhook-site regression test pinning issue #81's exact exploit URLs so the gate cannot regress to the old scheme-only check. cargo test -p gitlawb-node is green (172 tests), clippy clean.

Out of scope

Noted for separate follow-up, not addressed here: DNS rebinding between registration and delivery (accepted residual, same posture as the peer path), and three pre-existing issues this review surfaced: the gossip task building a redirect-following client, unauthenticated list_webhooks exposing webhook target URLs, and the validator's lack of a port restriction.

Closes #81.

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced URL validation for peers and webhooks to prevent access to private/loopback addresses and internal services.
    • Improved handling of IPv6 addresses to better detect and reject unsafe URL targets.
  • Tests

    • Added comprehensive tests validating URL safety against known attack patterns.

#81)

Webhook creation only scheme-checked the URL, so an authenticated repo
owner could register a hook pointed at loopback, private, or metadata
endpoints and make the node POST to them on repo events. Route the URL
through is_public_http_url, the same validator the peer announce path
already uses.

Wiring it up surfaced a bypass in that shared validator: it folded
IPv4-mapped and compatible IPv6 but not 6to4 or NAT64, so [2002:7f00:1::]
(127.0.0.1) slipped through. Fold 6to4 and the NAT64 well-known prefix,
reject the NAT64 local-use prefix and the rest of 64:ff9b::/32 outright
(the embedded v4 offset varies by prefix length, so a decode would be
error-prone), and reject 0.0.0.0/8. The change only ever rejects more,
so the peer announce handler, the DB-boundary writer, and the boot-time
prune inherit the same hardening.

Closes #81.
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

is_public_http_url in peers.rs gains an embedded_ipv4 helper that normalizes IPv4-mapped, 6to4, and NAT64 IPv6 addresses to their IPv4 equivalents for range checks, extends rejection to 0.0.0.0/8, and blocks the full 64:ff9b::/32 NAT64 space. Webhook URL validation in webhooks.rs is updated to use this stricter validator instead of a scheme-only prefix check.

Changes

SSRF Hardening: IPv6 Transition Encoding and Webhook URL Gating

Layer / File(s) Summary
embedded_ipv4 helper and is_public_http_url tightening
crates/gitlawb-node/src/api/peers.rs
Adds embedded_ipv4 to extract IPv4 addresses from IPv4-mapped/compatible, 6to4, and well-known NAT64 IPv6 forms. Wires it into is_public_http_url replacing the previous to_ipv4_mapped/to_ipv4 logic. Extends IPv4 rejection to the full 0.0.0.0/8 block. Tightens NAT64 handling to reject the entire 64:ff9b::/32 prefix. Tests are extended with 0.x.x.x addresses, 6to4/NAT64 wrapping of private/loopback targets, a new passing case for 6to4/NAT64 wrapping a public IPv4, and CGNAT boundary tests.
Webhook URL validation gate
crates/gitlawb-node/src/api/webhooks.rs
create_webhook replaces the starts_with("http://") || starts_with("https://") check with is_public_http_url, returning a BadRequest for non-public/unsafe targets. Adds a test module asserting SSRF-relevant URLs (loopback, link-local, internal, transition-encoded) are rejected and a set of public URLs are accepted.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • Gitlawb/node#78: Directly modifies is_public_http_url and its tests in peers.rs; this PR is a follow-up refinement of those same security changes.

Suggested labels

kind:security, crate:node, sev:high

🐇 Six-to-four, NAT64 too,
No private IP shall sneak through!
Webhooks now check, not just the scheme,
SSRF is foiled — or so it seems.
The rabbit hops safe, the node stays clean! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main security fix: gating webhook creation through the public-host validator, directly addressing the SSRF vulnerability in issue #81.
Description check ✅ Passed The description comprehensively covers the vulnerability, the fix, the validator bypass discovered, test coverage, and out-of-scope items, matching the template's required sections.
Linked Issues check ✅ Passed The PR addresses the primary SSRF vulnerability by routing webhook URLs through is_public_http_url [#81], hardens the shared validator against IPv6 bypasses [#81], and adds tests covering the exploit cases [#81].
Out of Scope Changes check ✅ Passed All changes are directly related to addressing the SSRF vulnerability and hardening the validator; out-of-scope items are explicitly noted in the description as follow-ups.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/webhook-ssrf-public-host

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 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 `@crates/gitlawb-node/src/api/webhooks.rs`:
- Around line 43-52: The webhook URL validation at registration time only checks
the hostname string, not the actual resolved IP address, which allows
DNS-rebinding attacks where a public hostname resolves to a loopback or private
IP. Add IP address validation at webhook delivery time in the
`client.post(&hook.url)` call in crates/gitlawb-node/src/webhooks.rs around line
85. Before posting the request, resolve the webhook URL's hostname and validate
that the resolved socket address passes the same IP range checks as
`is_public_http_url` (rejecting loopback and private IP ranges). This ensures
that even if DNS resolves to a private address at delivery time, the request is
blocked.
🪄 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: baa9d9df-531c-4309-a8e0-0bdab3469ffc

📥 Commits

Reviewing files that changed from the base of the PR and between 2153b0b and 8aa8510.

📒 Files selected for processing (2)
  • crates/gitlawb-node/src/api/peers.rs
  • crates/gitlawb-node/src/api/webhooks.rs

Comment on lines +43 to 52
// Gate the target through the same hardened public-host validator the peer
// announce path uses, so an authenticated owner cannot register a webhook
// that makes the node POST to loopback/private/link-local/metadata
// endpoints (SSRF). Delivery runs on the shared no-redirect client
// (main.rs), which closes the 3xx-to-internal bounce.
if !crate::api::peers::is_public_http_url(&req.url) {
return Err(AppError::BadRequest(
"webhook URL must be http:// or https://".into(),
"webhook URL must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate webhook delivery code and inspect the reqwest client construction.
fd -t f -e rs | xargs rg -nP -C3 '(redirect|Policy::none|resolve\(|dns_resolver|deliver|webhook)' 2>/dev/null

# Specifically look for any post-resolution IP re-validation in delivery.
rg -nP -C4 'is_public_http_url|to_socket_addrs|lookup_host|resolve' crates/gitlawb-node/src 2>/dev/null

Repository: Gitlawb/node

Length of output: 50369


🏁 Script executed:

# Find is_public_http_url implementation to understand what it validates
rg -A20 'fn is_public_http_url' crates/gitlawb-node/src 2>/dev/null

# Check if there's any custom DNS resolver or socket filtering
rg -n 'dns_resolver|custom_resolver|socket_addr|to_socket_addrs|0\.0\.0\.0|127\.0\.0\.1|private_ip|is_private' crates/gitlawb-node/src/webhooks.rs 2>/dev/null

# Search for any IP validation in webhooks or related code
rg -n 'IpAddr|ip_addr|resolve|lookup' crates/gitlawb-node/src/webhooks.rs 2>/dev/null

Repository: Gitlawb/node

Length of output: 1661


🏁 Script executed:

# Get the complete is_public_http_url function
rg -A30 'fn is_public_http_url' crates/gitlawb-node/src/api/peers.rs

# Check the full webhooks.rs delivery code for any IP validation
cat -n crates/gitlawb-node/src/webhooks.rs | head -130

Repository: Gitlawb/node

Length of output: 5941


🏁 Script executed:

# Get the complete is_public_http_url function - need more lines
rg -A50 'fn is_public_http_url' crates/gitlawb-node/src/api/peers.rs | head -60

Repository: Gitlawb/node

Length of output: 2323


🏁 Script executed:

# Get the complete is_public_http_url function
rg -A80 'fn is_public_http_url' crates/gitlawb-node/src/api/peers.rs | head -100

Repository: Gitlawb/node

Length of output: 3548


Webhook delivery does not validate resolved IP addresses; DNS-rebinding SSRF remains exploitable.

is_public_http_url validates only the URL's hostname literals and suffix patterns (rejecting localhost, .local, .internal), and rejects IP literals in loopback/private ranges. However, a public hostname (e.g., webhook.attacker.example) that resolves to 127.0.0.1 or a private IP passes this gate, since DNS resolution happens at delivery time.

The webhook delivery code in crates/gitlawb-node/src/webhooks.rs line 85 (client.post(&hook.url)) performs no validation of the resolved address. The HTTP client (main.rs) uses Policy::none() to prevent 3xx redirects but has no custom DNS resolver, socket filter, or connect-time IP guards. Add IP address validation at delivery time to validate the resolved socket address matches the URL's validation requirements.

🤖 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 `@crates/gitlawb-node/src/api/webhooks.rs` around lines 43 - 52, The webhook
URL validation at registration time only checks the hostname string, not the
actual resolved IP address, which allows DNS-rebinding attacks where a public
hostname resolves to a loopback or private IP. Add IP address validation at
webhook delivery time in the `client.post(&hook.url)` call in
crates/gitlawb-node/src/webhooks.rs around line 85. Before posting the request,
resolve the webhook URL's hostname and validate that the resolved socket address
passes the same IP range checks as `is_public_http_url` (rejecting loopback and
private IP ranges). This ensures that even if DNS resolves to a private address
at delivery time, the request is blocked.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Findings

No findings.

I completed the no-findings challenge gate. The one potential bypass I investigated — IPv6 literals with zone IDs such as http://[::1%25eth0]/ — is blocked at reqwest::Url::parse (returns InvalidIpv6Address), so it cannot reach the validator. Other checked classes (handler wiring regression, boot-time prune over-deletion, NAT64 well-known prefix misidentification) also held up.

@kevincodex1 LGTM

@kevincodex1 kevincodex1 left a comment

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.

nice one! thank you

@kevincodex1 kevincodex1 merged commit f28fa02 into main Jun 25, 2026
14 checks passed
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.

Webhook delivery is an unvalidated SSRF sink with no repo-ownership check

3 participants