Skip to content

Commit f28fa02

Browse files
authored
fix(security): gate webhook creation through the public-host validator (#81) (#92)
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.
1 parent 1af4fdf commit f28fa02

2 files changed

Lines changed: 130 additions & 13 deletions

File tree

‎crates/gitlawb-node/src/api/peers.rs‎

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,38 @@ pub struct PeerResponse {
4040
pub reachable: bool,
4141
}
4242

43+
/// Extract an IPv4 address embedded in an IPv6 literal across the transition
44+
/// formats that carry one: IPv4-mapped (`::ffff:a.b.c.d`), IPv4-compatible
45+
/// (`::a.b.c.d`), 6to4 (`2002:WWXX:YYZZ::/16`), and the NAT64 well-known prefix
46+
/// (`64:ff9b::/96`). Returns `None` for native IPv6. Callers fold the result
47+
/// back through the v4 range checks so loopback/private addresses smuggled in
48+
/// via any of these encodings are rejected, not just the mapped/compatible pair.
49+
///
50+
/// INCOMPLETE-NAT64 CONTRACT: non-well-known NAT64 prefixes (e.g. the RFC 8215
51+
/// local-use `64:ff9b:1::/48`) are deliberately **not** decoded here — their v4
52+
/// sits at a prefix-length-dependent offset (RFC 6052 §2.2) — so they return
53+
/// `None`. Any caller that needs them blocked must reject the wider
54+
/// `64:ff9b::/32` itself; `is_public_http_url` does this in its native-v6 arm.
55+
fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
56+
if let Some(v4) = v6.to_ipv4_mapped().or_else(|| v6.to_ipv4()) {
57+
return Some(v4);
58+
}
59+
let s = v6.segments();
60+
// 6to4 (2002::/16) embeds W.X.Y.Z in the next two segments.
61+
if s[0] == 0x2002 {
62+
let [a, b] = s[1].to_be_bytes();
63+
let [c, d] = s[2].to_be_bytes();
64+
return Some(std::net::Ipv4Addr::new(a, b, c, d));
65+
}
66+
// NAT64 well-known prefix (64:ff9b::/96) embeds the IPv4 in the low 32 bits.
67+
if s[0] == 0x0064 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] {
68+
let [a, b] = s[6].to_be_bytes();
69+
let [c, d] = s[7].to_be_bytes();
70+
return Some(std::net::Ipv4Addr::new(a, b, c, d));
71+
}
72+
None
73+
}
74+
4375
/// Whether a peer `http_url` is a public http(s) endpoint safe to register.
4476
/// Rejects non-http(s) schemes, loopback/unspecified/private/link-local IPs,
4577
/// and `localhost` / `.local` / `.internal` hostnames. Used at announce time
@@ -78,15 +110,12 @@ pub fn is_public_http_url(raw: &str) -> bool {
78110
if ip.is_loopback() || ip.is_unspecified() {
79111
return false;
80112
}
81-
// Fold IPv4-mapped/compatible IPv6 (`::ffff:127.0.0.1`, `::127.0.0.1`)
82-
// down to IPv4 so the v4 range checks catch loopback/private addresses
83-
// smuggled in via an IPv6 literal, then re-check loopback/unspecified.
113+
// Fold any IPv6 literal that embeds an IPv4 address (mapped, compatible,
114+
// 6to4, NAT64) down to that IPv4 so the v4 range checks catch
115+
// loopback/private addresses smuggled in via an IPv6 encoding, then
116+
// re-check loopback/unspecified.
84117
let ip = match ip {
85-
std::net::IpAddr::V6(v6) => v6
86-
.to_ipv4_mapped()
87-
.or_else(|| v6.to_ipv4())
88-
.map(std::net::IpAddr::V4)
89-
.unwrap_or(ip),
118+
std::net::IpAddr::V6(v6) => embedded_ipv4(v6).map(std::net::IpAddr::V4).unwrap_or(ip),
90119
v4 => v4,
91120
};
92121
if ip.is_loopback() || ip.is_unspecified() {
@@ -95,8 +124,14 @@ pub fn is_public_http_url(raw: &str) -> bool {
95124
match ip {
96125
std::net::IpAddr::V4(v4) => {
97126
let o = v4.octets();
98-
// RFC1918 private, link-local, or CGNAT (100.64.0.0/10).
99-
if v4.is_private() || v4.is_link_local() || (o[0] == 100 && (o[1] & 0xc0) == 64) {
127+
// RFC1918 private, link-local, CGNAT (100.64.0.0/10), or the
128+
// RFC1122 "this host" block 0.0.0.0/8 (never a valid destination;
129+
// 0.0.0.0 itself is already caught by the is_unspecified check).
130+
if v4.is_private()
131+
|| v4.is_link_local()
132+
|| (o[0] == 100 && (o[1] & 0xc0) == 64)
133+
|| o[0] == 0
134+
{
100135
return false;
101136
}
102137
}
@@ -106,6 +141,16 @@ pub fn is_public_http_url(raw: &str) -> bool {
106141
if (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 {
107142
return false;
108143
}
144+
// Any NAT64 address (64:ff9b::/32) that is not the cleanly
145+
// decodable well-known /96 — e.g. the RFC 8215 local-use
146+
// 64:ff9b:1::/48 — carries a translated target whose embedded v4
147+
// sits at a prefix-length-dependent offset (RFC 6052 §2.2).
148+
// Rather than risk a wrong decode across every prefix length we
149+
// reject the whole NAT64 space here. The well-known /96 was
150+
// already folded to its v4 above and never reaches this arm.
151+
if s[0] == 0x0064 && s[1] == 0xff9b {
152+
return false;
153+
}
109154
}
110155
}
111156
}
@@ -425,6 +470,11 @@ mod tests {
425470
"http://127.0.0.1:5432/",
426471
"http://localhost:22/",
427472
"http://0.0.0.0:7545",
473+
// RFC1122 "this host" block 0.0.0.0/8 beyond 0.0.0.0 itself,
474+
// including the upper boundary (1.0.0.0 is public, tested below).
475+
"http://0.0.0.1:7545",
476+
"http://0.1.2.3/",
477+
"http://0.255.255.255/",
428478
"http://10.0.0.5:7545",
429479
"http://192.168.1.10/",
430480
"http://172.16.0.1:7545",
@@ -445,15 +495,46 @@ mod tests {
445495
"http://[::ffff:10.0.0.1]/",
446496
"http://[::ffff:192.168.1.1]:7545",
447497
"http://[::127.0.0.1]/",
498+
// 6to4 (2002::/16) embedding loopback/private v4.
499+
"http://[2002:7f00:1::]:7545",
500+
"http://[2002:a00:1::]/",
501+
"http://[2002:c0a8:101::]/",
502+
// NAT64 well-known prefix (64:ff9b::/96) embedding loopback/private v4.
503+
"http://[64:ff9b::7f00:1]/",
504+
"http://[64:ff9b::a00:1]:7545",
505+
// NAT64 local-use prefix (64:ff9b:1::/48, RFC 8215) — rejected
506+
// outright rather than decoded, so the whole NAT64 space is closed.
507+
"http://[64:ff9b:1::7f00:1]/",
508+
"http://[64:ff9b:1::a00:1]:7545",
509+
"http://[64:ff9b:1:a00::]/",
510+
// Local-use NAT64 wrapping a *public* v4 (203.0.113.10) is still
511+
// rejected: the block is prefix-based, not payload-based.
512+
"http://[64:ff9b:1::cb00:710a]/",
448513
] {
449514
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
450515
}
451516
}
452517

518+
#[test]
519+
fn accepts_6to4_and_nat64_wrapping_public_v4() {
520+
// Fold-and-recheck stays consistent with the mapped/compatible handling:
521+
// a transition address that wraps a public v4 is allowed, only the
522+
// private/loopback-wrapping forms above are rejected. 203.0.113.10.
523+
assert!(is_public_http_url("http://[2002:cb00:710a::]/"));
524+
assert!(is_public_http_url("http://[64:ff9b::cb00:710a]/"));
525+
}
526+
453527
#[test]
454528
fn accepts_public_outside_cgnat_range() {
455529
// 100.0.0.0/10 boundary: .63 and .128 are public, only 100.64-127 is CGNAT.
456530
assert!(is_public_http_url("http://100.63.255.255:7545"));
457531
assert!(is_public_http_url("http://100.128.0.1/"));
458532
}
533+
534+
#[test]
535+
fn accepts_first_address_above_this_host_block() {
536+
// 1.0.0.0 is the first address above the rejected 0.0.0.0/8 block;
537+
// pins the `o[0] == 0` check against an off-by-one into 1.x.
538+
assert!(is_public_http_url("http://1.0.0.0/"));
539+
}
459540
}

‎crates/gitlawb-node/src/api/webhooks.rs‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,14 @@ pub async fn create_webhook(
4040
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
4141
crate::api::require_repo_owner(&record, &auth.0)?;
4242

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

@@ -120,3 +124,35 @@ pub async fn delete_webhook(
120124
state.db.delete_webhook(&id).await?;
121125
Ok(Json(serde_json::json!({ "deleted": true, "id": id })))
122126
}
127+
128+
#[cfg(test)]
129+
mod tests {
130+
use crate::api::peers::is_public_http_url;
131+
132+
// create_webhook gates req.url through is_public_http_url. Pin the exact
133+
// SSRF targets from issue #81 so the webhook path can never regress to the
134+
// old scheme-only check, independent of the validator's own peer tests.
135+
#[test]
136+
fn webhook_url_gate_rejects_ssrf_targets() {
137+
for bad in [
138+
"http://127.0.0.1:5432/",
139+
"http://169.254.169.254/latest/meta-data/",
140+
"http://localhost/",
141+
"http://10.0.0.5/",
142+
"http://[::1]/",
143+
// IPv6 transition encodings smuggling loopback v4 (6to4 / NAT64).
144+
"http://[2002:7f00:1::]/",
145+
"http://[64:ff9b::7f00:1]/",
146+
"ftp://example.com/",
147+
"not-a-url",
148+
] {
149+
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
150+
}
151+
}
152+
153+
#[test]
154+
fn webhook_url_gate_allows_public_targets() {
155+
assert!(is_public_http_url("https://hooks.example.com/gitlawb"));
156+
assert!(is_public_http_url("http://203.0.113.10:7545/"));
157+
}
158+
}

0 commit comments

Comments
 (0)