did-hosting-common 0.10.1

DID Hosting Common, Common methods and structs related to WebVH Content
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! Request-host extractor with trusted-CIDR gating.
//!
//! Per `docs/multi-domain-spec.md` §3 row "Reverse proxy trust":
//!
//! - **Inside** the trusted-CIDR set: honour `Forwarded` (RFC 7239)
//!   `host=` parameter first, then `X-Forwarded-Host` (first value
//!   only — last is closest to the server and attacker-controllable),
//!   else `Host`.
//! - **Outside** the trusted-CIDR set: always `Host`, regardless of
//!   what forwarded headers the request claims.
//!
//! This is the read-side of the multi-domain feature: every inbound
//! request gets its "intended domain" extracted here, normalised via
//! [`super::normalize::normalize_domain_name`], and then matched
//! against the active-domain set (T20 / T21 enforcement).
//!
//! ## What this module ships
//!
//! - [`HostHeaders`] — the small struct callers pass in. Decouples
//!   the resolver from Axum so it can be unit-tested without a full
//!   request.
//! - [`resolve_request_host`] — the resolver. Returns the raw host
//!   string the client claims; normalisation is the caller's job
//!   (so the caller can decide whether a non-canonical value 404s,
//!   400s, or is silently lowercased depending on the caller's
//!   error surface).
//! - [`parse_forwarded_host`] — public for unit testing of edge
//!   cases. Implements the minimum subset of RFC 7239 needed for the
//!   `host=` parameter; full RFC parsing is out of scope (we don't
//!   consume `for=` / `by=` / `proto=`).
//!
//! The Axum middleware wiring lives in `did-hosting-{server,control}`
//! and lands with T20 — that's the first task that actually consumes
//! the resolved domain.

use std::net::IpAddr;
use std::str::FromStr;

use ipnetwork::IpNetwork;
use tracing::warn;

/// Headers + peer IP the resolver needs. Decoupled from Axum so this
/// is testable as a pure function.
pub struct HostHeaders<'a> {
    /// `Host` header. Always populated by any HTTP/1.1+ client.
    pub host: Option<&'a str>,
    /// `Forwarded` header (RFC 7239). May be `None` even behind a
    /// proxy; many CDNs emit `X-Forwarded-*` only.
    pub forwarded: Option<&'a str>,
    /// `X-Forwarded-Host` header. May carry a comma-separated list
    /// if the request traversed multiple proxies; the resolver
    /// honours the **first** value (the original client's claim;
    /// later values are added by intermediate proxies and are
    /// attacker-controllable relative to the original).
    pub x_forwarded_host: Option<&'a str>,
}

/// Resolve the "intended host" for an inbound request.
///
/// `peer_ip` is the direct TCP peer (what `request.connection_info()
/// .peer_addr()` would report) — NOT a value extracted from a
/// forwarded header. The trust check runs against this; an
/// X-Forwarded-Host spoof from a non-trusted peer has no effect.
///
/// Returns the host string as the caller-claimed value (un-
/// normalised — the caller normalises afterwards). `None` when
/// neither the trusted-path headers nor `Host` is set, which is
/// only reachable in pathological cases (e.g. an HTTP/1.0 client
/// without a Host header).
pub fn resolve_request_host<'a>(
    headers: &'a HostHeaders<'_>,
    peer_ip: Option<IpAddr>,
    trusted_cidrs: &[IpNetwork],
) -> Option<&'a str> {
    let peer_is_trusted = peer_is_trusted(peer_ip, trusted_cidrs);

    if peer_is_trusted {
        // Inside the trusted set: prefer Forwarded `host=`, then
        // X-Forwarded-Host (first value), then Host.
        if let Some(fwd) = headers.forwarded
            && let Some(host) = parse_forwarded_host(fwd)
        {
            return Some(host);
        }
        if let Some(xfh) = headers.x_forwarded_host
            && let Some(first) = first_xff_value(xfh)
        {
            return Some(first);
        }
    } else if headers.forwarded.is_some() || headers.x_forwarded_host.is_some() {
        // Outside the trusted set and the request is *claiming* a
        // forwarded host. Almost always benign (a misconfigured load
        // balancer, a curl with `-H "X-Forwarded-Host: ..."` from a
        // dev box), but it's worth a warn-log so operators can spot
        // a deployment misconfig before it bites them in production.
        warn!(
            peer = ?peer_ip,
            "request from untrusted peer claims Forwarded / X-Forwarded-Host; ignoring"
        );
    }

    headers.host
}

/// True when `peer_ip` matches any of `trusted_cidrs`. An unset peer
/// IP is treated as untrusted (the safe default — we can't prove the
/// origin).
fn peer_is_trusted(peer_ip: Option<IpAddr>, trusted_cidrs: &[IpNetwork]) -> bool {
    let Some(ip) = peer_ip else {
        return false;
    };
    trusted_cidrs.iter().any(|cidr| cidr.contains(ip))
}

