mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-14 18:59:52 +02:00
Replaces the Go server with a Rust one (axum, tokio, sqlx, feed-rs, ammonia, reqwest) serving the same REST/WebSocket API, so the React frontend is unchanged. - Same database: migrations keep the schema_migrations table and file names, Argon2id hashes and hashed session tokens created by the Go server keep working (users stay logged in), and feed item GUIDs are computed the same way, so existing articles are not re-ingested. - Same security model: SSRF-safe fetching (public addresses only, checked at resolution time and on every redirect), HTML sanitization, CSP and security headers, same-origin guard, WebSocket origin check, per-IP / per-user rate limits, trusted-proxy aware client IPs. - Fix: articles whose publication date lies in the future (ingested before the clamp existed) pinned the top of the list. The maintenance job now pulls such dates back at startup and daily. - Relative links and images in feed content are resolved against the article URL instead of being dropped. - Deploy: static musl binary in an Alpine image; the Unraid compose file uses pull_policy: always so the new image is applied on the next `docker compose up -d`. CI runs fmt, clippy and tests (including a PostgreSQL integration test) before building the image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fc1UFpB4V1MGWLxHAu3BMK
287 lines
8.9 KiB
Rust
287 lines
8.9 KiB
Rust
//! Cross-cutting request handling: auth, CSRF guard, headers, rate limits.
|
|
|
|
use std::net::{IpAddr, SocketAddr};
|
|
|
|
use axum::extract::{ConnectInfo, Request, State as AxState};
|
|
use axum::http::header::{self, HeaderMap, HeaderValue};
|
|
use axum::http::{Method, StatusCode};
|
|
use axum::middleware::Next;
|
|
use axum::response::{IntoResponse, Response};
|
|
use ipnet::IpNet;
|
|
|
|
use super::api::error;
|
|
use super::State;
|
|
use crate::models::{User, ROLE_ADMIN};
|
|
use crate::repo;
|
|
|
|
pub const SESSION_COOKIE: &str = "session_id";
|
|
|
|
pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
|
|
headers
|
|
.get_all(header::COOKIE)
|
|
.iter()
|
|
.filter_map(|v| v.to_str().ok())
|
|
.flat_map(|v| v.split(';'))
|
|
.filter_map(|kv| kv.trim().split_once('='))
|
|
.find(|(k, _)| *k == name)
|
|
.map(|(_, v)| v.trim_matches('"').to_string())
|
|
}
|
|
|
|
/// Resolves the session cookie once per request and stores the user in the
|
|
/// request extensions. Unauthenticated requests get 401.
|
|
pub async fn require_auth(
|
|
AxState(state): AxState<State>,
|
|
mut req: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
let Some(token) = cookie(req.headers(), SESSION_COOKIE).filter(|t| !t.is_empty()) else {
|
|
return error(StatusCode::UNAUTHORIZED, "Not authenticated");
|
|
};
|
|
match repo::user_by_token(&state.pool, &token).await {
|
|
Ok(Some(user)) => {
|
|
req.extensions_mut().insert(user);
|
|
next.run(req).await
|
|
}
|
|
Ok(None) => error(StatusCode::UNAUTHORIZED, "Session expired"),
|
|
Err(e) => {
|
|
tracing::error!("resolving session: {e}");
|
|
error(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"Failed to resolve session",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Restricts access to admins. Must run after `require_auth`.
|
|
pub async fn admin_only(req: Request, next: Next) -> Response {
|
|
match req.extensions().get::<User>() {
|
|
None => error(StatusCode::UNAUTHORIZED, "Not authenticated"),
|
|
Some(u) if u.role != ROLE_ADMIN => error(StatusCode::FORBIDDEN, "Admin access required"),
|
|
Some(_) => next.run(req).await,
|
|
}
|
|
}
|
|
|
|
fn is_trusted(proxies: &[IpNet], ip: IpAddr) -> bool {
|
|
let ip = unmap(ip);
|
|
proxies.iter().any(|p| p.contains(&ip))
|
|
}
|
|
|
|
fn unmap(ip: IpAddr) -> IpAddr {
|
|
match ip {
|
|
IpAddr::V6(v6) => v6
|
|
.to_ipv4_mapped()
|
|
.map(IpAddr::V4)
|
|
.unwrap_or(IpAddr::V6(v6)),
|
|
v4 => v4,
|
|
}
|
|
}
|
|
|
|
/// The peer address, honouring X-Forwarded-For only when the direct peer is a
|
|
/// trusted proxy. The right-most untrusted hop is used (not spoofable).
|
|
pub fn client_ip_from(proxies: &[IpNet], peer: Option<IpAddr>, headers: &HeaderMap) -> String {
|
|
let Some(peer) = peer else {
|
|
return String::new();
|
|
};
|
|
if !is_trusted(proxies, peer) {
|
|
return unmap(peer).to_string();
|
|
}
|
|
let xff = headers
|
|
.get_all("x-forwarded-for")
|
|
.iter()
|
|
.filter_map(|v| v.to_str().ok())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
for hop in xff.split(',').rev() {
|
|
match hop.trim().parse::<IpAddr>() {
|
|
Ok(ip) if !is_trusted(proxies, ip) => return unmap(ip).to_string(),
|
|
Ok(_) => continue,
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
unmap(peer).to_string()
|
|
}
|
|
|
|
pub fn client_ip(state: &State, req_ext: &axum::http::Extensions, headers: &HeaderMap) -> String {
|
|
let peer = req_ext.get::<ConnectInfo<SocketAddr>>().map(|c| c.0.ip());
|
|
client_ip_from(&state.config.trusted_proxies, peer, headers)
|
|
}
|
|
|
|
fn too_many() -> Response {
|
|
let mut resp = error(
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
"Too many requests. Please slow down.",
|
|
);
|
|
resp.headers_mut()
|
|
.insert(header::RETRY_AFTER, HeaderValue::from_static("60"));
|
|
resp
|
|
}
|
|
|
|
pub async fn auth_rate_limit(AxState(state): AxState<State>, req: Request, next: Next) -> Response {
|
|
let key = client_ip(&state, req.extensions(), req.headers());
|
|
if !state.auth_limiter.allow(&key) {
|
|
return too_many();
|
|
}
|
|
next.run(req).await
|
|
}
|
|
|
|
/// Per-user limit for expensive endpoints. Must run after `require_auth`.
|
|
pub async fn summary_rate_limit(
|
|
AxState(state): AxState<State>,
|
|
req: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
let key = match req.extensions().get::<User>() {
|
|
Some(u) => u.id.to_string(),
|
|
None => client_ip(&state, req.extensions(), req.headers()),
|
|
};
|
|
if !state.summary_limiter.allow(&key) {
|
|
return too_many();
|
|
}
|
|
next.run(req).await
|
|
}
|
|
|
|
/// Rejects state-changing requests coming from another site (CSRF defence in
|
|
/// depth on top of SameSite=Strict cookies).
|
|
pub async fn same_origin_guard(req: Request, next: Next) -> Response {
|
|
if matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS) {
|
|
return next.run(req).await;
|
|
}
|
|
let headers = req.headers();
|
|
let blocked = if let Some(site) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) {
|
|
site != "same-origin" && site != "none"
|
|
} else if let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
|
|
let host = headers
|
|
.get(header::HOST)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
!origin_matches_host(origin, host)
|
|
} else {
|
|
false
|
|
};
|
|
if blocked {
|
|
return error(StatusCode::FORBIDDEN, "Cross-site request blocked");
|
|
}
|
|
next.run(req).await
|
|
}
|
|
|
|
/// Compares an Origin header's host[:port] with the request Host.
|
|
pub fn origin_matches_host(origin: &str, host: &str) -> bool {
|
|
match url::Url::parse(origin) {
|
|
Ok(u) => origin_host(&u).eq_ignore_ascii_case(host),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
pub fn origin_host(u: &url::Url) -> String {
|
|
let host = u.host_str().unwrap_or("");
|
|
match u.port() {
|
|
Some(p) => format!("{host}:{p}"),
|
|
None => host.to_string(),
|
|
}
|
|
}
|
|
|
|
/// API responses are never cached unless a handler says otherwise.
|
|
pub async fn no_store(req: Request, next: Next) -> Response {
|
|
let mut resp = next.run(req).await;
|
|
resp.headers_mut()
|
|
.entry(header::CACHE_CONTROL)
|
|
.or_insert(HeaderValue::from_static("no-store"));
|
|
resp
|
|
}
|
|
|
|
/// Whether the session cookie should carry the Secure flag: COOKIE_SECURE
|
|
/// override, else TLS terminated by a proxy (X-Forwarded-Proto: https).
|
|
pub fn secure_cookie(state: &State, headers: &HeaderMap) -> bool {
|
|
if let Some(v) = state.config.cookie_secure {
|
|
return v;
|
|
}
|
|
headers
|
|
.get("x-forwarded-proto")
|
|
.and_then(|v| v.to_str().ok())
|
|
.is_some_and(|v| v.eq_ignore_ascii_case("https"))
|
|
}
|
|
|
|
const CSP: &str = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
|
|
img-src * data: blob:; media-src *; font-src 'self' data:; connect-src 'self'; \
|
|
frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; \
|
|
form-action 'self'; manifest-src 'self'; worker-src 'self'";
|
|
|
|
/// Defensive response headers, including a CSP that backstops the HTML
|
|
/// sanitizer for feed content.
|
|
pub async fn security_headers(
|
|
AxState(state): AxState<State>,
|
|
req: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
let secure = secure_cookie(&state, req.headers());
|
|
let mut resp = next.run(req).await.into_response();
|
|
let h = resp.headers_mut();
|
|
h.insert(
|
|
"x-content-type-options",
|
|
HeaderValue::from_static("nosniff"),
|
|
);
|
|
h.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
|
h.insert(
|
|
"referrer-policy",
|
|
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
|
);
|
|
h.insert(
|
|
"permissions-policy",
|
|
HeaderValue::from_static("geolocation=(), microphone=(), camera=()"),
|
|
);
|
|
h.insert("content-security-policy", HeaderValue::from_static(CSP));
|
|
h.insert(
|
|
"cross-origin-opener-policy",
|
|
HeaderValue::from_static("same-origin"),
|
|
);
|
|
if secure {
|
|
h.insert(
|
|
"strict-transport-security",
|
|
HeaderValue::from_static("max-age=31536000"),
|
|
);
|
|
}
|
|
resp
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::parse_prefixes;
|
|
|
|
fn xff(v: &str) -> HeaderMap {
|
|
let mut h = HeaderMap::new();
|
|
h.insert("x-forwarded-for", HeaderValue::from_str(v).unwrap());
|
|
h
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_spoofed_headers_without_trusted_proxy() {
|
|
let ip = client_ip_from(&[], Some("203.0.113.7".parse().unwrap()), &xff("1.2.3.4"));
|
|
assert_eq!(ip, "203.0.113.7");
|
|
}
|
|
|
|
#[test]
|
|
fn uses_rightmost_untrusted_hop() {
|
|
let proxies = parse_prefixes("10.0.0.0/8");
|
|
let ip = client_ip_from(
|
|
&proxies,
|
|
Some("10.0.0.2".parse().unwrap()),
|
|
&xff("6.6.6.6, 198.51.100.9, 10.0.0.3"),
|
|
);
|
|
assert_eq!(ip, "198.51.100.9");
|
|
}
|
|
|
|
#[test]
|
|
fn origin_check() {
|
|
assert!(origin_matches_host(
|
|
"http://reader.example:8080",
|
|
"reader.example:8080"
|
|
));
|
|
assert!(!origin_matches_host(
|
|
"https://evil.example",
|
|
"reader.example"
|
|
));
|
|
}
|
|
}
|