use bytes::Bytes;
use crate::request::ForgeResponse;
pub static XLOGIN_JS: &[u8] = include_bytes!("../assets/xlogin.js");
#[must_use]
pub fn build_csp(csp_connect: &[String]) -> String {
let mut connect = String::from("'self'");
for o in csp_connect {
let rest = o
.strip_prefix("https://")
.or_else(|| o.strip_prefix("http://"));
let ok = rest.is_some_and(|r| {
!r.is_empty()
&& !r
.chars()
.any(|c| c.is_whitespace() || matches!(c, ';' | '\'' | '"'))
});
if ok {
connect.push(' ');
connect.push_str(o);
}
}
format!(
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self' https: data:; \
script-src 'unsafe-inline' 'self' https://esm.sh; connect-src {connect}; \
base-uri 'none'; form-action 'self'"
)
}
#[must_use]
pub fn build_strict_csp(csp_connect: &[String], nonce: &str) -> String {
let nonce: String = nonce
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-'))
.collect();
build_csp(csp_connect).replace(
"script-src 'unsafe-inline' 'self' https://esm.sh;",
&format!("script-src 'self' 'nonce-{nonce}';"),
)
}
#[must_use]
pub fn xlogin_response(method: &str) -> ForgeResponse {
if !(method.eq_ignore_ascii_case("GET") || method.eq_ignore_ascii_case("HEAD")) {
return ForgeResponse {
status: 405,
headers: vec![("allow".into(), "GET, HEAD".into())],
body: Bytes::new(),
};
}
let body = if method.eq_ignore_ascii_case("HEAD") {
Bytes::new()
} else {
Bytes::from_static(XLOGIN_JS)
};
ForgeResponse {
status: 200,
headers: vec![
(
"content-type".into(),
"application/javascript; charset=utf-8".into(),
),
(
"cache-control".into(),
"public, max-age=31536000, immutable".into(),
),
("x-content-type-options".into(), "nosniff".into()),
],
body,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn served_bytes_are_identical_and_headers_match() {
let r = xlogin_response("GET");
assert_eq!(r.status, 200);
assert_eq!(r.body.as_ref(), XLOGIN_JS);
let h = |n: &str| {
r.headers
.iter()
.find(|(k, _)| k == n)
.map(|(_, v)| v.clone())
.unwrap_or_default()
};
assert!(h("content-type").starts_with("application/javascript"));
assert!(h("cache-control").contains("immutable"));
assert_eq!(h("x-content-type-options"), "nosniff");
assert!(String::from_utf8_lossy(XLOGIN_JS).contains("version 0.0.15"));
assert_eq!(xlogin_response("HEAD").body.len(), 0);
assert_eq!(xlogin_response("PUT").status, 405);
}
#[test]
fn csp_admits_widget_and_keeps_connect_self() {
let csp = build_csp(&[]);
assert!(csp.contains("script-src 'unsafe-inline' 'self' https://esm.sh"));
assert!(csp.contains("connect-src 'self';"));
assert!(csp.contains("form-action 'self'"));
assert!(csp.starts_with("default-src 'none';"));
}
#[test]
fn strict_csp_has_no_inline_or_cdn_and_sanitises_the_nonce() {
let csp = build_strict_csp(&[], "ab12'; script-src *");
assert!(csp.contains("'nonce-ab12script-src'"), "{csp}");
assert!(!csp.contains("esm.sh"));
assert!(!csp.contains("unsafe-inline' 'self'"));
assert!(
csp.contains("style-src 'unsafe-inline'"),
"styles unchanged"
);
assert!(csp.contains("connect-src 'self';"));
}
#[test]
fn csp_connect_origins_are_filtered() {
let csp = build_csp(&[
"https://idp.example".into(),
"http://localhost:3000".into(),
"ftp://x".into(),
"https://a;b".into(),
"https://a'b".into(),
"https://".into(),
]);
assert!(csp.contains("connect-src 'self' https://idp.example http://localhost:3000;"));
assert!(!csp.contains("ftp"));
assert!(!csp.contains("a;b"));
}
}