/// Take the first comma-separated value from an `X-Forwarded-Host`
/// header, trimmed of surrounding whitespace. Empty result → `None`.
///
/// **First** is correct: multiple proxies prepend their own values
/// to the list; the original client's claim is at index 0 and the
/// later ones are added by intermediate trusted proxies (or by an
/// attacker controlling an intermediate hop, in which case the
/// trusted-CIDR gate above is what stops the spoof).
fn first_xff_value(header: &str) -> Option<&str> {
    let first = header.split(',').next()?.trim();
    if first.is_empty() { None } else { Some(first) }
}

/// Parse the `host=` parameter from an RFC 7239 `Forwarded` header
/// value. Returns the value with surrounding quotes stripped and IPv6
/// bracket preserved. `None` if no `host=` parameter is present or
/// the header is malformed.
///
/// Supports:
/// - Multiple forwarded-element groups (comma-separated). Honours
///   the **first** group's `host=` (same rationale as
///   [`first_xff_value`]).
/// - Quoted (`host="example.com"`) and unquoted (`host=example.com`)
///   parameter values.
/// - Case-insensitive parameter names (`Host=`, `HOST=`).
/// - Other parameters in the same group (`for=`, `by=`, `proto=`)
///   are tolerated and ignored.
///
/// Out of scope: full RFC 7239 parser. We only need `host=`; the
/// other parameters are consumed by other middleware (or not at all).
pub fn parse_forwarded_host(header: &str) -> Option<&str> {
    let first_group = header.split(',').next()?;
    for raw_pair in first_group.split(';') {
        let pair = raw_pair.trim();
        let (name, value) = pair.split_once('=')?;
        if !name.trim().eq_ignore_ascii_case("host") {
            continue;
        }
        let value = value.trim();
        // Strip outer double-quotes, if present.
        let stripped = value
            .strip_prefix('"')
            .and_then(|s| s.strip_suffix('"'))
            .unwrap_or(value);
        if stripped.is_empty() {
            return None;
        }
        return Some(stripped);
    }
    None
}

