| ... | @@ -29,6 +29,29 @@ fn issue_csrf(document: &Html) -> Result<Option<String>> { | ... | @@ -29,6 +29,29 @@ fn issue_csrf(document: &Html) -> Result<Option<String>> { |
| 29 | } | 29 | } |
| 30 | } | 30 | } |
| 31 | | 31 | |
| | 32 | /// The verified r1616 build predates tokenized forms. Its cookie mutations are |
| | 33 | /// protected by the service's exact-Origin gate; never infer this from a missing token alone. |
| | 34 | fn tokenless_r1616(document: &Html) -> bool { |
| | 35 | if document.select(&Selector::parse("input[name=csrf_token]").unwrap()).next().is_some() { |
| | 36 | return false; |
| | 37 | } |
| | 38 | let links: Vec<_> = document.select(&Selector::parse("body#page-issue > footer.usa-footer .usa-footer__secondary-section a[href='https://astheno.software/shale/']").unwrap()).collect(); |
| | 39 | matches!(links.as_slice(), [link] if text(*link) == "shale r1616-ga87d2f5.zig.0.16.0") |
| | 40 | } |
| | 41 | |
| | 42 | fn prepare_issue_csrf(document: &Html, fields: &mut HashMap<String, String>) -> Result<()> { |
| | 43 | if tokenless_r1616(document) && !fields.contains_key("csrf_token") { |
| | 44 | return Ok(()); |
| | 45 | } |
| | 46 | if fields.get("csrf_token").is_none_or(|token| token.is_empty()) { |
| | 47 | return Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")); |
| | 48 | } |
| | 49 | if let Some(token) = issue_csrf(document)? { |
| | 50 | fields.insert("csrf_token".to_owned(), token); |
| | 51 | } |
| | 52 | Ok(()) |
| | 53 | } |
| | 54 | |
| 32 | pub struct Backend { | 55 | pub struct Backend { |
| 33 | pub(crate) origin: url::Url, | 56 | pub(crate) origin: url::Url, |
| 34 | http: reqwest::Client, | 57 | http: reqwest::Client, |
| ... | @@ -471,12 +494,7 @@ impl ServerHandler for Shale { | ... | @@ -471,12 +494,7 @@ impl ServerHandler for Shale { |
| 471 | } | 494 | } |
| 472 | } | 495 | } |
| 473 | if matches!(name, "comment_issue" | "set_issue_status") { | 496 | if matches!(name, "comment_issue" | "set_issue_status") { |
| 474 | if let Some(token) = issue_csrf(&document)? { | 497 | prepare_issue_csrf(&document, &mut fields)?; |
| 475 | if !fields.contains_key("csrf_token") { | | |
| 476 | return Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")); | | |
| 477 | } | | |
| 478 | fields.insert("csrf_token".to_owned(), token); | | |
| 479 | } | | |
| 480 | } | 498 | } |
| 481 | fields.insert("timezone".to_owned(), "UTC".to_owned()); | 499 | fields.insert("timezone".to_owned(), "UTC".to_owned()); |
| 482 | fields.insert("tzoffset".to_owned(), "+00:00".to_owned()); | 500 | fields.insert("tzoffset".to_owned(), "+00:00".to_owned()); |
| ... | @@ -826,6 +844,33 @@ mod tests { | ... | @@ -826,6 +844,33 @@ mod tests { |
| 826 | } | 844 | } |
| 827 | } | 845 | } |
| 828 | #[test] | 846 | #[test] |
| | 847 | fn tokenless_issue_forms_require_verified_r1616_footer_and_no_tokens_anywhere() { |
| | 848 | const FOOTER: &str = "<footer class='usa-footer usa-footer--slim'><div class=usa-footer__secondary-section><div>generated by <a href='https://astheno.software/shale/' class=usa-link>shale r1616-ga87d2f5.zig.0.16.0</a> (git 2.54.0)</div></div></footer>"; |
| | 849 | const FORMS: &str = "<form method=post><input type=hidden name=t value=status></form><form method=post><input type=hidden name=t value=comment></form><ul class=timeline><li class=comment><form method=post><input type=hidden name=t value=delete><input type=hidden name=id value=1></form></li></ul>"; |
| | 850 | let page = format!("<body id=page-issue>{FORMS}{FOOTER}</body>"); |
| | 851 | let mut fields = HashMap::from([("t".to_owned(), "status".to_owned()), ("status".to_owned(), "done".to_owned())]); |
| | 852 | let original = fields.clone(); |
| | 853 | prepare_issue_csrf(&Html::parse_document(&page), &mut fields).unwrap(); |
| | 854 | assert_eq!(fields, original); |
| | 855 | for bad in [ |
| | 856 | page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"), |
| | 857 | page.replace("r1616-ga87d2f5.zig.0.16.0", "r1616-other-build"), |
| | 858 | page.replace("https://astheno.software/shale/", "https://other.test/shale/"), |
| | 859 | format!("<body id=page-issue>{FORMS}<main class=markdown>{FOOTER}</main></body>"), |
| | 860 | format!("<body id=page-issue>{FORMS}{FOOTER}{FOOTER}</body>"), |
| | 861 | format!("<body id=page-issue>{FORMS}{FOOTER}<input name=csrf_token value=mixed></body>"), |
| | 862 | format!("<body id=page-issue>{FORMS}{FOOTER}<input name=csrf_token value=''></body>"), |
| | 863 | ] { |
| | 864 | assert!(!tokenless_r1616(&Html::parse_document(&bad))); |
| | 865 | assert!(prepare_issue_csrf(&Html::parse_document(&bad), &mut original.clone()).is_err()); |
| | 866 | assert!(prepare_issue_csrf(&Html::parse_document(&bad), &mut HashMap::from([("csrf_token".to_owned(), "selected".to_owned())])).is_err()); |
| | 867 | } |
| | 868 | // A newer or mixed page must also reject a missing/empty selected form token. |
| | 869 | let newer = Html::parse_document(&page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new")); |
| | 870 | assert!(prepare_issue_csrf(&newer, &mut HashMap::new()).is_err()); |
| | 871 | assert!(prepare_issue_csrf(&newer, &mut HashMap::from([("csrf_token".to_owned(), String::new())])).is_err()); |
| | 872 | } |
| | 873 | #[test] |
| 829 | fn repository_names_cannot_change_origin_or_path_segments() { | 874 | fn repository_names_cannot_change_origin_or_path_segments() { |
| 830 | let origin = url::Url::parse("https://shale.studio.test").unwrap(); | 875 | let origin = url::Url::parse("https://shale.studio.test").unwrap(); |
| 831 | for name in [ | 876 | for name in [ |