From 3fe6530e54cd7a6121d486f6e88f2ca4ee46c9f4 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 5 Oct 2026 00:35:08 -0700 Subject: [PATCH] Support token-free issue forms in verified Shale r1616 --- dashboard/src/shale.rs | 57 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/dashboard/src/shale.rs b/dashboard/src/shale.rs index 2ee38bff8766c99f1237f95c71c679bdb77e765f..4e739b8e47d2997791e72bb942cd1f1e1a8f74a7 100644 --- a/dashboard/src/shale.rs +++ b/dashboard/src/shale.rs @@ -29,6 +29,29 @@ fn issue_csrf(document: &Html) -> Result> { } } +/// The verified r1616 build predates tokenized forms. Its cookie mutations are +/// protected by the service's exact-Origin gate; never infer this from a missing token alone. +fn tokenless_r1616(document: &Html) -> bool { + if document.select(&Selector::parse("input[name=csrf_token]").unwrap()).next().is_some() { + return false; + } + 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(); + matches!(links.as_slice(), [link] if text(*link) == "shale r1616-ga87d2f5.zig.0.16.0") +} + +fn prepare_issue_csrf(document: &Html, fields: &mut HashMap) -> Result<()> { + if tokenless_r1616(document) && !fields.contains_key("csrf_token") { + return Ok(()); + } + if fields.get("csrf_token").is_none_or(|token| token.is_empty()) { + return Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")); + } + if let Some(token) = issue_csrf(document)? { + fields.insert("csrf_token".to_owned(), token); + } + Ok(()) +} + pub struct Backend { pub(crate) origin: url::Url, http: reqwest::Client, @@ -471,12 +494,7 @@ impl ServerHandler for Shale { } } if matches!(name, "comment_issue" | "set_issue_status") { - if let Some(token) = issue_csrf(&document)? { - if !fields.contains_key("csrf_token") { - return Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")); - } - fields.insert("csrf_token".to_owned(), token); - } + prepare_issue_csrf(&document, &mut fields)?; } fields.insert("timezone".to_owned(), "UTC".to_owned()); fields.insert("tzoffset".to_owned(), "+00:00".to_owned()); @@ -826,6 +844,33 @@ mod tests { } } #[test] + fn tokenless_issue_forms_require_verified_r1616_footer_and_no_tokens_anywhere() { + const FOOTER: &str = ""; + const FORMS: &str = "
"; + let page = format!("{FORMS}{FOOTER}"); + let mut fields = HashMap::from([("t".to_owned(), "status".to_owned()), ("status".to_owned(), "done".to_owned())]); + let original = fields.clone(); + prepare_issue_csrf(&Html::parse_document(&page), &mut fields).unwrap(); + assert_eq!(fields, original); + for bad in [ + page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"), + page.replace("r1616-ga87d2f5.zig.0.16.0", "r1616-other-build"), + page.replace("https://astheno.software/shale/", "https://other.test/shale/"), + format!("{FORMS}
{FOOTER}
"), + format!("{FORMS}{FOOTER}{FOOTER}"), + format!("{FORMS}{FOOTER}"), + format!("{FORMS}{FOOTER}"), + ] { + assert!(!tokenless_r1616(&Html::parse_document(&bad))); + assert!(prepare_issue_csrf(&Html::parse_document(&bad), &mut original.clone()).is_err()); + assert!(prepare_issue_csrf(&Html::parse_document(&bad), &mut HashMap::from([("csrf_token".to_owned(), "selected".to_owned())])).is_err()); + } + // A newer or mixed page must also reject a missing/empty selected form token. + let newer = Html::parse_document(&page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new")); + assert!(prepare_issue_csrf(&newer, &mut HashMap::new()).is_err()); + assert!(prepare_issue_csrf(&newer, &mut HashMap::from([("csrf_token".to_owned(), String::new())])).is_err()); + } + #[test] fn repository_names_cannot_change_origin_or_path_segments() { let origin = url::Url::parse("https://shale.studio.test").unwrap(); for name in [ -- 2.54.0