security helpers
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::http::{HeaderValue, Request, Response, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::body::Body;
|
||||
|
||||
// per-key rate limit state
|
||||
struct RateWindow {
|
||||
count: u32,
|
||||
window_start: Instant,
|
||||
window_secs: u64,
|
||||
}
|
||||
|
||||
static RATE_LIMITS: std::sync::LazyLock<Mutex<HashMap<String, RateWindow>>> =
|
||||
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
const RATE_LIMIT_MAX_ENTRIES: usize = 4096;
|
||||
|
||||
pub fn check_rate_limit(session: &str, key: &str, max: u32, window_secs: u64) -> Result<(), StatusCode> {
|
||||
check_bucket(&format!("{}:{}", session, key), max, window_secs)
|
||||
}
|
||||
|
||||
pub fn check_rate_limit_ip(ip: &str, key: &str, max: u32, window_secs: u64) -> Result<(), StatusCode> {
|
||||
check_bucket(&format!("ip:{}:{}", ip, key), max, window_secs)
|
||||
}
|
||||
|
||||
fn check_bucket(bucket: &str, max: u32, window_secs: u64) -> Result<(), StatusCode> {
|
||||
let mut map = RATE_LIMITS.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
|
||||
// bound memory: prune expired windows occasionally instead of growing
|
||||
// forever with per-session and per-puzzle bucket keys
|
||||
if map.len() > RATE_LIMIT_MAX_ENTRIES {
|
||||
map.retain(|_, w| now.duration_since(w.window_start).as_secs() < w.window_secs);
|
||||
}
|
||||
|
||||
let entry = map.entry(bucket.to_string()).or_insert(RateWindow { count: 0, window_start: now, window_secs });
|
||||
if now.duration_since(entry.window_start).as_secs() >= window_secs {
|
||||
entry.count = 1;
|
||||
entry.window_start = now;
|
||||
Ok(())
|
||||
} else if entry.count < max {
|
||||
entry.count += 1;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StatusCode::TOO_MANY_REQUESTS)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_client_ip(headers: &axum::http::HeaderMap) -> String {
|
||||
// proxy headers are only meaningful when an explicit trusted reverse proxy
|
||||
// is in front; otherwise a client can supply whatever it wants
|
||||
if std::env::var("TRUST_PROXY").as_deref() != Ok("true") {
|
||||
return "unknown".to_string();
|
||||
}
|
||||
headers.get("x-forwarded-for")
|
||||
.or_else(|| headers.get("x-real-ip"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("unknown")
|
||||
.split(',').next().unwrap_or("unknown").trim().to_string()
|
||||
}
|
||||
|
||||
pub async fn security_headers(
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
let mut response = next.run(request).await;
|
||||
let headers = response.headers_mut();
|
||||
|
||||
headers.insert("X-Content-Type-Options", HeaderValue::from_static("nosniff"));
|
||||
headers.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
|
||||
headers.insert("X-XSS-Protection", HeaderValue::from_static("1; mode=block"));
|
||||
headers.insert("Referrer-Policy", HeaderValue::from_static("strict-origin-when-cross-origin"));
|
||||
headers.insert("Permissions-Policy", HeaderValue::from_static(
|
||||
"camera=(), microphone=(), geolocation=(), payment=()"
|
||||
));
|
||||
headers.insert("Content-Security-Policy", HeaderValue::from_static(
|
||||
"default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'"
|
||||
));
|
||||
|
||||
if std::env::var("HSTS").as_deref() == Ok("true") {
|
||||
headers.insert(
|
||||
"Strict-Transport-Security",
|
||||
HeaderValue::from_static("max-age=31536000; includeSubDomains"),
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
pub fn is_internal_url(url: &str) -> bool {
|
||||
let url_lower = url.to_lowercase();
|
||||
let host = url_lower
|
||||
.strip_prefix("http://").or_else(|| url_lower.strip_prefix("https://"))
|
||||
.unwrap_or(&url_lower)
|
||||
.split('/').next().unwrap_or("")
|
||||
.split(':').next().unwrap_or("");
|
||||
|
||||
// strip IPv6 brackets
|
||||
let host = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
|
||||
|
||||
// block non-dotted numeric hosts (decimal IP like 2130706433 = 127.0.0.1)
|
||||
if !host.is_empty() && host.chars().all(|c| c.is_ascii_digit()) { return true; }
|
||||
|
||||
// block octal IPs (0177.0.0.1 = 127.0.0.1)
|
||||
if host.starts_with("0") && host.contains('.') && host.chars().all(|c| c.is_ascii_digit() || c == '.') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// block URLs with @ (http://user@internal/path)
|
||||
if host.contains('@') { return true; }
|
||||
|
||||
// loopback
|
||||
host == "localhost"
|
||||
|| host.starts_with("127.") // full 127.0.0.0/8 range
|
||||
|| host == "::1"
|
||||
|| host.starts_with("::ffff:127.") // IPv4-mapped IPv6 loopback
|
||||
// current network
|
||||
|| host.starts_with("0.") // 0.0.0.0/8
|
||||
|| host == "0.0.0.0"
|
||||
// RFC 1918 private ranges
|
||||
|| host.starts_with("10.") // 10.0.0.0/8
|
||||
|| is_172_private(host) // 172.16.0.0/12
|
||||
|| host.starts_with("192.168.") // 192.168.0.0/16
|
||||
// carrier-grade NAT (CVE-2026-22245 - Mastodon missed this)
|
||||
|| is_cgnat(host) // 100.64.0.0/10
|
||||
// link-local
|
||||
|| host.starts_with("169.254.") // 169.254.0.0/16
|
||||
// benchmark testing
|
||||
|| host.starts_with("198.18.") // 198.18.0.0/15
|
||||
|| host.starts_with("198.19.")
|
||||
// IPv6 private/local
|
||||
|| host.starts_with("fc") // fc00::/7 unique local
|
||||
|| host.starts_with("fd")
|
||||
|| host.starts_with("fe80") // fe80::/10 link-local
|
||||
|| host.starts_with("::ffff:10.") // IPv4-mapped private
|
||||
|| host.starts_with("::ffff:192.168.")
|
||||
|| host.starts_with("::ffff:0.")
|
||||
// DNS rebinding / special hostnames
|
||||
|| host.ends_with(".local")
|
||||
|| host.ends_with(".internal")
|
||||
|| host.ends_with(".localhost")
|
||||
|| host == "metadata.google.internal"
|
||||
// cloud metadata endpoints
|
||||
|| host == "169.254.169.254"
|
||||
|| host == "metadata.google.internal"
|
||||
|| host == "100.100.100.200" // Alibaba cloud metadata
|
||||
}
|
||||
|
||||
fn is_172_private(host: &str) -> bool {
|
||||
if !host.starts_with("172.") { return false; }
|
||||
let second = host.strip_prefix("172.").and_then(|s| s.split('.').next())
|
||||
.and_then(|s| s.parse::<u8>().ok()).unwrap_or(0);
|
||||
(16..=31).contains(&second)
|
||||
}
|
||||
|
||||
fn is_cgnat(host: &str) -> bool {
|
||||
if !host.starts_with("100.") { return false; }
|
||||
let second = host.strip_prefix("100.").and_then(|s| s.split('.').next())
|
||||
.and_then(|s| s.parse::<u8>().ok()).unwrap_or(0);
|
||||
(64..=127).contains(&second)
|
||||
}
|
||||
|
||||
pub fn sanitize_html(input: &str) -> String {
|
||||
input
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// Redirect policy that refuses to follow hops into internal or loopback
|
||||
/// addresses, so an initial URL check cannot be undone by a redirect chain.
|
||||
pub fn safe_redirect_policy() -> reqwest::redirect::Policy {
|
||||
reqwest::redirect::Policy::custom(|attempt| {
|
||||
if is_internal_url(attempt.url().as_str()) {
|
||||
attempt.stop()
|
||||
} else {
|
||||
attempt.follow()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Sanitize ActivityPub note content for rendering on HTML permalink pages.
|
||||
/// Only the tags the daily-post template actually emits survive.
|
||||
pub fn sanitize_note_html(html: &str) -> String {
|
||||
let tags: std::collections::HashSet<&str> = ["a", "br", "p", "span"].into_iter().collect();
|
||||
ammonia::Builder::default()
|
||||
.tags(tags)
|
||||
.link_rel(Some("nofollow noopener noreferrer"))
|
||||
.clean(html)
|
||||
.to_string()
|
||||
}
|
||||
Reference in New Issue
Block a user