| ... | ... | @@ -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 | 55 | pub struct Backend { |
| 33 | 56 | pub(crate) origin: url::Url, |
| 34 | 57 | http: reqwest::Client, |
| ... | ... | @@ -471,12 +494,7 @@ impl ServerHandler for Shale { |
| 471 | 494 | } |
| 472 | 495 | } |
| 473 | 496 | if matches!(name, "comment_issue" | "set_issue_status") { |
| 474 | | if let Some(token) = issue_csrf(&document)? { |
| 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 | | } |
| 497 | prepare_issue_csrf(&document, &mut fields)?; |
| 480 | 498 | } |
| 481 | 499 | fields.insert("timezone".to_owned(), "UTC".to_owned()); |
| 482 | 500 | fields.insert("tzoffset".to_owned(), "+00:00".to_owned()); |
| ... | ... | @@ -826,6 +844,33 @@ mod tests { |
| 826 | 844 | } |
| 827 | 845 | } |
| 828 | 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 | 874 | fn repository_names_cannot_change_origin_or_path_segments() { |
| 830 | 875 | let origin = url::Url::parse("https://shale.studio.test").unwrap(); |
| 831 | 876 | for name in [ |