diff --git a/crates/rm-handlers/src/common.rs b/crates/rm-handlers/src/common.rs index b41defa..8f1a7b8 100644 --- a/crates/rm-handlers/src/common.rs +++ b/crates/rm-handlers/src/common.rs @@ -1,10 +1,15 @@ //! Helpers every resource handler shares. Today's set: //! - [`AppState`] — shared axum `State` //! - [`wrap_in_doc`] — HTML shell until G1 lands the master template +//! (escapes the title — XSS guard, codex P1 on PR #10) //! - [`record_id_to_u64`] — URL → render-kit u64 adapter for //! record-keyed resources (Issue) //! - [`identifier_to_u64`] — same adapter for slug-keyed resources //! (Project) and any future resource whose URL key is a string +//! - [`html_escape`] — minimal `& < > " '` escape, no extra dep +//! - [`encode_path_segment`] — percent-encode a single URL path +//! segment so slug-keyed resources tolerate `# ? /` in the key +//! (codex P2 on PR #13) //! - [`HandlerError`] — the per-resource error enum; factored out //! when W3 landed as the third caller (Plan §1.6 "three points //! form a line") @@ -27,16 +32,91 @@ pub struct AppState { pub store: Store, } +/// HTML-escape the five attribute-/content-significant chars +/// (`& < > " '`). No external dep — askama isn't a direct dep of +/// rm-handlers (it's transitive via ogar-render-askama; pulling it +/// in here re-triggers the askama_axum macro-expansion issue we hit +/// in W0.1). +/// +/// Used for any user-controlled string that lands in handler-built +/// HTML *outside* the askama kit's render path — most importantly +/// document titles ([`wrap_in_doc`]) and the pre-rendered +/// `headline_html` passed to `render_detail` (the kit treats that +/// parameter as raw HTML per the cell-template contract; user data +/// must be escaped before it goes in). +#[must_use] +pub fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + other => out.push(other), + } + } + out +} + +/// Percent-encode a single URL path segment. Encodes everything +/// that isn't an RFC-3986 unreserved char (`A-Z a-z 0-9 - . _ ~`) +/// or the path-segment-safe `:` `@`. +/// +/// Project identifiers + role names are arbitrary admin labels; +/// without encoding a `#`/`?`/`/` in the name breaks the URL +/// (codex P2 on PR #13). No external `percent-encoding` dep — +/// the rule is small. +#[must_use] +pub fn encode_path_segment(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + let safe = matches!( + b, + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b':' | b'@' + ); + if safe { + out.push(b as char); + } else { + out.push('%'); + out.push(hex_nibble(b >> 4)); + out.push(hex_nibble(b & 0x0F)); + } + } + out +} + +#[inline] +fn hex_nibble(n: u8) -> char { + match n { + 0..=9 => (b'0' + n) as char, + 10..=15 => (b'A' + n - 10) as char, + _ => unreachable!(), + } +} + /// Wrap a fragment in a minimal HTML5 document. G1 (Plan §5 GUI /// chrome) will replace this with the master `base.askama` template /// — at that point this fn deletes and every handler uses /// `askama_axum::Template` directly. +/// +/// The `title` parameter is **HTML-escaped** — handlers pass +/// user-controlled strings (issue subjects, project names) and the +/// raw value can't reach the `
hello
")); } + #[test] + fn wrap_in_doc_escapes_xss_in_title() { + // Codex P1 on PR #10: a subject like `", "body
"); + assert!( + !html.contains("".to_string(), + description: None, + }) + .await + .unwrap(); + let rid = inserted.id.unwrap(); + let key = rid.key.to_sql(); + let app = router(AppState { store }); + let res = app + .oneshot( + Request::builder() + .uri(format!("/issues/{key}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!( + !s.contains("