Skip to content

Commit 607828e

Browse files
gracexmatinclaude
andcommitted
fix(sources): rss review round 3 — canonicalize mapped-IPv6 at the egress seam, +fixes
Review P2 (security): check_hop_target and check_addrs handed IPs to the injected EgressPolicy without canonicalizing. An IPv4-mapped IPv6 like ::ffff:10.0.0.1 reached a policy as IpAddr::V6, so a policy checking V4-private and V6-reserved ranges separately allowed it while the OS dual-stack connect delivered the TCP connection to IPv4 10.0.0.1 — the seam failing to hold for injected policies (not the documented AllowAll SSRF stance). Both seam call points now to_canonical() before check_ip (unmapping mapped-v6 to V4), reporting the canonical address in EgressDenied; check_addrs still returns the original addrs to connect to. Regression tests: bracketed mapped literal on the initial URL and on a redirect hop, plus a check_addrs unit test via a DenyV4 policy that can only match once canonicalized (the AAAA-record path). Review P3s: - OPML over-cap file cut mid-UTF-8-sequence reported "invalid UTF-8" instead of the size limit. read_bytes_bounded now reads raw bytes; the size check runs before String::from_utf8, so an over-size file always reports the size limit. Test pins it with a multibyte pad. - Seam test gaps: the redirect->hostname->PolicyDns composition (DNS-rebinding-via-redirect) and the Some(Validators{None,None})-answered -with-304 distinction (sent_validator vs is_some) are now pinned. - Dangling forward references: error.rs/egress.rs doc comments and the max_error_chars test cited docs that land later in this stack; softened to "published later in this stack" rather than exact paths/line numbers, so nothing dangles or claims falsely if phase 1 merges alone. - Convention: quick_xml::XmlVersion imported at the top instead of an inline path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c708c02 commit 607828e

4 files changed

Lines changed: 250 additions & 30 deletions

File tree

