Skip to main content

sofie_http/
guard.rs

1use http::request::Parts;
2use http::{Method, StatusCode};
3use sofie_core::Result;
4
5use crate::answer::{self, Redirect, Response};
6use crate::caller::Caller;
7use crate::router::{Lookup, Router};
8use crate::routes::Audience;
9use crate::session::RequestSession;
10use crate::urlencoded::{self, percent_encode};
11use crate::{Access, Area, RequestContext};
12use sofie_db::Resolve;
13
14const SIGN_IN_FIRST: &str = "Sign in to continue.";
15
16pub(crate) fn unauthenticated(audience: Audience, request: &Parts, caller: Caller<'_>) -> Response {
17    let location = sign_in_location(audience.sign_in, request);
18    let redirect = match caller {
19        Caller::Json => return answer::refusal(StatusCode::UNAUTHORIZED, SIGN_IN_FIRST, caller),
20        #[cfg(feature = "clients")]
21        Caller::Screen => return answer::refusal(StatusCode::UNAUTHORIZED, SIGN_IN_FIRST, caller),
22        Caller::Htmx { .. } => {
23            answer::redirect(Redirect::Navigate, &location).map(|mut response| {
24                *response.status_mut() = StatusCode::UNAUTHORIZED;
25                response
26            })
27        }
28        Caller::FormPost | Caller::Page | Caller::Browser => {
29            answer::redirect(Redirect::SeeOther, &location)
30        }
31    };
32    answer::or_failure(redirect, caller)
33}
34
35/// Only a GET to a path of this site, so the sign-in page cannot redirect anyone elsewhere.
36fn sign_in_location(sign_in: &str, request: &Parts) -> String {
37    let wanted = request
38        .uri
39        .path_and_query()
40        .map(http::uri::PathAndQuery::as_str)
41        .filter(|path| request.method == Method::GET && is_local_path(path));
42    with_next(sign_in, wanted)
43}
44
45fn with_next(action: &str, next: Option<&str>) -> String {
46    match next {
47        Some(path) => format!("{action}?next={}", percent_encode(path)),
48        None => action.to_owned(),
49    }
50}
51
52/// `//host` and `/\host` leave the site, also behind a tab or newline, which browsers drop.
53pub(crate) fn is_local_path(path: &str) -> bool {
54    path.starts_with('/')
55        && !path.starts_with("//")
56        && !path.contains('\\')
57        && !path.contains(char::is_control)
58}
59
60pub struct Next {
61    path: Option<String>,
62}
63
64impl Next {
65    #[must_use]
66    pub fn carried_by(&self, action: &str) -> String {
67        with_next(action, self.path.as_deref())
68    }
69
70    /// A local path, checked as when the browser is sent there after signing in.
71    #[must_use]
72    pub fn path(&self) -> Option<&str> {
73        self.path.as_deref()
74    }
75}
76
77impl<A: Area, M> Resolve<RequestContext<A, M>> for Next {
78    fn resolve(context: &mut RequestContext<A, M>) -> impl Future<Output = Result<Self>> + Send {
79        std::future::ready(Ok(Self {
80            path: next_path(context.query()),
81        }))
82    }
83}
84
85/// Decoding once more must not leave the site either, so an escaped `//`, `\` or scheme is refused.
86fn next_path(query: Option<&str>) -> Option<String> {
87    let raw = query?
88        .split('&')
89        .find_map(|pair| pair.strip_prefix("next="))?;
90    let path = urlencoded::decode(raw.as_bytes()).ok()?;
91    let again = urlencoded::decode(path.as_bytes()).ok()?;
92    (path.is_ascii() && is_local_path(&path) && is_local_path(&again)).then_some(path)
93}
94
95/// A page of another area than the one just signed in to would only send it back to sign in.
96pub(crate) fn signed_in_destination(
97    request: &Parts,
98    session: &RequestSession,
99    router: &Router,
100) -> Option<String> {
101    let area = session.signed_in_area()?;
102    let path = next_path(request.uri.query())?;
103    let page = path.split('?').next().unwrap_or_default();
104    let Lookup::Found(target, _) = router.find(&Method::GET, page) else {
105        return None;
106    };
107    let audience = target.audience;
108    let opens = audience.access == Access::Public || audience.name == area;
109    opens.then_some(path)
110}
111
112#[cfg(test)]
113mod tests {
114    use http::Request;
115
116    use super::*;
117
118    fn location(method: Method, uri: &str) -> String {
119        let (parts, ()) = Request::builder()
120            .method(method)
121            .uri(uri)
122            .body(())
123            .unwrap()
124            .into_parts();
125        sign_in_location("/sign-in", &parts)
126    }
127
128    #[test]
129    fn a_page_is_named_as_the_next_path() {
130        assert_eq!(
131            location(Method::GET, "/account/orders?page=2"),
132            "/sign-in?next=/account/orders%3Fpage%3D2"
133        );
134    }
135
136    #[test]
137    fn a_next_path_is_decoded_and_kept_only_on_this_site() {
138        assert_eq!(
139            next_path(Some("a=1&next=/account/orders%3Fpage%3D2")),
140            Some("/account/orders?page=2".to_owned())
141        );
142        for refused in [
143            "next=//evil.test",
144            "next=/\\evil.test",
145            "next=https://evil.test",
146            "next=javascript:alert(1)",
147            "next=%2F%2Fevil.test",
148            "next=%2f%5Cevil.test",
149            "next=/%2F/evil.test",
150            "next=/%5Cevil.test",
151            "next=%252F%252Fevil.test",
152            "next=/%2F%252Fevil.test",
153            "next=%09//evil.test",
154            "next=/%09/evil.test",
155            "next=/%E2%80%8B",
156            "next=%",
157        ] {
158            assert_eq!(next_path(Some(refused)), None, "{refused}");
159        }
160        assert_eq!(next_path(None), None);
161    }
162
163    #[test]
164    fn no_next_path_leaves_the_site_or_repeats_a_write() {
165        assert_eq!(location(Method::GET, "//evil.test/x"), "/sign-in");
166        assert_eq!(
167            location(Method::GET, "https://evil.test/x"),
168            "/sign-in?next=/x"
169        );
170        assert_eq!(location(Method::POST, "/account/orders"), "/sign-in");
171        assert!(!is_local_path("/\\evil.test"));
172        assert!(!is_local_path("evil.test"));
173    }
174}