Merge pull request #711 from EdouardVanbelle/fix/front-end2end-test-race
This commit is contained in:
@@ -1600,6 +1600,33 @@ pub struct RateLimitConfig {
|
||||
pub lockout_max_failures: u32,
|
||||
/// Account lockout duration in seconds (default: 900 = 15 min)
|
||||
pub lockout_duration_secs: u64,
|
||||
|
||||
// ── Per-caller limits ────────────────────────────────────────────
|
||||
//
|
||||
// Keyed on `caller_id`, not IP: these guard an authenticated user
|
||||
// against exhausting a shared resource, whereas the three above
|
||||
// guard the unauthenticated front door against an attacker.
|
||||
//
|
||||
// The distinction matters when several actors share one identity —
|
||||
// an automated test suite, a CI job, an integration bot. They then
|
||||
// share one bucket, and a ceiling that is generous for one human
|
||||
// is easily exceeded. That is exactly what made the e2e suite emit
|
||||
// 81 × 429 in a single run, all as the same `admin`.
|
||||
/// Max user-profile lookups per caller per window (default: 60).
|
||||
///
|
||||
/// Guards the visibility query behind `GET /api/users/{id}`, which
|
||||
/// touches `access_grants` — the rate check runs BEFORE it so an
|
||||
/// attacker cannot exhaust it by hammering random UUIDs.
|
||||
pub user_profile_max_requests: u32,
|
||||
/// User-profile lookup window in seconds (default: 60).
|
||||
pub user_profile_window_secs: u64,
|
||||
/// Max delta-upload requests per caller per window (default: 240).
|
||||
///
|
||||
/// Generous for a real client — chunk PUTs carry up to 100 MB each
|
||||
/// — while stopping pin/negotiate floods.
|
||||
pub delta_upload_max_requests: u32,
|
||||
/// Delta-upload window in seconds (default: 60).
|
||||
pub delta_upload_window_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
@@ -1613,6 +1640,12 @@ impl Default for RateLimitConfig {
|
||||
refresh_window_secs: 60,
|
||||
lockout_max_failures: 5,
|
||||
lockout_duration_secs: 900,
|
||||
// Unchanged from the literals these replaced in `di.rs`, so
|
||||
// an operator who sets nothing sees no behaviour change.
|
||||
user_profile_max_requests: 60,
|
||||
user_profile_window_secs: 60,
|
||||
delta_upload_max_requests: 240,
|
||||
delta_upload_window_secs: 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3093,6 +3126,28 @@ impl AppConfig {
|
||||
{
|
||||
config.auth.rate_limit.refresh_window_secs = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_USER_PROFILE_MAX").map(|v| v.parse::<u32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.auth.rate_limit.user_profile_max_requests = val;
|
||||
}
|
||||
if let Ok(v) =
|
||||
env::var("OXICLOUD_RATE_LIMIT_USER_PROFILE_WINDOW_SECS").map(|v| v.parse::<u64>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.auth.rate_limit.user_profile_window_secs = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_DELTA_UPLOAD_MAX").map(|v| v.parse::<u32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.auth.rate_limit.delta_upload_max_requests = val;
|
||||
}
|
||||
if let Ok(v) =
|
||||
env::var("OXICLOUD_RATE_LIMIT_DELTA_UPLOAD_WINDOW_SECS").map(|v| v.parse::<u64>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.auth.rate_limit.delta_upload_window_secs = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_LOCKOUT_MAX_FAILURES").map(|v| v.parse::<u32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
@@ -3959,6 +4014,24 @@ pub fn default_config() -> AppConfig {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The per-caller limits moved from hardcoded literals in `di.rs` into
|
||||
/// config. The whole point was to add a knob, NOT to change behaviour
|
||||
/// for anyone who does not turn it — so the defaults must still be the
|
||||
/// values that were compiled in before.
|
||||
///
|
||||
/// Worth pinning because the failure is silent and asymmetric: too low
|
||||
/// and real users get 429s on file listings, too high and the
|
||||
/// `access_grants` visibility query loses the guard that stops an
|
||||
/// attacker exhausting it with random UUIDs.
|
||||
#[test]
|
||||
fn per_caller_rate_limit_defaults_match_the_previous_literals() {
|
||||
let rl = RateLimitConfig::default();
|
||||
assert_eq!(rl.user_profile_max_requests, 60);
|
||||
assert_eq!(rl.user_profile_window_secs, 60);
|
||||
assert_eq!(rl.delta_upload_max_requests, 240);
|
||||
assert_eq!(rl.delta_upload_window_secs, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_job_parses_name_and_flags() {
|
||||
let jobs = parse_startup_jobs(
|
||||
|
||||
+20
-10
@@ -2330,19 +2330,29 @@ impl AppServiceFactory {
|
||||
mock_email_sender: None, // populated below
|
||||
magic_link_invite_service: None, // populated below
|
||||
recipient_notification_service: None, // populated below alongside magic_link_invite_service
|
||||
// 60 lookups / minute / caller; cap at 50 000 tracked
|
||||
// callers to bound memory. The same limiter instance is
|
||||
// shared by every clone of AppState since it lives in an
|
||||
// Arc.
|
||||
// Per-caller limits, configurable since the hardcoded ceilings
|
||||
// had no escape hatch for deployments where several actors share
|
||||
// one identity — a CI suite running as a single `admin` shares
|
||||
// one bucket and trips a limit sized for one human. Defaults
|
||||
// match the literals these replaced, so an operator who sets
|
||||
// nothing sees no change. `OXICLOUD_RATE_LIMIT_USER_PROFILE_*` /
|
||||
// `OXICLOUD_RATE_LIMIT_DELTA_UPLOAD_*`.
|
||||
//
|
||||
// 50 000 tracked callers caps memory in both cases. The limiter
|
||||
// lives in an Arc, so every clone of AppState shares the counts.
|
||||
user_profile_rate_limiter: Arc::new(
|
||||
crate::interfaces::middleware::rate_limit::RateLimiter::new(60, 60, 50_000),
|
||||
crate::interfaces::middleware::rate_limit::RateLimiter::new(
|
||||
self.config.auth.rate_limit.user_profile_max_requests,
|
||||
self.config.auth.rate_limit.user_profile_window_secs,
|
||||
50_000,
|
||||
),
|
||||
),
|
||||
// Delta upload: 240 requests / minute / caller. Generous for a
|
||||
// real client (chunk PUTs carry up to 100 MB each) while
|
||||
// stopping pin/negotiate floods; 50 000 tracked callers bound
|
||||
// the memory like the other limiters.
|
||||
delta_upload_rate_limiter: Arc::new(
|
||||
crate::interfaces::middleware::rate_limit::RateLimiter::new(240, 60, 50_000),
|
||||
crate::interfaces::middleware::rate_limit::RateLimiter::new(
|
||||
self.config.auth.rate_limit.delta_upload_max_requests,
|
||||
self.config.auth.rate_limit.delta_upload_window_secs,
|
||||
50_000,
|
||||
),
|
||||
),
|
||||
// PR 12 — per-sharer email-invite ceiling: caller_id-keyed.
|
||||
// Defends against a compromised account spamming external
|
||||
|
||||
@@ -237,6 +237,34 @@ pub async fn require_dpop_layer(
|
||||
// window at the verifier means this one still
|
||||
// succeeded, but we still want the client onto
|
||||
// the nonce path immediately.
|
||||
//
|
||||
// Logged because this returns a 401, and every
|
||||
// rejection owes the operator a line saying why —
|
||||
// otherwise the access log shows a bare
|
||||
// `client_error status=401` with nothing to
|
||||
// explain it, while the client's successful retry
|
||||
// stays invisible under the default `http=warn`
|
||||
// access-log filter. That combination reads like a
|
||||
// real auth failure and is not one.
|
||||
//
|
||||
// NOT `dpop.verify_failed`: the proof verified
|
||||
// cleanly, and counting a routine bootstrap as a
|
||||
// verification failure would put a per-session
|
||||
// event into the metric operators watch for
|
||||
// attacks. `nonce_stale` above keeps that event
|
||||
// name — it is long-standing and log aggregators
|
||||
// key off it — but this path is new, so it gets
|
||||
// the accurate one. The challenge itself is
|
||||
// already counted centrally by
|
||||
// `nonce_challenge_response`.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "dpop.nonce_challenged",
|
||||
reason = "nonce_missing",
|
||||
method = %method,
|
||||
htu = %htu,
|
||||
"👮🏻♂️ DPoP proof carried no nonce — issuing challenge (expected once per client scope; the client harvests the nonce and retries)",
|
||||
);
|
||||
return nonce_challenge_response(&nonce_service);
|
||||
}
|
||||
Some(n) => n,
|
||||
|
||||
Reference in New Issue
Block a user