use std::{
fmt,
fmt::Write as _,
net::{IpAddr, Ipv4Addr, SocketAddr},
};
use super::error::ServeError;
pub const LOOPBACK_BIND_ADDRESS: &str = "127.0.0.1";
pub const LOOPBACK_IPV4: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 1);
pub fn bind_address(port: u16) -> SocketAddr {
SocketAddr::new(IpAddr::V4(LOOPBACK_IPV4), port)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NonLoopbackBindError {
bound: SocketAddr,
}
impl fmt::Display for NonLoopbackBindError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"refusing to serve on {}: rapidstruct serve only listens on {LOOPBACK_BIND_ADDRESS}; \
there is deliberately no --host option (docs/05 §7.1)",
self.bound
)
}
}
impl std::error::Error for NonLoopbackBindError {}
pub fn assert_loopback(bound: SocketAddr) -> Result<SocketAddr, NonLoopbackBindError> {
if bound.ip() == IpAddr::V4(LOOPBACK_IPV4) {
return Ok(bound);
}
Err(NonLoopbackBindError { bound })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalOrigin {
port: u16,
allowed_hosts: Vec<String>,
allowed_origins: Vec<String>,
}
impl LocalOrigin {
pub fn for_port(port: u16) -> Self {
let mut allowed_hosts = vec![format!("127.0.0.1:{port}"), format!("localhost:{port}")];
let mut allowed_origins = vec![
format!("http://127.0.0.1:{port}"),
format!("http://localhost:{port}"),
];
if port == 80 {
allowed_hosts.push("127.0.0.1".to_string());
allowed_hosts.push("localhost".to_string());
allowed_origins.push("http://127.0.0.1".to_string());
allowed_origins.push("http://localhost".to_string());
}
Self {
port,
allowed_hosts,
allowed_origins,
}
}
pub fn from_bound(bound: SocketAddr) -> Result<Self, NonLoopbackBindError> {
Ok(Self::for_port(assert_loopback(bound)?.port()))
}
pub fn port(&self) -> u16 {
self.port
}
pub fn allowed_hosts(&self) -> &[String] {
&self.allowed_hosts
}
pub fn allowed_origins(&self) -> &[String] {
&self.allowed_origins
}
pub fn primary_origin(&self) -> &str {
&self.allowed_origins[0]
}
pub fn check_host(&self, host: Option<&str>) -> Result<(), ServeError> {
let Some(host) = host else {
return Err(ServeError::BadHost);
};
let candidate = host.trim().to_ascii_lowercase();
if self
.allowed_hosts
.iter()
.any(|allowed| allowed == &candidate)
{
return Ok(());
}
Err(ServeError::BadHost)
}
pub fn check_origin(
&self,
origin: Option<&str>,
is_state_changing: bool,
) -> Result<(), ServeError> {
if !is_state_changing {
return Ok(());
}
let Some(origin) = origin else {
return Err(ServeError::BadOrigin);
};
let candidate = origin.trim().to_ascii_lowercase();
if self
.allowed_origins
.iter()
.any(|allowed| allowed == &candidate)
{
return Ok(());
}
Err(ServeError::BadOrigin)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ServeToken(String);
impl ServeToken {
pub fn generate() -> Result<Self, RandomError> {
Ok(Self(random_hex(TOKEN_BYTES)?))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn matches(&self, candidate: &str) -> bool {
let expected = self.0.as_bytes();
let given = candidate.as_bytes();
let mut diff = (expected.len() ^ given.len()) as u64;
for (index, byte) in expected.iter().enumerate() {
let other = given.get(index).copied().unwrap_or(0);
diff |= u64::from(byte ^ other);
}
diff == 0
}
}
impl fmt::Debug for ServeToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ServeToken(<redacted {} hex chars>)", self.0.len())
}
}
const TOKEN_BYTES: usize = 32;
const NONCE_BYTES: usize = 16;
pub fn generate_nonce() -> Result<String, RandomError> {
random_hex(NONCE_BYTES)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RandomError {
Bcrypt { status: i32 },
Empty,
}
impl fmt::Display for RandomError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Bcrypt { status } => write!(
f,
"BCryptGenRandom (bcrypt.dll) failed with NTSTATUS {status:#x}; rapidstruct serve \
refuses to start instead of falling back to weak entropy"
),
Self::Empty => write!(f, "cannot generate an empty random string"),
}
}
}
impl std::error::Error for RandomError {}
pub const RANDOM_SOURCE: &str =
"windows:BCryptGenRandom(bcrypt.dll, BCRYPT_USE_SYSTEM_PREFERRED_RNG)";
pub fn random_source() -> &'static str {
RANDOM_SOURCE
}
const BCRYPT_USE_SYSTEM_PREFERRED_RNG: u32 = 0x0000_0002;
#[link(name = "bcrypt")]
unsafe extern "system" {
fn BCryptGenRandom(
algorithm: *mut core::ffi::c_void,
buffer: *mut u8,
length: u32,
flags: u32,
) -> i32;
}
fn fill_random(buffer: &mut [u8]) -> Result<(), RandomError> {
if buffer.is_empty() {
return Err(RandomError::Empty);
}
let length = u32::try_from(buffer.len()).map_err(|_| RandomError::Empty)?;
let status = unsafe {
BCryptGenRandom(
std::ptr::null_mut(),
buffer.as_mut_ptr(),
length,
BCRYPT_USE_SYSTEM_PREFERRED_RNG,
)
};
if status != 0 {
return Err(RandomError::Bcrypt { status });
}
Ok(())
}
pub fn random_hex(byte_len: usize) -> Result<String, RandomError> {
random_hex_with(byte_len, &fill_random)
}
fn random_hex_with(
byte_len: usize,
fill: &dyn Fn(&mut [u8]) -> Result<(), RandomError>,
) -> Result<String, RandomError> {
if byte_len == 0 {
return Err(RandomError::Empty);
}
let mut bytes = vec![0_u8; byte_len];
fill(&mut bytes)?;
let mut out = String::with_capacity(byte_len * 2);
for byte in &bytes {
write!(out, "{byte:02x}").expect("writing into a String cannot fail");
}
Ok(out)
}
pub const SECURITY_HEADERS: [(&str, &str); 3] = [
("X-Content-Type-Options", "nosniff"),
("Referrer-Policy", "no-referrer"),
("Cache-Control", "no-store"),
];
pub const CSP_NONCE_PLACEHOLDER: &str = "__CSP_NONCE__";
pub const TOKEN_PLACEHOLDER: &str = "__SRV_TOKEN__";
pub fn inject(html: &str, nonce: &str, token: &str) -> String {
debug_assert!(
!nonce.is_empty() && !token.is_empty(),
"an empty nonce/token would pass the residual scan while shipping an unusable page"
);
html.replace(CSP_NONCE_PLACEHOLDER, nonce)
.replace(TOKEN_PLACEHOLDER, token)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PlaceholderError {
pub placeholder: &'static str,
}
impl fmt::Display for PlaceholderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"the static page still contains the placeholder `{}` after injection",
self.placeholder
)
}
}
impl std::error::Error for PlaceholderError {}
pub fn assert_no_placeholders_left(html: &str) -> Result<(), PlaceholderError> {
for placeholder in [CSP_NONCE_PLACEHOLDER, TOKEN_PLACEHOLDER] {
if html.contains(placeholder) {
return Err(PlaceholderError { placeholder });
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use super::{
CSP_NONCE_PLACEHOLDER, LOOPBACK_BIND_ADDRESS, LocalOrigin, RANDOM_SOURCE, RandomError,
SECURITY_HEADERS, ServeToken, TOKEN_PLACEHOLDER, assert_loopback,
assert_no_placeholders_left, bind_address, fill_random, generate_nonce, inject, random_hex,
random_hex_with, random_source,
};
fn policy() -> LocalOrigin {
LocalOrigin::for_port(8760)
}
fn strings(items: &[&str]) -> Vec<String> {
items.iter().map(|item| (*item).to_string()).collect()
}
#[test]
fn the_bind_address_is_hardcoded_loopback_ipv4() {
assert_eq!(LOOPBACK_BIND_ADDRESS, "127.0.0.1");
let bound = bind_address(8760);
assert_eq!(bound.ip(), IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert_eq!(bound.port(), 8760);
assert_eq!(bound.to_string(), "127.0.0.1:8760");
for port in [1_u16, 80, 8760, 65_535] {
assert_eq!(bind_address(port).ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
}
}
#[test]
fn the_startup_assertion_accepts_only_127_0_0_1() {
let accepted = assert_loopback(bind_address(8760)).expect("hardcoded loopback");
assert_eq!(accepted.to_string(), "127.0.0.1:8760");
let lan = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5)), 8760);
let error = assert_loopback(lan).expect_err("a LAN address must be refused");
assert!(error.to_string().contains("192.168.1.5:8760"), "{error}");
assert!(error.to_string().contains("--host"), "{error}");
let any = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 8760);
assert!(assert_loopback(any).is_err(), "0.0.0.0 must be refused");
let v6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8760);
assert!(assert_loopback(v6).is_err(), "[::1] must be refused");
let other_loopback = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 8760);
assert!(assert_loopback(other_loopback).is_err());
}
#[test]
fn allowed_hosts_are_ipv4_only_and_bound_to_the_actual_port() {
let policy = policy();
assert_eq!(
policy.allowed_hosts(),
strings(&["127.0.0.1:8760", "localhost:8760"]).as_slice()
);
assert_eq!(policy.primary_origin(), "http://127.0.0.1:8760");
assert_eq!(
LocalOrigin::from_bound(bind_address(9000))
.expect("loopback")
.allowed_hosts(),
strings(&["127.0.0.1:9000", "localhost:9000"]).as_slice()
);
for accepted in [
"127.0.0.1:8760",
"localhost:8760",
"LOCALHOST:8760",
" 127.0.0.1:8760 ",
] {
assert!(
policy.check_host(Some(accepted)).is_ok(),
"accepted: {accepted}"
);
}
let rejected = [
"[::1]:8760", "127.0.0.1:8761", "localhost:8761", "127.0.0.1", "localhost", "127.0.0.1:87600", "127.0.0.10:8760", "evil.example:8760", "127.0.0.1:8760.evil", "xn--127-0-0-1:8760", "",
];
for host in rejected {
let error = policy
.check_host(Some(host))
.expect_err(&format!("{host} must be rejected"));
assert_eq!(error.status_code(), 421, "host: {host}");
assert_eq!(error.code(), "bad_host", "host: {host}");
}
let error = policy
.check_host(None)
.expect_err("a missing Host must be rejected");
assert_eq!(error.status_code(), 421);
}
#[test]
fn port_80_also_accepts_the_bare_host_because_browsers_omit_the_default_port() {
let policy = LocalOrigin::for_port(80);
assert!(policy.check_host(Some("127.0.0.1")).is_ok());
assert!(policy.check_host(Some("localhost")).is_ok());
assert!(policy.check_host(Some("127.0.0.1:80")).is_ok());
assert!(policy.check_host(Some("127.0.0.1:81")).is_err());
assert!(policy.check_origin(Some("http://127.0.0.1"), true).is_ok());
assert_eq!(policy.primary_origin(), "http://127.0.0.1:80");
}
#[test]
fn origin_is_required_and_must_match_exactly_on_state_changing_methods() {
let policy = policy();
assert!(
policy
.check_origin(Some("http://127.0.0.1:8760"), true)
.is_ok()
);
assert!(
policy
.check_origin(Some("http://localhost:8760"), true)
.is_ok()
);
assert!(
policy
.check_origin(Some("HTTP://127.0.0.1:8760"), true)
.is_ok(),
"the scheme and host are case-insensitive"
);
let rejected = [
"null", "http://127.0.0.1:8761", "http://127.0.0.1", "https://127.0.0.1:8760", "http://evil.example:8760", "http://127.0.0.1:8760/", "http://127.0.0.1:8760.evil", "http://[::1]:8760", "",
];
for origin in rejected {
let error = policy
.check_origin(Some(origin), true)
.expect_err(&format!("{origin} must be rejected"));
assert_eq!(error.status_code(), 403, "origin: {origin}");
assert_eq!(error.code(), "bad_origin", "origin: {origin}");
}
let error = policy
.check_origin(None, true)
.expect_err("a missing Origin must be rejected on POST");
assert_eq!(error.status_code(), 403);
assert_eq!(error.code(), "bad_origin");
}
#[test]
fn read_requests_do_not_require_an_origin() {
let policy = policy();
assert!(
policy.check_origin(None, false).is_ok(),
"GET/HEAD must not be blocked on a missing Origin"
);
assert!(policy.check_origin(Some("null"), false).is_ok());
assert!(
policy
.check_origin(Some("http://evil.example"), false)
.is_ok(),
"reads are guarded by the token and the Host check, not by Origin"
);
assert!(
policy
.check_origin(Some("http://127.0.0.1:8760"), false)
.is_ok()
);
}
#[test]
fn the_token_is_random_per_run_and_compared_exactly() {
let token = ServeToken::generate().expect("BCryptGenRandom must be available");
let other = ServeToken::generate().expect("BCryptGenRandom must be available");
assert_eq!(token.as_str().len(), 64, "256 bit of hex");
assert_ne!(
token.as_str(),
other.as_str(),
"each run gets its own token"
);
assert!(
token.as_str().chars().all(|c| c.is_ascii_hexdigit()),
"the token must be hex: {}",
token.as_str()
);
assert_eq!(random_source(), RANDOM_SOURCE);
assert!(RANDOM_SOURCE.contains("BCryptGenRandom"), "{RANDOM_SOURCE}");
assert!(token.matches(token.as_str()));
assert!(!token.matches(other.as_str()));
assert!(!token.matches(""), "an empty candidate must never match");
assert!(
!token.matches(&token.as_str()[..63]),
"a prefix must not match"
);
assert!(
!token.matches(&format!("{}0", token.as_str())),
"a longer value must not match"
);
let mut first_flipped = token.as_str().to_string();
let first = if first_flipped.starts_with('0') {
'1'
} else {
'0'
};
first_flipped.replace_range(0..1, &first.to_string());
assert!(!token.matches(&first_flipped));
let mut last_flipped = token.as_str().to_string();
let last = if last_flipped.ends_with('0') {
'1'
} else {
'0'
};
last_flipped.replace_range(63..64, &last.to_string());
assert!(!token.matches(&last_flipped));
}
#[test]
fn a_failing_entropy_source_is_propagated_and_never_falls_back() {
let failing = |_buffer: &mut [u8]| {
Err(RandomError::Bcrypt {
status: 0xC000_0001u32 as i32,
})
};
let error = random_hex_with(32, &failing).expect_err("a failing RNG must fail generation");
assert_eq!(
error,
RandomError::Bcrypt {
status: 0xC000_0001u32 as i32
}
);
let text = error.to_string();
assert!(text.contains("BCryptGenRandom"), "{text}");
assert!(text.contains("refuses to start"), "{text}");
assert_eq!(random_hex_with(0, &failing), Err(RandomError::Empty));
assert_eq!(random_hex(0), Err(RandomError::Empty));
let mut buffer = [0_u8; 32];
fill_random(&mut buffer).expect("the OS CSPRNG must be available");
assert!(buffer.iter().any(|byte| *byte != 0));
}
#[test]
fn the_token_is_redacted_in_debug_output() {
let token = ServeToken::generate().expect("the OS CSPRNG must be available");
let text = format!("{token:?}");
assert!(
!text.contains(token.as_str()),
"debug output leaked the token"
);
assert!(text.contains("redacted"), "{text}");
}
#[test]
fn nonces_are_random_and_hex() {
let first = generate_nonce().expect("the OS CSPRNG must be available");
let second = generate_nonce().expect("the OS CSPRNG must be available");
assert_eq!(first.len(), 32, "128 bit of hex");
assert!(first.chars().all(|c| c.is_ascii_hexdigit()), "{first}");
assert_ne!(first, second);
}
#[test]
fn the_security_headers_are_the_documented_set() {
assert_eq!(
SECURITY_HEADERS,
[
("X-Content-Type-Options", "nosniff"),
("Referrer-Policy", "no-referrer"),
("Cache-Control", "no-store"),
]
);
}
#[test]
fn the_placeholder_literals_are_exactly_the_two_documented_strings() {
assert_eq!(CSP_NONCE_PLACEHOLDER, "__CSP_NONCE__");
assert_eq!(TOKEN_PLACEHOLDER, "__SRV_TOKEN__");
assert_ne!(CSP_NONCE_PLACEHOLDER, TOKEN_PLACEHOLDER);
}
#[test]
fn inject_replaces_both_placeholders_and_leaves_nothing_behind() {
let page = "<script nonce=\"__CSP_NONCE__\">let t='__SRV_TOKEN__';</script>\
<style nonce=\"__CSP_NONCE__\"></style>";
let nonce = generate_nonce().expect("the OS CSPRNG must be available");
let token = ServeToken::generate().expect("the OS CSPRNG must be available");
let injected = inject(page, &nonce, token.as_str());
assert_eq!(
injected.matches(&nonce).count(),
2,
"the page has 4 nonce slots, 2 here"
);
assert_eq!(injected.matches(token.as_str()).count(), 1);
assert!(!injected.contains(CSP_NONCE_PLACEHOLDER));
assert!(!injected.contains(TOKEN_PLACEHOLDER));
assert_no_placeholders_left(&injected).expect("no placeholder may survive injection");
}
#[test]
fn assert_no_placeholders_left_names_the_leftover_placeholder() {
let error = assert_no_placeholders_left("<p>__CSP_NONCE__</p>")
.expect_err("a left nonce placeholder must fail the startup scan");
assert_eq!(error.placeholder, CSP_NONCE_PLACEHOLDER);
assert!(error.to_string().contains("__CSP_NONCE__"), "{error}");
let error = assert_no_placeholders_left("<p>__SRV_TOKEN__</p>")
.expect_err("a left token placeholder must fail the startup scan");
assert_eq!(error.placeholder, TOKEN_PLACEHOLDER);
assert!(
assert_no_placeholders_left("<p>__csp_nonce__</p>").is_ok(),
"case matters"
);
assert!(assert_no_placeholders_left("<p>plain page</p>").is_ok());
}
}