/// Parse a list of CIDR strings (the `trusted_proxy_cidrs` config
/// field). Returns the parsed CIDRs and a list of any strings that
/// failed to parse (so the caller can warn-log them but still boot).
pub fn parse_trusted_cidrs(input: &[String]) -> (Vec<IpNetwork>, Vec<String>) {
    let mut ok = Vec::with_capacity(input.len());
    let mut bad = Vec::new();
    for s in input {
        match IpNetwork::from_str(s) {
            Ok(net) => ok.push(net),
            Err(_) => bad.push(s.clone()),
        }
    }
    (ok, bad)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cidr(s: &str) -> IpNetwork {
        IpNetwork::from_str(s).expect("valid cidr")
    }

    fn ip(s: &str) -> IpAddr {
        IpAddr::from_str(s).expect("valid ip")
    }

    fn h(
        host: Option<&'static str>,
        fwd: Option<&'static str>,
        xfh: Option<&'static str>,
    ) -> HostHeaders<'static> {
        HostHeaders {
            host,
            forwarded: fwd,
            x_forwarded_host: xfh,
        }
    }

    // ---- peer trust ----

    #[test]
    fn peer_inside_cidr_is_trusted() {
        let cidrs = vec![cidr("10.0.0.0/8")];
        assert!(peer_is_trusted(Some(ip("10.1.2.3")), &cidrs));
    }

    #[test]
    fn peer_outside_cidr_is_untrusted() {
        let cidrs = vec![cidr("10.0.0.0/8")];
        assert!(!peer_is_trusted(Some(ip("172.16.1.1")), &cidrs));
    }

    #[test]
    fn empty_cidr_list_trusts_no_one() {
        assert!(!peer_is_trusted(Some(ip("10.0.0.1")), &[]));
        assert!(!peer_is_trusted(Some(ip("127.0.0.1")), &[]));
    }

    #[test]
    fn missing_peer_ip_is_untrusted() {
        assert!(!peer_is_trusted(None, &[cidr("0.0.0.0/0")]));
    }

    // ---- resolve_request_host: untrusted path ----

    #[test]
    fn untrusted_peer_uses_host_header() {
        let headers = h(
            Some("example.com"),
            Some("host=evil.example"),
            Some("evil.example"),
        );
        let cidrs = vec![cidr("10.0.0.0/8")];
        let resolved = resolve_request_host(&headers, Some(ip("8.8.8.8")), &cidrs);
        assert_eq!(resolved, Some("example.com"));
    }

    #[test]
    fn untrusted_peer_xfh_spoof_has_no_effect() {
        // Classic spoof: client sends X-Forwarded-Host claiming a
        // different domain. From outside the trusted CIDR set this
        // must NOT change the resolved host.
        let headers = h(Some("real.example.com"), None, Some("victim.example"));
        let resolved = resolve_request_host(&headers, Some(ip("8.8.8.8")), &[]);
        assert_eq!(resolved, Some("real.example.com"));
    }

    #[test]
    fn missing_host_yields_none() {
        let headers = h(None, None, None);
        let resolved = resolve_request_host(&headers, Some(ip("8.8.8.8")), &[]);
        assert_eq!(resolved, None);
    }

    // ---- resolve_request_host: trusted path ----

    #[test]
    fn trusted_peer_prefers_forwarded_over_xfh_over_host() {
        let headers = h(
            Some("fallback.example"),
            Some("host=forwarded.example"),
            Some("xfh.example"),
        );
        let cidrs = vec![cidr("10.0.0.0/8")];
        let resolved = resolve_request_host(&headers, Some(ip("10.1.2.3")), &cidrs);
        assert_eq!(resolved, Some("forwarded.example"));
    }

    #[test]
    fn trusted_peer_uses_xfh_when_forwarded_absent() {
        let headers = h(Some("fallback.example"), None, Some("xfh.example"));
        let cidrs = vec![cidr("10.0.0.0/8")];
        let resolved = resolve_request_host(&headers, Some(ip("10.1.2.3")), &cidrs);
        assert_eq!(resolved, Some("xfh.example"));
    }

    #[test]
    fn trusted_peer_xfh_first_value_wins() {
        // Multi-proxy: client claim is index 0, intermediate proxies
        // append. First wins.
        let headers = h(
            None,
            None,
            Some("client.example, proxy1.example, proxy2.example"),
        );
        let cidrs = vec![cidr("10.0.0.0/8")];
        let resolved = resolve_request_host(&headers, Some(ip("10.0.0.5")), &cidrs);
        assert_eq!(resolved, Some("client.example"));
    }

    #[test]
    fn trusted_peer_falls_back_to_host_when_forwarded_headers_empty() {
        let headers = h(Some("only.example"), None, None);
        let cidrs = vec![cidr("10.0.0.0/8")];
        let resolved = resolve_request_host(&headers, Some(ip("10.1.2.3")), &cidrs);
        assert_eq!(resolved, Some("only.example"));
    }

    // ---- Forwarded parser ----

    #[test]
    fn forwarded_parses_unquoted_host() {
        assert_eq!(
            parse_forwarded_host("host=example.com"),
            Some("example.com")
        );
    }

    #[test]
    fn forwarded_parses_quoted_host() {
        assert_eq!(
            parse_forwarded_host(r#"host="example.com""#),
            Some("example.com")
        );
    }

    #[test]
    fn forwarded_parses_with_other_params() {
        assert_eq!(
            parse_forwarded_host("for=192.0.2.60;host=example.com;proto=https"),
            Some("example.com")
        );
    }

    #[test]
    fn forwarded_is_case_insensitive_for_param_name() {
        assert_eq!(
            parse_forwarded_host("HOST=example.com"),
            Some("example.com")
        );
        assert_eq!(
            parse_forwarded_host("Host=example.com"),
            Some("example.com")
        );
    }

    #[test]
    fn forwarded_multiple_groups_honours_first() {
        assert_eq!(
            parse_forwarded_host("host=first.example, host=second.example"),
            Some("first.example")
        );
    }

    #[test]
    fn forwarded_missing_host_param() {
        assert_eq!(parse_forwarded_host("for=192.0.2.60;proto=https"), None);
    }

    #[test]
    fn forwarded_empty_value_returns_none() {
        assert_eq!(parse_forwarded_host("host="), None);
        assert_eq!(parse_forwarded_host(r#"host="""#), None);
    }

    #[test]
    fn forwarded_handles_ipv6_bracketed_host() {
        // RFC 7239 §4: IPv6 in Forwarded must be quoted + bracketed.
        // Our parser strips the outer quotes; the brackets stay.
        assert_eq!(
            parse_forwarded_host(r#"host="[2001:db8::1]:8080""#),
            Some("[2001:db8::1]:8080")
        );
    }

    // ---- CIDR list parser ----

    #[test]
    fn parse_trusted_cidrs_separates_good_and_bad() {
        let inputs = vec![
            "10.0.0.0/8".to_string(),
            "garbage".to_string(),
            "2001:db8::/32".to_string(),
            "192.168.1.300/24".to_string(), // invalid octet
        ];
        let (ok, bad) = parse_trusted_cidrs(&inputs);
        assert_eq!(ok.len(), 2);
        assert_eq!(bad, vec!["garbage", "192.168.1.300/24"]);
    }

    // ---- end-to-end smoke ----

    #[test]
    fn realistic_aws_alb_scenario() {
        // AWS ALB sits in 10.0.0.0/16 and adds `X-Forwarded-Host`
        // when forwarding to the app. Forwarded is not emitted by
        // ALB (yet) — so the resolver should pick XFH from the ALB
        // and trust it.
        let cidrs = vec![cidr("10.0.0.0/16")];
        let alb_ip = Some(ip("10.0.5.42"));
        let headers = h(Some("internal.example"), None, Some("tenant-a.example.com"));
        assert_eq!(
            resolve_request_host(&headers, alb_ip, &cidrs),
            Some("tenant-a.example.com")
        );
    }

    #[test]
    fn realistic_direct_request_no_proxy() {
        // No proxy, no trusted CIDRs configured — only Host is used.
        let headers = h(Some("example.com"), None, None);
        let resolved = resolve_request_host(&headers, Some(ip("8.8.8.8")), &[]);
        assert_eq!(resolved, Some("example.com"));
    }
}