Skip to main content

rustlavel_http/
flash.rs

1//! Leaving something for the next request from the same visitor.
2//!
3//! A form that fails validation has to answer twice over: tell the person what
4//! is wrong, and give them back what they typed. Neither can travel in the
5//! response, because the answer to a failed `POST` is a redirect — sending the
6//! page directly would leave the browser on a URL that re-submits the form when
7//! reloaded, which is the double-charge problem in miniature.
8//!
9//! So the errors and the old input are left where the *next* request will find
10//! them, and the browser is sent back to the form.
11//!
12//! The trait is here rather than in `rustlavel-auth` on purpose. Validation
13//! needs somewhere to leave errors and must not therefore depend on sessions;
14//! the view layer needs to read them and must not depend on either. A session
15//! registers itself as `Arc<dyn Flash>` on the request, and everything else
16//! talks to that.
17
18use crate::request::Request;
19use rustlavel_core::Json;
20use std::sync::Arc;
21
22/// The key a failed validation leaves its messages under.
23pub const ERRORS_KEY: &str = "_errors";
24
25/// The key it leaves the submitted input under.
26pub const OLD_INPUT_KEY: &str = "_old";
27
28/// The key the session middleware records the last page under, so a failed
29/// form knows where "back" is.
30pub const PREVIOUS_URL_KEY: &str = "_previous";
31
32/// Somewhere a value can be left for exactly one further request.
33///
34/// Implemented by the session. Anything reading through this trait works
35/// whether or not sessions are enabled — with no session, `flash` is a no-op
36/// and `take` finds nothing, which degrades a failed form to a plain `422`
37/// rather than to a panic.
38pub trait Flash: std::fmt::Debug + Send + Sync + 'static {
39    /// Leave a value for the next request, and no longer.
40    fn flash(&self, key: &str, value: Json);
41
42    /// Read a value and consume it.
43    fn take(&self, key: &str) -> Option<Json>;
44
45    /// Read a value and leave it in place.
46    ///
47    /// A template renders the errors and the old input separately, and a page
48    /// with two forms on it reads them more than once, so reading must not be
49    /// what removes them. The flash lifetime does that at the end of the
50    /// request instead.
51    fn peek(&self, key: &str) -> Option<Json>;
52}
53
54impl Request {
55    /// The flash store for this request, when something registered one.
56    pub fn flash(&self) -> Option<&Arc<dyn Flash>> {
57        self.extension::<Arc<dyn Flash>>()
58    }
59
60    /// The validation messages from the request that redirected here, as
61    /// `{"email": ["…"]}` — empty when the last request did not fail.
62    ///
63    /// Hand it to a template and read one field with a dotted path:
64    ///
65    /// ```ignore
66    /// req.view("posts/create", &ViewContext::new()
67    ///     .with("errors", req.errors())
68    ///     .with("old", req.old()))
69    /// ```
70    /// ```html
71    /// @if(errors.title)<p class="error">{{ errors.title.0 }}</p>@endif
72    /// <input name="title" value="{{ old.title }}">
73    /// ```
74    pub fn errors(&self) -> Json {
75        self.flash()
76            .and_then(|flash| flash.peek(ERRORS_KEY))
77            .unwrap_or_else(|| Json::object([] as [(&str, Json); 0]))
78    }
79
80    /// The input the failed request submitted, so a form can refill itself.
81    ///
82    /// Never contains a password: [`old_input_of`] leaves those out, because
83    /// re-filling a password field means putting the password back into HTML
84    /// that ends up in caches, in history and in screenshots.
85    pub fn old(&self) -> Json {
86        self.flash()
87            .and_then(|flash| flash.peek(OLD_INPUT_KEY))
88            .unwrap_or_else(|| Json::object([] as [(&str, Json); 0]))
89    }
90
91    /// One field of the old input, as a string. Empty when there is none.
92    pub fn old_field(&self, name: &str) -> String {
93        self.old().get(name).and_then(Json::as_str).unwrap_or_default().to_string()
94    }
95
96    /// Whether the last request left validation messages behind.
97    pub fn has_errors(&self) -> bool {
98        self.errors().as_object().is_some_and(|fields| !fields.is_empty())
99    }
100
101    /// Where a failed form should send the browser back to.
102    ///
103    /// The page the session last recorded, then the `Referer`, then `/`. Both
104    /// candidates are checked to be a path on this site: a full URL here would
105    /// be an open redirect, which is how a phishing link borrows a real domain.
106    pub fn previous_url(&self) -> String {
107        let recorded = self
108            .flash()
109            .and_then(|flash| flash.peek(PREVIOUS_URL_KEY))
110            .and_then(|value| value.as_str().map(str::to_string));
111
112        recorded
113            .or_else(|| self.header("referer").map(str::to_string))
114            .filter(|target| is_local_path(target))
115            .unwrap_or_else(|| "/".to_string())
116    }
117}
118
119/// Whether a redirect target is a path on this site rather than another origin.
120///
121/// `//evil.example` is the one that catches people out: it has no scheme, looks
122/// like a path, and a browser reads it as a protocol-relative URL to somebody
123/// else's host.
124pub fn is_local_path(target: &str) -> bool {
125    target.starts_with('/') && !target.starts_with("//") && !target.contains('\\')
126}
127
128/// The submitted fields worth keeping, as a JSON object.
129///
130/// Anything whose name looks like a secret is dropped. The check is on the
131/// name rather than the value because there is nothing about a password that
132/// makes it recognisable — and the cost of guessing wrong in this direction is
133/// only an empty field, while guessing wrong in the other puts a password in
134/// the HTML.
135pub fn old_input_of(request: &mut Request) -> Json {
136    let sensitive = |name: &str| {
137        let name = name.to_ascii_lowercase();
138        ["password", "secret", "token", "_token", "otp", "code", "pin", "cvv", "card"]
139            .iter()
140            .any(|needle| name.contains(needle))
141    };
142
143    let pairs: Vec<(String, Json)> = request
144        .form()
145        .iter()
146        .filter(|(name, _)| !sensitive(name))
147        .map(|(name, value)| (name.clone(), Json::from(value.as_str())))
148        .collect();
149
150    Json::object(pairs)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::method::Method;
157    use std::sync::Mutex;
158
159    /// A flash store standing in for a session.
160    #[derive(Debug, Default)]
161    struct Notebook(Mutex<std::collections::BTreeMap<String, Json>>);
162
163    impl Flash for Notebook {
164        fn flash(&self, key: &str, value: Json) {
165            self.0.lock().unwrap().insert(key.to_string(), value);
166        }
167        fn take(&self, key: &str) -> Option<Json> {
168            self.0.lock().unwrap().remove(key)
169        }
170        fn peek(&self, key: &str) -> Option<Json> {
171            self.0.lock().unwrap().get(key).cloned()
172        }
173    }
174
175    fn with_flash(request: Request, notebook: Notebook) -> Request {
176        let mut request = request;
177        let store: Arc<dyn Flash> = Arc::new(notebook);
178        request.extend(store);
179        request
180    }
181
182    #[test]
183    fn a_request_with_no_flash_reports_empty_rather_than_failing() {
184        let request = Request::new(Method::Get, "/posts/create");
185        assert!(!request.has_errors());
186        assert_eq!(request.errors().as_object().map(|f| f.len()), Some(0));
187        assert_eq!(request.old_field("title"), "");
188        assert_eq!(request.previous_url(), "/");
189    }
190
191    #[test]
192    fn errors_and_old_input_survive_to_the_next_request() {
193        let notebook = Notebook::default();
194        notebook.flash(
195            ERRORS_KEY,
196            Json::object([("title", Json::Array(vec![Json::from("The title field is required.")]))]),
197        );
198        notebook.flash(OLD_INPUT_KEY, Json::object([("body", Json::from("half a draft"))]));
199
200        let request = with_flash(Request::new(Method::Get, "/posts/create"), notebook);
201
202        assert!(request.has_errors());
203        assert_eq!(
204            request.errors().get("title.0").and_then(Json::as_str),
205            Some("The title field is required.")
206        );
207        assert_eq!(request.old_field("body"), "half a draft");
208        assert_eq!(request.old_field("title"), "", "a field with no old value is empty, not missing");
209    }
210
211    #[test]
212    fn reading_does_not_consume_them() {
213        // A page with two forms reads the bag more than once, and the second
214        // read must find what the first did.
215        let notebook = Notebook::default();
216        notebook.flash(ERRORS_KEY, Json::object([("a", Json::Array(vec![Json::from("x")]))]));
217        let request = with_flash(Request::new(Method::Get, "/"), notebook);
218
219        assert!(request.has_errors());
220        assert!(request.has_errors());
221    }
222
223    #[test]
224    fn old_input_keeps_what_was_typed_and_drops_what_was_secret() {
225        let mut request = Request::new(Method::Post, "/register")
226            .with_header("content-type", "application/x-www-form-urlencoded")
227            .with_body(
228                b"name=Ada&email=ada%40example.com&password=hunter2&\
229                  password_confirmation=hunter2&_token=abc&api_token=xyz&note=fine"
230                    .to_vec(),
231            );
232
233        let old = old_input_of(&mut request);
234        assert_eq!(old.get("name").and_then(Json::as_str), Some("Ada"));
235        assert_eq!(old.get("email").and_then(Json::as_str), Some("ada@example.com"));
236        assert_eq!(old.get("note").and_then(Json::as_str), Some("fine"));
237
238        for secret in ["password", "password_confirmation", "_token", "api_token"] {
239            assert!(old.get(secret).is_none(), "{secret} must not be kept");
240        }
241    }
242
243    #[test]
244    fn back_goes_to_the_recorded_page_then_the_referer_then_the_root() {
245        let notebook = Notebook::default();
246        notebook.flash(PREVIOUS_URL_KEY, Json::from("/posts/create"));
247        let request = with_flash(
248            Request::new(Method::Post, "/posts").with_header("referer", "/somewhere-else"),
249            notebook,
250        );
251        assert_eq!(request.previous_url(), "/posts/create", "the recorded page wins");
252
253        let no_record = Request::new(Method::Post, "/posts").with_header("referer", "/from-here");
254        assert_eq!(no_record.previous_url(), "/from-here");
255
256        let nothing = Request::new(Method::Post, "/posts");
257        assert_eq!(nothing.previous_url(), "/");
258    }
259
260    #[test]
261    fn a_referer_pointing_at_another_site_is_refused() {
262        // Sending the browser wherever the Referer says is an open redirect,
263        // and the header is written by whoever linked to the form.
264        for hostile in [
265            "https://evil.example/login",
266            "//evil.example/login",
267            "http://evil.example",
268            "/\\evil.example",
269        ] {
270            let request = Request::new(Method::Post, "/posts").with_header("referer", hostile);
271            assert_eq!(request.previous_url(), "/", "{hostile} should not be followed");
272        }
273    }
274
275    #[test]
276    fn a_local_path_is_recognised_and_a_foreign_one_is_not() {
277        assert!(is_local_path("/posts/create"));
278        assert!(is_local_path("/"));
279        assert!(!is_local_path("//evil.example"));
280        assert!(!is_local_path("https://evil.example"));
281        assert!(!is_local_path("posts/create"));
282        assert!(!is_local_path("/\\evil.example"));
283    }
284}