mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
security: add per-IP sliding-window rate limit on POST /api/auth/login
5 attempts / 15 min per IP. Excess attempts get 429 + Retry-After. Client IP read from X-Forwarded-For with loopback fallback. In-memory; prune inline on each check. Refactored app() into app_with_state() + thin wrapper. Uses parking_lot::Mutex (no unwrap, no poisoning).
This commit is contained in:
parent
3b3b0d7207
commit
a8e52bdf71
3 changed files with 189 additions and 18 deletions
|
|
@ -32,3 +32,4 @@ base64 = "0.22"
|
||||||
argon2 = { version = "0.5", features = ["std"] }
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
rand = { version = "0.8", features = ["std"] }
|
rand = { version = "0.8", features = ["std"] }
|
||||||
axum-extra = { version = "0.9", features = ["cookie"] }
|
axum-extra = { version = "0.9", features = ["cookie"] }
|
||||||
|
parking_lot = "0.12"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ chrono.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
|
parking_lot.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,14 @@
|
||||||
// GET/PATCH /api/admin/instance-settings
|
// GET/PATCH /api/admin/instance-settings
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
use std::{path::PathBuf, sync::Arc};
|
use std::{
|
||||||
|
collections::{HashMap, VecDeque},
|
||||||
|
net::IpAddr,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
use archivr_core::{archive, capture, database};
|
use archivr_core::{archive, capture, database};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
|
@ -39,10 +46,14 @@ pub use crate::auth::{AuthUser, ROLE_ADMIN, ROLE_GUEST, ROLE_OWNER, ROLE_USER};
|
||||||
use axum_extra::extract::CookieJar;
|
use axum_extra::extract::CookieJar;
|
||||||
use rusqlite::OptionalExtension;
|
use rusqlite::OptionalExtension;
|
||||||
|
|
||||||
|
const LOGIN_WINDOW: Duration = Duration::from_secs(15 * 60);
|
||||||
|
const LOGIN_MAX_ATTEMPTS: usize = 5;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
registry: Arc<ServerRegistry>,
|
registry: Arc<ServerRegistry>,
|
||||||
pub auth_db_path: Arc<std::path::PathBuf>,
|
pub auth_db_path: Arc<std::path::PathBuf>,
|
||||||
|
pub login_attempts: Arc<Mutex<HashMap<IpAddr, VecDeque<Instant>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, Default)]
|
#[derive(Debug, serde::Deserialize, Default)]
|
||||||
|
|
@ -112,11 +123,59 @@ async fn security_headers(req: Request, next: Next) -> Response {
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router {
|
async fn login_rate_limit(
|
||||||
let state = AppState {
|
State(state): State<AppState>,
|
||||||
registry: Arc::new(registry),
|
req: Request,
|
||||||
auth_db_path: Arc::new(auth_db_path),
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
if req.method() != axum::http::Method::POST || req.uri().path() != "/api/auth/login" {
|
||||||
|
return next.run(req).await;
|
||||||
|
}
|
||||||
|
let ip = extract_client_ip(&req);
|
||||||
|
let retry_after = {
|
||||||
|
let mut map = state.login_attempts.lock();
|
||||||
|
let attempts = map.entry(ip).or_default();
|
||||||
|
let now = Instant::now();
|
||||||
|
attempts.retain(|t| now.duration_since(*t) < LOGIN_WINDOW);
|
||||||
|
if attempts.len() >= LOGIN_MAX_ATTEMPTS {
|
||||||
|
let oldest = *attempts.front().unwrap();
|
||||||
|
let elapsed = now.duration_since(oldest).as_secs() as i64;
|
||||||
|
let secs = (LOGIN_WINDOW.as_secs() as i64 - elapsed).max(1);
|
||||||
|
Some(secs)
|
||||||
|
} else {
|
||||||
|
attempts.push_back(now);
|
||||||
|
None
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
if let Some(secs) = retry_after {
|
||||||
|
return (
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
[(
|
||||||
|
axum::http::header::RETRY_AFTER,
|
||||||
|
axum::http::HeaderValue::from_str(&secs.to_string()).unwrap(),
|
||||||
|
)],
|
||||||
|
axum::Json(serde_json::json!({
|
||||||
|
"error": "rate_limited",
|
||||||
|
"retry_after_secs": secs,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_client_ip(req: &Request) -> IpAddr {
|
||||||
|
req.headers()
|
||||||
|
.get("x-forwarded-for")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.split(',').next())
|
||||||
|
.and_then(|s| s.trim().parse().ok())
|
||||||
|
.unwrap_or(IpAddr::from([127, 0, 0, 1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the Axum router from a pre-constructed `AppState`.
|
||||||
|
/// Use this in tests that need to share state across multiple `oneshot` calls.
|
||||||
|
pub fn app_with_state(state: AppState) -> Router {
|
||||||
let static_dir = static_dir();
|
let static_dir = static_dir();
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|
@ -166,28 +225,51 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router
|
||||||
.route("/api/admin/users/:uid/roles", axum::routing::post(admin_assign_role))
|
.route("/api/admin/users/:uid/roles", axum::routing::post(admin_assign_role))
|
||||||
.route("/api/admin/users/:uid/roles/:role_slug", axum::routing::delete(admin_remove_role))
|
.route("/api/admin/users/:uid/roles/:role_slug", axum::routing::delete(admin_remove_role))
|
||||||
.route("/api/admin/roles", get(admin_list_roles).post(admin_create_role))
|
.route("/api/admin/roles", get(admin_list_roles).post(admin_create_role))
|
||||||
.route("/api/admin/instance-settings",
|
.route(
|
||||||
get(get_instance_settings_handler).patch(update_instance_settings_handler))
|
"/api/admin/instance-settings",
|
||||||
.route("/api/archives/:archive_id/collections",
|
get(get_instance_settings_handler).patch(update_instance_settings_handler),
|
||||||
get(list_collections_handler).post(create_collection_handler))
|
)
|
||||||
.route("/api/archives/:archive_id/collections/:coll_uid",
|
.route(
|
||||||
|
"/api/archives/:archive_id/collections",
|
||||||
|
get(list_collections_handler).post(create_collection_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/archives/:archive_id/collections/:coll_uid",
|
||||||
get(get_collection_handler)
|
get(get_collection_handler)
|
||||||
.patch(patch_collection_handler)
|
.patch(patch_collection_handler)
|
||||||
.delete(delete_collection_handler))
|
.delete(delete_collection_handler),
|
||||||
.route("/api/archives/:archive_id/collections/:coll_uid/entries",
|
)
|
||||||
post(add_entry_to_collection_handler))
|
.route(
|
||||||
.route("/api/archives/:archive_id/collections/:coll_uid/entries/:entry_uid",
|
"/api/archives/:archive_id/collections/:coll_uid/entries",
|
||||||
|
post(add_entry_to_collection_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/archives/:archive_id/collections/:coll_uid/entries/:entry_uid",
|
||||||
delete(remove_entry_from_collection_handler)
|
delete(remove_entry_from_collection_handler)
|
||||||
.patch(update_entry_visibility_handler))
|
.patch(update_entry_visibility_handler),
|
||||||
.route("/api/archives/:archive_id/entries/:entry_uid/collections",
|
)
|
||||||
get(list_entry_collections_handler))
|
.route(
|
||||||
|
"/api/archives/:archive_id/entries/:entry_uid/collections",
|
||||||
|
get(list_entry_collections_handler),
|
||||||
|
)
|
||||||
.nest_service("/assets", ServeDir::new(static_dir.join("assets")))
|
.nest_service("/assets", ServeDir::new(static_dir.join("assets")))
|
||||||
.fallback_service(ServeFile::new(static_dir.join("index.html")))
|
.fallback_service(ServeFile::new(static_dir.join("index.html")))
|
||||||
.layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard))
|
.layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard))
|
||||||
|
.layer(axum::middleware::from_fn_with_state(state.clone(), login_rate_limit))
|
||||||
.layer(axum::middleware::from_fn(security_headers))
|
.layer(axum::middleware::from_fn(security_headers))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the Axum router, constructing `AppState` from the given registry and auth DB path.
|
||||||
|
pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router {
|
||||||
|
let state = AppState {
|
||||||
|
registry: Arc::new(registry),
|
||||||
|
auth_db_path: Arc::new(auth_db_path),
|
||||||
|
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
};
|
||||||
|
app_with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
fn static_dir() -> PathBuf {
|
fn static_dir() -> PathBuf {
|
||||||
std::env::var_os("ARCHIVR_STATIC_DIR")
|
std::env::var_os("ARCHIVR_STATIC_DIR")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
|
|
@ -2185,4 +2267,91 @@ mod tests {
|
||||||
assert!(response.headers().get("permissions-policy").is_some());
|
assert!(response.headers().get("permissions-policy").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_rate_limit_blocks_after_max_attempts() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let auth_path = dir.path().join("auth.sqlite");
|
||||||
|
{
|
||||||
|
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
|
||||||
|
archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap();
|
||||||
|
}
|
||||||
|
let state = AppState {
|
||||||
|
registry: Arc::new(ServerRegistry {
|
||||||
|
archives: vec![],
|
||||||
|
bind: None,
|
||||||
|
auth_db_path: None,
|
||||||
|
}),
|
||||||
|
auth_db_path: Arc::new(auth_path),
|
||||||
|
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
};
|
||||||
|
let bad_creds = serde_json::json!({ "username": "nobody", "password": "wrong" });
|
||||||
|
for _ in 0..LOGIN_MAX_ATTEMPTS {
|
||||||
|
let resp = app_with_state(state.clone())
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/auth/login")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-forwarded-for", "10.0.0.1")
|
||||||
|
.body(json_body(&bad_creds))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "attempt within limit should reach handler");
|
||||||
|
}
|
||||||
|
let resp = app_with_state(state)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/auth/login")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-forwarded-for", "10.0.0.1")
|
||||||
|
.body(json_body(&bad_creds))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS, "attempt over limit must be 429");
|
||||||
|
assert!(resp.headers().contains_key("retry-after"), "429 must carry Retry-After header");
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert_eq!(body["error"], "rate_limited");
|
||||||
|
assert!(body["retry_after_secs"].as_i64().unwrap() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_rate_limit_does_not_affect_other_routes() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let auth_path = dir.path().join("auth.sqlite");
|
||||||
|
{
|
||||||
|
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
|
||||||
|
archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap();
|
||||||
|
}
|
||||||
|
let state = AppState {
|
||||||
|
registry: Arc::new(ServerRegistry { archives: vec![], bind: None, auth_db_path: None }),
|
||||||
|
auth_db_path: Arc::new(auth_path),
|
||||||
|
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
};
|
||||||
|
let bad_creds = serde_json::json!({ "username": "x", "password": "y" });
|
||||||
|
for _ in 0..LOGIN_MAX_ATTEMPTS {
|
||||||
|
app_with_state(state.clone())
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/auth/login")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-forwarded-for", "10.0.0.2")
|
||||||
|
.body(json_body(&bad_creds))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let resp = app_with_state(state)
|
||||||
|
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK, "/health must be unaffected");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue