authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 00:35:08-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
log3fe6530e54cd7a6121d486f6e88f2ca4ee46c9f4
tree3cd7fcbff2927a735e438cedc51bbc0791bfe058
parentf9a6035160c394d1c95a4d8b341ee5a582b73e83
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Support token-free issue forms in verified Shale r1616


1 files changed, 51 insertions(+), 6 deletions(-)

dashboard/src/shale.rs+51-6
...@@ -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}
3131
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.
34fn 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
42fn 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
32pub struct Backend {55pub 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 [