Skip to main content

solid_pod_rs_server/
scheme.rs

1//! Externally visible request scheme, with proxy headers honoured only from
2//! a trusted proxy.
3//!
4//! JSS derives the public scheme from `X-Forwarded-Proto` and, failing that,
5//! the Cloudflare `cf-visitor: {"scheme":"https"}` header
6//! (`src/ap/index.js` `getProtocol` 49-56 and `src/ap/routes/actor.js` 17-18).
7//! Those headers are attacker-controlled unless a proxy that the operator
8//! trusts overwrites them, and the scheme feeds the NIP-98 signed-URL check
9//! and every absolute base URI the server mints. So here they are honoured
10//! only when the operator sets the trusted-proxy flag
11//! (`--trust-proxy` / `JSS_TRUST_PROXY`, default off). Without it the scheme
12//! is the transport's own: `https` when the listener terminates TLS, else
13//! `http`.
14
15use actix_web::{web, HttpRequest};
16
17use crate::AppState;
18
19/// Parse a scheme value, accepting only `http` and `https`
20/// (case-insensitive).
21fn clean_scheme(raw: &str) -> Option<&'static str> {
22    match raw.trim().to_ascii_lowercase().as_str() {
23        "https" => Some("https"),
24        "http" => Some("http"),
25        _ => None,
26    }
27}
28
29/// Scheme named by the proxy headers, if any: `X-Forwarded-Proto` first
30/// (first element of a comma list), else the `scheme` member of the
31/// `cf-visitor` JSON object. An unusable `X-Forwarded-Proto` value falls
32/// through to `cf-visitor`.
33pub fn proxy_header_scheme(req: &HttpRequest) -> Option<&'static str> {
34    if let Some(v) = req
35        .headers()
36        .get("x-forwarded-proto")
37        .and_then(|v| v.to_str().ok())
38    {
39        if let Some(s) = v.split(',').next().and_then(clean_scheme) {
40            return Some(s);
41        }
42    }
43    let cf = req.headers().get("cf-visitor")?.to_str().ok()?;
44    if cf.len() > 256 {
45        return None;
46    }
47    let parsed: serde_json::Value = serde_json::from_str(cf).ok()?;
48    clean_scheme(parsed.get("scheme")?.as_str()?)
49}
50
51/// Scheme of the connection itself, ignoring every forwarding header.
52fn transport_scheme(req: &HttpRequest) -> &'static str {
53    if req.app_config().secure() {
54        "https"
55    } else {
56        "http"
57    }
58}
59
60/// The request scheme as the client sees it.
61///
62/// With `trust_proxy` set this is [`proxy_header_scheme`], falling back to
63/// the transport scheme; otherwise forwarding headers are ignored.
64///
65/// ```
66/// use actix_web::test::TestRequest;
67/// use solid_pod_rs_server::scheme::effective_scheme_with;
68///
69/// let req = TestRequest::get()
70///     .insert_header(("cf-visitor", r#"{"scheme":"https"}"#))
71///     .to_http_request();
72/// assert_eq!(effective_scheme_with(&req, true), "https");
73/// assert_eq!(effective_scheme_with(&req, false), "http");
74/// ```
75pub fn effective_scheme_with(req: &HttpRequest, trust_proxy: bool) -> &'static str {
76    if trust_proxy {
77        if let Some(s) = proxy_header_scheme(req) {
78            return s;
79        }
80    }
81    transport_scheme(req)
82}
83
84/// [`effective_scheme_with`] using the server's configured trusted-proxy
85/// flag (`false` when the request carries no [`AppState`]).
86pub fn effective_scheme(req: &HttpRequest) -> &'static str {
87    let trust = req
88        .app_data::<web::Data<AppState>>()
89        .map(|s| s.trust_proxy)
90        .unwrap_or(false);
91    effective_scheme_with(req, trust)
92}
93
94/// Reduce a configured public base URL to its origin (`scheme://authority`,
95/// no path, no trailing slash). `None` when it is not an `http(s)` URL with a
96/// usable authority.
97///
98/// ```
99/// use solid_pod_rs_server::scheme::normalise_origin;
100/// assert_eq!(normalise_origin("https://pod.example/base/"), Some("https://pod.example".into()));
101/// assert_eq!(normalise_origin("ftp://pod.example"), None);
102/// ```
103pub fn normalise_origin(raw: &str) -> Option<String> {
104    let raw = raw.trim();
105    let (scheme, rest) = raw.split_once("://")?;
106    let scheme = clean_scheme(scheme)?;
107    let authority = rest.split(['/', '?', '#']).next()?;
108    valid_authority(authority).then(|| format!("{scheme}://{}", authority.to_ascii_lowercase()))
109}
110
111/// `true` for a plausible `host[:port]` (letters, digits, `.`, `-`, `:`,
112/// bracketed IPv6), so a forged header cannot smuggle a path, userinfo or
113/// whitespace into a generated URL.
114fn valid_authority(a: &str) -> bool {
115    !a.is_empty()
116        && a.len() <= 255
117        && a.bytes()
118            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'[' | b']'))
119}
120
121/// Host named by the first hop of an `X-Forwarded-Host` list, else by the
122/// `host=` parameter of the first `Forwarded` element.
123fn proxy_header_host(req: &HttpRequest) -> Option<String> {
124    let xfh = req
125        .headers()
126        .get("x-forwarded-host")
127        .and_then(|v| v.to_str().ok())
128        .and_then(|v| v.split(',').next())
129        .map(str::trim);
130    if let Some(h) = xfh.filter(|h| valid_authority(h)) {
131        return Some(h.to_string());
132    }
133    let fwd = req.headers().get("forwarded")?.to_str().ok()?;
134    let first = fwd.split(',').next()?;
135    first.split(';').find_map(|p| {
136        let (k, v) = p.split_once('=')?;
137        let v = v.trim().trim_matches('"');
138        (k.trim().eq_ignore_ascii_case("host") && valid_authority(v)).then(|| v.to_string())
139    })
140}
141
142/// The `Host` header (or the HTTP/2 `:authority`) exactly as received.
143fn raw_host(req: &HttpRequest) -> Option<String> {
144    req.headers()
145        .get("host")
146        .and_then(|v| v.to_str().ok())
147        .map(str::trim)
148        .filter(|h| valid_authority(h))
149        .map(str::to_string)
150        .or_else(|| {
151            req.uri()
152                .authority()
153                .map(|a| a.as_str().to_string())
154                .filter(|a| valid_authority(a))
155        })
156}
157
158/// The authority a request should be treated as addressed to, with the
159/// configuration passed explicitly.
160///
161/// Order: the configured public origin; with `trust_proxy`, `X-Forwarded-Host`
162/// then `Forwarded`; the plain `Host` header; the request URI authority; the
163/// server's own bound address.
164/// Without `trust_proxy` the forwarding headers are never read, so a client
165/// cannot retarget NIP-98 URL matching or generated links by sending them.
166///
167/// ```
168/// use actix_web::test::TestRequest;
169/// use solid_pod_rs_server::scheme::host_with;
170///
171/// let req = TestRequest::get()
172///     .insert_header(("host", "pod.example"))
173///     .insert_header(("x-forwarded-host", "evil.example"))
174///     .to_http_request();
175/// assert_eq!(host_with(&req, None, false), "pod.example");
176/// assert_eq!(host_with(&req, None, true), "evil.example");
177/// assert_eq!(host_with(&req, Some("https://pod.example"), true), "pod.example");
178/// ```
179pub fn host_with(req: &HttpRequest, public_origin: Option<&str>, trust_proxy: bool) -> String {
180    if let Some(o) = public_origin {
181        if let Some((_, a)) = o.split_once("://") {
182            return a.to_string();
183        }
184    }
185    if trust_proxy {
186        if let Some(h) = proxy_header_host(req) {
187            return h;
188        }
189    }
190    // Last resort: the server's own bound address (never client-controlled).
191    raw_host(req).unwrap_or_else(|| req.app_config().host().to_string())
192}
193
194/// [`host_with`] using the server's configured public origin and
195/// trusted-proxy flag.
196///
197/// ```
198/// use actix_web::test::TestRequest;
199/// use solid_pod_rs_server::scheme::request_host;
200///
201/// // No `AppState` is attached, so forwarding headers are ignored.
202/// let req = TestRequest::get()
203///     .insert_header(("host", "pod.example"))
204///     .insert_header(("x-forwarded-host", "evil.example"))
205///     .to_http_request();
206/// assert_eq!(request_host(&req), "pod.example");
207/// ```
208pub fn request_host(req: &HttpRequest) -> String {
209    match req.app_data::<web::Data<AppState>>() {
210        Some(s) => host_with(req, s.public_origin().as_deref(), s.trust_proxy),
211        None => host_with(req, None, false),
212    }
213}
214
215/// `scheme://host` of the request. A configured public origin wins outright;
216/// otherwise [`effective_scheme`] plus [`request_host`].
217pub fn request_origin(req: &HttpRequest) -> String {
218    if let Some(o) = req
219        .app_data::<web::Data<AppState>>()
220        .and_then(|s| s.public_origin())
221    {
222        return o;
223    }
224    format!("{}://{}", effective_scheme(req), request_host(req))
225}
226
227/// The absolute URL the client addressed (origin plus request target), the
228/// value a NIP-98 `u` tag is signed over.
229///
230/// ```
231/// use actix_web::test::TestRequest;
232/// use solid_pod_rs_server::scheme::request_url;
233///
234/// let req = TestRequest::get()
235///     .uri("/pod/file.ttl?x=1")
236///     .insert_header(("host", "pod.example"))
237///     .to_http_request();
238/// assert_eq!(request_url(&req), "http://pod.example/pod/file.ttl?x=1");
239/// ```
240pub fn request_url(req: &HttpRequest) -> String {
241    format!("{}{}", request_origin(req), req.uri())
242}
243
244/// `true` when `host` (a `host[:port]`) is permitted by `allowed`, an empty
245/// list permitting everything. Entries match case-insensitively either the
246/// whole authority or just the host part.
247///
248/// ```
249/// use solid_pod_rs_server::scheme::host_allowed;
250/// let allow = vec!["pod.example".to_string()];
251/// assert!(host_allowed("POD.example:8443", &allow));
252/// assert!(!host_allowed("evil.example", &allow));
253/// assert!(host_allowed("anything", &[]));
254/// ```
255pub fn host_allowed(host: &str, allowed: &[String]) -> bool {
256    if allowed.is_empty() {
257        return true;
258    }
259    let host = host.to_ascii_lowercase();
260    let bare = match host.rsplit_once(':') {
261        Some((h, p)) if !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) => h,
262        _ => host.as_str(),
263    };
264    allowed.iter().any(|a| {
265        let a = a.trim().to_ascii_lowercase();
266        a == host || a == bare
267    })
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use actix_web::test::TestRequest;
274
275    fn req(headers: &[(&'static str, &'static str)]) -> HttpRequest {
276        let mut r = TestRequest::get().uri("/x");
277        for h in headers {
278            r = r.insert_header(*h);
279        }
280        r.to_http_request()
281    }
282
283    #[test]
284    fn untrusted_headers_are_ignored() {
285        let r = req(&[
286            ("x-forwarded-proto", "https"),
287            ("cf-visitor", r#"{"scheme":"https"}"#),
288        ]);
289        assert_eq!(effective_scheme_with(&r, false), "http");
290    }
291
292    #[test]
293    fn trusted_forwarded_proto_wins_over_cf_visitor() {
294        let r = req(&[
295            ("x-forwarded-proto", "http"),
296            ("cf-visitor", r#"{"scheme":"https"}"#),
297        ]);
298        assert_eq!(effective_scheme_with(&r, true), "http");
299    }
300
301    #[test]
302    fn trusted_cf_visitor_used_when_no_forwarded_proto() {
303        let r = req(&[("cf-visitor", r#"{"scheme":"https"}"#)]);
304        assert_eq!(effective_scheme_with(&r, true), "https");
305    }
306
307    #[test]
308    fn forwarded_proto_comma_list_takes_first_hop() {
309        let r = req(&[("x-forwarded-proto", "https, http")]);
310        assert_eq!(effective_scheme_with(&r, true), "https");
311    }
312
313    #[test]
314    fn garbage_values_fall_back_to_transport() {
315        for h in [
316            ("x-forwarded-proto", "gopher"),
317            ("cf-visitor", "not json"),
318            ("cf-visitor", r#"{"scheme":"ftp"}"#),
319            ("cf-visitor", r#"{"scheme":7}"#),
320        ] {
321            assert_eq!(effective_scheme_with(&req(&[h]), true), "http", "{h:?}");
322        }
323    }
324
325    #[test]
326    fn unusable_forwarded_proto_falls_through_to_cf_visitor() {
327        let r = req(&[
328            ("x-forwarded-proto", "bogus"),
329            ("cf-visitor", r#"{"scheme":"https"}"#),
330        ]);
331        assert_eq!(effective_scheme_with(&r, true), "https");
332    }
333
334    #[test]
335    fn forwarded_host_is_ignored_without_trust_proxy() {
336        let r = req(&[
337            ("host", "pod.example"),
338            ("x-forwarded-host", "evil.example"),
339        ]);
340        assert_eq!(host_with(&r, None, false), "pod.example");
341        let r = req(&[("host", "pod.example"), ("forwarded", "host=evil.example")]);
342        assert_eq!(host_with(&r, None, false), "pod.example");
343    }
344
345    #[test]
346    fn forwarded_host_is_honoured_behind_trust_proxy() {
347        let r = req(&[
348            ("host", "10.0.0.5"),
349            ("x-forwarded-host", "pod.example, other"),
350        ]);
351        assert_eq!(host_with(&r, None, true), "pod.example");
352        let r = req(&[
353            ("host", "10.0.0.5"),
354            ("forwarded", "for=1.2.3.4;host=\"pod.example\""),
355        ]);
356        assert_eq!(host_with(&r, None, true), "pod.example");
357    }
358
359    #[test]
360    fn configured_origin_beats_every_header() {
361        let r = req(&[
362            ("host", "evil.example"),
363            ("x-forwarded-host", "evil.example"),
364        ]);
365        assert_eq!(
366            host_with(&r, Some("https://pod.example"), true),
367            "pod.example"
368        );
369    }
370
371    #[test]
372    fn hostile_host_values_are_not_reflected() {
373        for bad in ["a/b", "user@evil.example", "a b", "evil.example/path?x"] {
374            let r = req(&[("x-forwarded-host", bad)]);
375            assert_eq!(host_with(&r, None, true), r.app_config().host(), "{bad}");
376        }
377    }
378
379    #[test]
380    fn allow_list_matches_host_and_authority() {
381        let a = vec!["pod.example".to_string(), "127.0.0.1:3000".to_string()];
382        assert!(host_allowed("pod.example", &a));
383        assert!(host_allowed("pod.example:8443", &a));
384        assert!(host_allowed("127.0.0.1:3000", &a));
385        assert!(!host_allowed("127.0.0.1:4000", &a));
386        assert!(!host_allowed("evil.example", &a));
387    }
388
389    #[test]
390    fn oversized_cf_visitor_is_ignored() {
391        let big = format!(r#"{{"scheme":"https","pad":"{}"}}"#, "a".repeat(400));
392        let r = TestRequest::get()
393            .insert_header(("cf-visitor", big))
394            .to_http_request();
395        assert_eq!(effective_scheme_with(&r, true), "http");
396    }
397}