crates/skardi/src/sources/providers/rss/egress.rs

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
//! targets). An operator — or Skardi Cloud — supplies a real [`EgressPolicy`]
88
//! through the fetcher's constructor to restrict egress; the reserved-range
99
//! taxonomy that would refuse loopback/link-local/private/CGNAT/unique-local
10-
//! targets is Cloud policy, specified in
11-
//! `docs/superpowers/specs/2026-08-03-rss-cloud-egress-design.md`, not shipped
12-
//! here.
10+
//! targets is Cloud policy, specified in the RSS Cloud egress design doc
11+
//! (`docs/superpowers/specs/2026-08-03-rss-cloud-egress-design.md`, added
12+
//! later in this stack), not shipped here.
1313
//!
1414
//! The seam is enforced at the DNS-resolver layer ([`PolicyDns`]) so an
1515
//! injected policy holds against DNS rebinding: reqwest only ever connects to
@@ -97,14 +97,22 @@ pub(crate) fn check_addrs(
9797
addrs: Vec<SocketAddr>,
9898
) -> Result<Vec<SocketAddr>, EgressDenied> {
9999
for addr in &addrs {
100-
if let Err(reason) = policy.check_ip(addr.ip()) {
100+
// Canonicalize for the CHECK only: a dual-stack connect to an
101+
// IPv4-mapped v6 (an AAAA answer like `::ffff:10.0.0.1`) reaches the
102+
// unmapped V4, so the policy must judge that V4 or the mapped form
103+
// bypasses a V4-private rule. `to_canonical` unmaps mapped-v6 to V4 and
104+
// leaves everything else unchanged.
105+
let ip = addr.ip().to_canonical();
106+
if let Err(reason) = policy.check_ip(ip) {
101107
return Err(EgressDenied {
102108
host: host.to_string(),
103-
ip: addr.ip(),
109+
ip,
104110
reason,
105111
});
106112
}
107113
}
114+
// Return the ORIGINAL addrs: canonicalization gated the check, but the
115+
// caller must connect to exactly what the lookup returned.
108116
Ok(addrs)
109117
}
110118

@@ -140,6 +148,7 @@ impl Resolve for PolicyDns {
140148
#[cfg(test)]
141149
mod tests {
142150
use super::*;
151+
use std::net::Ipv4Addr;
143152

144153
/// Test-only denying policy: refuses every address. OSS ships no such
145154
/// policy — the seam exists so a caller can inject one.
@@ -151,6 +160,22 @@ mod tests {
151160
}
152161
}
153162

163+
/// Test-only policy that denies exactly one V4 address and nothing else.
164+
/// It cannot match a raw IPv4-mapped v6 (`::ffff:10.0.0.1` arrives as
165+
/// `IpAddr::V6`), so it only refuses if `check_addrs` canonicalized first —
166+
/// which is precisely what the mapped-v6 test relies on.
167+
#[derive(Debug)]
168+
struct DenyV4(Ipv4Addr);
169+
impl EgressPolicy for DenyV4 {
170+
fn check_ip(&self, ip: IpAddr) -> Result<(), EgressReason> {
171+
if ip == IpAddr::V4(self.0) {
172+
Err("test-denied".into())
173+
} else {
174+
Ok(())
175+
}
176+
}
177+
}
178+
154179
#[test]
155180
fn allow_all_permits_every_address() {
156181
// The OSS default refuses nothing — including addresses a cloud policy
@@ -180,6 +205,18 @@ mod tests {
180205
assert_eq!(err.reason, "test-denied");
181206
}
182207

208+
#[test]
209+
fn check_addrs_canonicalizes_mapped_ipv6_before_policy() {
210+
// The resolver/AAAA-record equivalent of the fetcher's mapped-literal
211+
// case: a lookup answer of `::ffff:10.0.0.1` is a v6 spelling of the V4
212+
// 10.0.0.1 that a dual-stack connect reaches. DenyV4 refuses only the
213+
// raw V4, so this passes only because check_addrs canonicalizes before
214+
// consulting the policy.
215+
let policy = DenyV4("10.0.0.1".parse().unwrap());
216+
let addrs: Vec<SocketAddr> = vec!["[::ffff:10.0.0.1]:80".parse().unwrap()];
217+
assert!(check_addrs(&policy, "evil.example", addrs).is_err());
218+
}
219+
183220
#[test]
184221
fn egress_denied_display_names_host_reason_and_ip() {
185222
// Contractual: stored verbatim as feeds.last_error. A cloud policy that

crates/skardi/src/sources/providers/rss/error.rs

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ use thiserror::Error;
2222
/// A bound on *length* only. What content may reach `feeds.last_error` at all is
2323
/// a separate question, argued in `engine.rs`'s module doc.
2424
///
25-
/// `docs/rss.md` and `docs/rss/semantics.yaml` publish the number as a bare
26-
/// `512`; neither is Rust and neither can reference this constant, so both name
27-
/// it as `MAX_ERROR_CHARS`'s value and this is where it is defined.
25+
/// The RSS docs (`docs/rss.md` and `docs/rss/semantics.yaml`, published later
26+
/// in this stack) will spell the number as a bare `512`; neither is Rust and
27+
/// neither can reference this constant, so both name it as `MAX_ERROR_CHARS`'s
28+
/// value and this is where it is defined.
2829
pub const MAX_ERROR_CHARS: usize = 512;
2930

3031
/// Bound `text` to `max_chars` *characters*, cutting on a char boundary so a
@@ -115,21 +116,22 @@ mod tests {
115116

116117
/// The published number, spelled literally.
117118
///
118-
/// `docs/rss.md:974` ("bounded at 512 characters") and
119-
/// `docs/rss/semantics.yaml:85` (the `last_error` column description) both
120-
/// state 512 as this provider's diagnostic-length contract, and neither is
121-
/// Rust: neither can reference the constant, so neither would notice it
122-
/// changing. Every other assertion in this crate compares against
123-
/// `MAX_ERROR_CHARS` itself and so agrees with any value it is given —
124-
/// verified by mutation (512 → 200 and 512 → 999 both left the suite
125-
/// green). Same discipline as `mod.rs`'s
119+
/// The RSS docs (`docs/rss.md` and `docs/rss/semantics.yaml`, published
120+
/// later in this stack) will state 512 as this provider's
121+
/// diagnostic-length contract, and neither is Rust: neither can reference
122+
/// the constant, so neither would notice it changing. Every other
123+
/// assertion in this crate compares against `MAX_ERROR_CHARS` itself and so
124+
/// agrees with any value it is given — verified by mutation (512 → 200 and
125+
/// 512 → 999 both left the suite green). Same discipline as `mod.rs`'s
126126
/// `schema_metadata_carries_surface_version`, which spells `"1"` rather than
127-
/// reading `RSS_SURFACE_VERSION`.
127+
/// reading `RSS_SURFACE_VERSION`. Once those docs land, the phase that adds
128+
/// them owns keeping their literal in step with this test (see the phase-4
129+
/// note); until then this pins the value against silent drift.
128130
#[test]
129131
fn max_error_chars_is_the_number_the_docs_publish() {
130132
assert_eq!(
131133
MAX_ERROR_CHARS, 512,
132-
"docs/rss.md and docs/rss/semantics.yaml both publish 512; change them together"
134+
"the RSS docs (published later in this stack) will state 512; keep them in step"
133135
);
134136
}
135137

crates/skardi/src/sources/providers/rss/fetch.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,12 @@ impl FeedFetcher {
338338
_ => None,
339339
};
340340
if let Some(ip) = ip {
341+
// Canonicalize before checking: a dual-stack OS connect to an
342+
// IPv4-mapped v6 literal (`::ffff:10.0.0.1`) reaches the unmapped
343+
// V4 (10.0.0.1), so the policy must judge that same V4 — otherwise
344+
// a V4-private rule is bypassed by the mapped form. Report the
345+
// canonical address too, so the error names the rule that matched.
346+
let ip = ip.to_canonical();
341347
self.policy.check_ip(ip).map_err(|reason| EgressDenied {
342348
host: ip.to_string(),
343349
ip,
@@ -707,6 +713,42 @@ mod tests {
707713
);
708714
}
709715

716+
#[tokio::test]
717+
async fn empty_validators_answered_with_304_is_a_status_error() {
718+
// The `Some`-but-empty companion to
719+
// `unconditional_304_without_validators_is_a_status_error`:
720+
// `attempt_hop` maps a `304` to `NotModified` only when a conditional
721+
// header was *actually sent*, tracked by `sent_validator` — not merely
722+
// whether a `Validators` was passed. A `Some(Validators { etag: None,
723+
// last_modified: None })` attaches no `If-None-Match`/`If-Modified-Since`
724+
// header, so a `304` answering it has no validator behind it and must
725+
// surface as the terminal status it is, exactly as the `None` case does
726+
// — never a fabricated `NotModified` with no cached body to back it up.
727+
let server = MockFeedServer::start(|_req| MockResponse::status(304)).await;
728+
let f = test_fetcher();
729+
let v = Validators {
730+
etag: None,
731+
last_modified: None,
732+
};
733+
let err = f
734+
.fetch(&format!("{}/f", server.url()), Some(&v))
735+
.await
736+
.unwrap_err();
737+
assert!(
738+
matches!(err, FetchError::Status { status: 304 }),
739+
"got {err}"
740+
);
741+
let req = &server.requests()[0];
742+
assert!(
743+
req.header("if-none-match").is_none(),
744+
"an empty Validators must attach no If-None-Match"
745+
);
746+
assert!(
747+
req.header("if-modified-since").is_none(),
748+
"an empty Validators must attach no If-Modified-Since"
749+
);
750+
}
751+
710752
#[tokio::test]
711753
async fn validators_are_not_resent_after_redirect() {
712754
// The module doc devotes a section to "validators cover only the
@@ -1046,6 +1088,26 @@ mod tests {
10461088
}
10471089
}
10481090

1091+
#[tokio::test]
1092+
async fn injected_policy_refuses_mapped_ipv6_literal_on_initial_url() {
1093+
// The IPv4-mapped-v6 literal path: `::ffff:10.0.0.1` is a v6 spelling of
1094+
// the V4 10.0.0.1 that a dual-stack connect actually reaches. The deny
1095+
// list holds the V4, so check_hop_target must canonicalize before the
1096+
// check. Before the fix the policy saw an unmatched V6, allowed it, and
1097+
// the fetch failed with a Transport error from the attempted connect to
1098+
// port 9 — so this pins the canonicalization.
1099+
let policy = Arc::new(DenyList(vec!["10.0.0.1".parse().unwrap()]));
1100+
let f = fetcher_with_policy(policy);
1101+
let err = f
1102+
.fetch("http://[::ffff:10.0.0.1]:9/f", None)
1103+
.await
1104+
.unwrap_err();
1105+
match err {
1106+
FetchError::Egress(e) => assert_eq!(e.reason, "test-denied", "got {e}"),
1107+
other => panic!("expected Egress, got {other:?}"),
1108+
}
1109+
}
1110+
10491111
#[tokio::test]
10501112
async fn injected_policy_refuses_redirect_target() {
10511113
// The redirect path: the loopback mock (allowed) 302s to a denied
@@ -1071,6 +1133,33 @@ mod tests {
10711133
);
10721134
}
10731135

1136+
#[tokio::test]
1137+
async fn injected_policy_refuses_mapped_ipv6_redirect_target() {
1138+
// The redirect path with an IPv4-mapped-v6 target: the loopback mock
1139+
// (allowed) 302s to `::ffff:10.0.0.1`, a v6 spelling of the denied V4.
1140+
// check_hop_target must canonicalize before the check so the V4 deny
1141+
// rule matches and the mapped target is never connected to.
1142+
let server = MockFeedServer::start(|_req| {
1143+
MockResponse::status(302).with_header("location", "http://[::ffff:10.0.0.1]/f")
1144+
})
1145+
.await;
1146+
let policy = Arc::new(DenyList(vec!["10.0.0.1".parse().unwrap()]));
1147+
let f = fetcher_with_policy(policy);
1148+
let err = f
1149+
.fetch(&format!("{}/start", server.url()), None)
1150+
.await
1151+
.unwrap_err();
1152+
match err {
1153+
FetchError::Egress(e) => assert_eq!(e.reason, "test-denied", "got {e}"),
1154+
other => panic!("expected Egress, got {other:?}"),
1155+
}
1156+
assert_eq!(
1157+
server.requests().len(),
1158+
1,
1159+
"the denied redirect target must never be connected to"
1160+
);
1161+
}
1162+
10741163
#[tokio::test]
10751164
async fn injected_policy_refuses_hostname_via_resolver() {
10761165
// The resolver path: a hostname (`localhost`) that resolves to a denied
@@ -1090,6 +1179,49 @@ mod tests {
10901179
assert!(matches!(err, FetchError::Egress(_)), "got {err}");
10911180
}
10921181

1182+
#[tokio::test]
1183+
async fn injected_policy_refuses_hostname_redirect_target_via_resolver() {
1184+
// The DNS-rebinding-via-redirect path, composing the two preceding
1185+
// tests: `injected_policy_refuses_redirect_target` refuses a redirect
1186+
// to an IP *literal*, and `injected_policy_refuses_hostname_via_resolver`
1187+
// refuses an initial-URL *hostname* via PolicyDns — this refuses a
1188+
// redirect whose target is a hostname the resolver then denies.
1189+
//
1190+
// Making it work on loopback turns on the IPv4/IPv6 asymmetry: hop 0 is
1191+
// `server.url()`, the 127.0.0.1 literal `check_hop_target`'s IP arm must
1192+
// ALLOW so the first hop connects; the 302 then points at `localhost`
1193+
// (same port, via the recorded `host` header — the handler runs before
1194+
// `server` exists, so it cannot read `server.url()` directly), which
1195+
// PolicyDns resolves to the dual-stack set and `check_addrs` denies as a
1196+
// whole because `::1` is on the deny list. Denying only `::1` while
1197+
// allowing 127.0.0.1 is exactly what lets hop 0 through yet refuses hop
1198+
// 1's hostname — otherwise both hops share loopback and no policy could
1199+
// allow one without the other. This relies on `localhost` resolving
1200+
// dual-stack (a set containing `::1`), which holds on the CI runner.
1201+
let server = MockFeedServer::start(|req| {
1202+
let host = req.header("host").expect("reqwest sends a host header");
1203+
let redirect_to = format!("http://{}/denied", host.replace("127.0.0.1", "localhost"));
1204+
MockResponse::status(302).with_header("location", &redirect_to)
1205+
})
1206+
.await;
1207+
let policy = Arc::new(DenyList(vec!["::1".parse().unwrap()]));
1208+
let f = fetcher_with_policy(policy);
1209+
let err = f
1210+
.fetch(&format!("{}/start", server.url()), None)
1211+
.await
1212+
.unwrap_err();
1213+
match err {
1214+
FetchError::Egress(e) => assert_eq!(e.reason, "test-denied", "got {e}"),
1215+
other => panic!("expected Egress, got {other:?}"),
1216+
}
1217+
assert_eq!(
1218+
server.requests().len(),
1219+
1,
1220+
"only /start was ever connected; the denied hostname target was \
1221+
refused before any connection"
1222+
);
1223+
}
1224+
10931225
/// The child half of `proxy_env_vars_do_not_bypass_the_egress_policy`:
10941226
/// the actual fetch-under-proxy-variables check, meant to run in a
10951227
/// subprocess whose environment the parent set at spawn. `#[ignore]`

0 commit comments

Comments
 (0)