use std::net::IpAddr;
use std::num::NonZeroU32;
use std::sync::{Arc, RwLock as StdRwLock};
use std::time::Duration;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{HeaderValue, Response, StatusCode, header};
use axum::middleware::Next;
use axum::response::IntoResponse;
use governor::clock::{Clock, DefaultClock};
use governor::{DefaultKeyedRateLimiter, RateLimiter};
use ipnetwork::IpNetwork;
use tokio::sync::RwLock;
use tower_governor::key_extractor::KeyExtractor;
use utoipa_axum::router::OpenApiRouter;
use vta_config::{AppConfig, ServerConfig};
use vti_common::rate_limit::TrustedProxyKeyExtractor;
pub const RATE_LIMIT_SOURCE_HEADER: &str = vta_sdk::rate_limit::SOURCE_HEADER;
pub const RATE_LIMIT_SOURCE_VTA: &str = "vta";
pub const RATE_LIMIT_SCOPE_HEADER: &str = "x-rate-limit-scope";
const LEGACY_RATE_LIMIT_AFTER_HEADER: &str = vta_sdk::rate_limit::LEGACY_RETRY_AFTER_HEADER;
pub(crate) const AUTH_INTERVAL_SECS: u64 = 5;
pub(crate) const AUTH_BURST: u32 = 10;
pub(crate) const DID_LOG_INTERVAL_SECS: u64 = 1;
pub(crate) const DID_LOG_BURST: u32 = 60;
pub const MAX_INTERVAL_SECS: u64 = 3600;
pub const MAX_BURST: u32 = 10_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Limiter {
Auth,
AuthAuthenticate,
AuthRefresh,
DidLog,
BackupBlob,
}
impl Limiter {
pub const fn name(self) -> &'static str {
match self {
Limiter::Auth => "auth",
Limiter::AuthAuthenticate => "auth-authenticate",
Limiter::AuthRefresh => "auth-refresh",
Limiter::DidLog => "did-log",
Limiter::BackupBlob => "backup-blob",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Quota {
interval_secs: u64,
burst: u32,
}
impl Quota {
pub fn new(interval_secs: u64, burst: u32) -> Self {
Self {
interval_secs: interval_secs.max(1),
burst: burst.max(1),
}
}
pub fn interval_secs(self) -> u64 {
self.interval_secs
}
pub fn burst(self) -> u32 {
self.burst
}
fn to_governor(self) -> governor::Quota {
governor::Quota::with_period(Duration::from_secs(self.interval_secs))
.expect("interval is clamped to >= 1 s, so the period is non-zero")
.allow_burst(NonZeroU32::new(self.burst).expect("burst is clamped to >= 1"))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RateLimits {
auth: Quota,
did_log: Quota,
}
impl RateLimits {
pub fn new(auth: Quota, did_log: Quota) -> Self {
Self { auth, did_log }
}
pub fn from_server_config(server: &ServerConfig) -> Self {
Self::new(
Quota::new(server.rate_limit_interval_secs, server.rate_limit_burst),
Quota::new(
server.did_log_rate_limit_interval_secs,
server.did_log_rate_limit_burst,
),
)
}
pub fn quota(self, limiter: Limiter) -> Quota {
match limiter {
Limiter::Auth
| Limiter::AuthAuthenticate
| Limiter::AuthRefresh
| Limiter::BackupBlob => self.auth,
Limiter::DidLog => self.did_log,
}
}
}
impl Default for RateLimits {
fn default() -> Self {
Self::new(
Quota::new(AUTH_INTERVAL_SECS, AUTH_BURST),
Quota::new(DID_LOG_INTERVAL_SECS, DID_LOG_BURST),
)
}
}
#[derive(Clone)]
pub enum QuotaSource {
Live(Arc<RwLock<AppConfig>>),
Fixed(RateLimits),
}
impl QuotaSource {
async fn current(&self, limiter: Limiter) -> Quota {
match self {
QuotaSource::Live(config) => {
RateLimits::from_server_config(&config.read().await.server).quota(limiter)
}
QuotaSource::Fixed(limits) => limits.quota(limiter),
}
}
}
struct Buckets {
quota: Quota,
limiter: DefaultKeyedRateLimiter<IpAddr>,
}
impl Buckets {
fn new(quota: Quota) -> Arc<Self> {
Arc::new(Self {
quota,
limiter: RateLimiter::keyed(quota.to_governor()),
})
}
}
struct LimiterState {
limiter: Limiter,
extractor: TrustedProxyKeyExtractor,
source: QuotaSource,
buckets: StdRwLock<Arc<Buckets>>,
}
impl LimiterState {
fn new(limiter: Limiter, trust_xff_cidrs: &[IpNetwork], source: &QuotaSource) -> Self {
let initial = match source {
QuotaSource::Fixed(limits) => limits.quota(limiter),
QuotaSource::Live(_) => RateLimits::default().quota(limiter),
};
Self {
limiter,
extractor: TrustedProxyKeyExtractor::new(trust_xff_cidrs.to_vec()),
source: source.clone(),
buckets: StdRwLock::new(Buckets::new(initial)),
}
}
fn buckets_for(&self, quota: Quota) -> Arc<Buckets> {
{
let current = self.buckets.read().unwrap_or_else(|e| e.into_inner());
if current.quota == quota {
return Arc::clone(¤t);
}
}
let mut current = self.buckets.write().unwrap_or_else(|e| e.into_inner());
if current.quota != quota {
tracing::info!(
limiter = self.limiter.name(),
interval_secs = quota.interval_secs(),
burst = quota.burst(),
"rate limit quota changed; limiter buckets reset"
);
*current = Buckets::new(quota);
}
Arc::clone(¤t)
}
fn client_ip(&self, req: &Request) -> Option<IpAddr> {
self.extractor.extract(req).ok()
}
}
pub(super) fn apply<S>(
router: OpenApiRouter<S>,
limiter: Limiter,
trust_xff_cidrs: &[IpNetwork],
source: &QuotaSource,
) -> OpenApiRouter<S>
where
S: Clone + Send + Sync + 'static,
{
let state = Arc::new(LimiterState::new(limiter, trust_xff_cidrs, source));
router.layer(axum::middleware::from_fn_with_state(state, enforce))
}
pub(super) fn apply_trust_tasks<S>(
router: OpenApiRouter<S>,
trust_xff_cidrs: &[IpNetwork],
source: &QuotaSource,
) -> OpenApiRouter<S>
where
S: Clone + Send + Sync + 'static,
{
let state = Arc::new(TrustTaskLimiters {
auth: LimiterState::new(Limiter::Auth, trust_xff_cidrs, source),
authenticate: LimiterState::new(Limiter::AuthAuthenticate, trust_xff_cidrs, source),
refresh: LimiterState::new(Limiter::AuthRefresh, trust_xff_cidrs, source),
});
router.layer(axum::middleware::from_fn_with_state(
state,
enforce_trust_tasks,
))
}
struct TrustTaskLimiters {
auth: LimiterState,
authenticate: LimiterState,
refresh: LimiterState,
}
impl TrustTaskLimiters {
fn for_type(&self, type_uri: Option<&str>) -> &LimiterState {
use vta_sdk::trust_tasks::{
TASK_AUTH_AUTHENTICATE_0_2, TASK_AUTH_AUTHENTICATE_0_3, TASK_AUTH_REFRESH_0_2,
};
match type_uri {
Some(t) if t == TASK_AUTH_AUTHENTICATE_0_2 || t == TASK_AUTH_AUTHENTICATE_0_3 => {
&self.authenticate
}
Some(t) if t == TASK_AUTH_REFRESH_0_2 => &self.refresh,
_ => &self.auth,
}
}
}
async fn enforce_trust_tasks(
State(limiters): State<Arc<TrustTaskLimiters>>,
req: Request,
next: Next,
) -> Response<Body> {
if vti_common::auth::extractor::presents_credential(req.headers()) {
return next.run(req).await;
}
let limit = crate::trust_tasks::PUBLIC_TASK_BODY_LIMIT;
let labelled = req
.headers()
.get(TRUST_TASK_HEADER)
.and_then(|v| v.to_str().ok())
.map(|t| limiters.for_type(Some(t)));
if let Some(state) = labelled
&& let Some(refused) = refusal(state, state.client_ip(&req)).await
{
return refused;
}
let declared_too_large = req
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.is_some_and(|n| n > limit as u64);
let (parts, body) = req.into_parts();
let bytes = if declared_too_large {
None
} else {
axum::body::to_bytes(body, limit).await.ok()
};
let Some(bytes) = bytes else {
let refused = Request::from_parts(parts, Body::empty());
if labelled.is_none()
&& let Some(limited) = refusal(&limiters.auth, limiters.auth.client_ip(&refused)).await
{
return limited;
}
return vti_common::error::AppError::Validation(format!(
"an anonymous request is limited to {limit} bytes"
))
.into_response();
};
let type_uri = crate::trust_tasks::ceremony::peek_type_uri(&bytes);
let actual = limiters.for_type(type_uri.as_deref());
let req = Request::from_parts(parts, Body::from(bytes));
if labelled.map(|l| l.limiter) != Some(actual.limiter)
&& let Some(limited) = refusal(actual, actual.client_ip(&req)).await
{
return limited;
}
next.run(req).await
}
const TRUST_TASK_HEADER: &str = "trust-task";
async fn enforce(
State(state): State<Arc<LimiterState>>,
req: Request,
next: Next,
) -> Response<Body> {
charge(&state, req, |req| next.run(req)).await
}
async fn charge<F, Fut>(state: &LimiterState, req: Request, serve: F) -> Response<Body>
where
F: FnOnce(Request) -> Fut,
Fut: std::future::Future<Output = Response<Body>>,
{
match refusal(state, state.client_ip(&req)).await {
None => serve(req).await,
Some(refused) => refused,
}
}
async fn refusal(state: &LimiterState, ip: Option<IpAddr>) -> Option<Response<Body>> {
let Some(ip) = ip else {
return Some(
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from("Unable To Extract Key!"))
.expect("static response always builds"),
);
};
let quota = state.source.current(state.limiter).await;
let buckets = state.buckets_for(quota);
let not_until = buckets.limiter.check_key(&ip).err()?;
let wait = not_until.wait_time_from(DefaultClock::default().now());
Some(too_many_requests(state.limiter, ceil_secs(wait)))
}
fn ceil_secs(d: Duration) -> u64 {
d.as_secs() + u64::from(d.subsec_nanos() > 0)
}
pub(crate) fn too_many_requests(limiter: Limiter, retry_after_secs: u64) -> Response<Body> {
let retry_after = retry_after_secs.max(1);
let body = serde_json::json!({
"error": "rate_limited",
"limiter": limiter.name(),
"message": format!(
"Too Many Requests: rejected by the VTA's `{}` rate limiter. Retry after {retry_after} s.",
limiter.name()
),
"retryAfterSecs": retry_after,
})
.to_string();
Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header(header::CONTENT_TYPE, "application/json")
.header(header::RETRY_AFTER, HeaderValue::from(retry_after))
.header(
LEGACY_RATE_LIMIT_AFTER_HEADER,
HeaderValue::from(retry_after),
)
.header(RATE_LIMIT_SOURCE_HEADER, RATE_LIMIT_SOURCE_VTA)
.header(RATE_LIMIT_SCOPE_HEADER, limiter.name())
.body(Body::from(body))
.expect("static header names and numeric values always build")
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
use axum::http::Request as HttpRequest;
use axum::routing::get;
use tower::ServiceExt;
async fn ok() -> &'static str {
"ok"
}
fn trusted_loopback() -> Vec<IpNetwork> {
vec!["127.0.0.1/32".parse().unwrap()]
}
fn two_branch_router(source: QuotaSource) -> axum::Router {
let auth = apply(
OpenApiRouter::<()>::new().route("/auth", get(ok)),
Limiter::Auth,
&trusted_loopback(),
&source,
);
let did_log = apply(
OpenApiRouter::<()>::new().route("/did.jsonl", get(ok)),
Limiter::DidLog,
&trusted_loopback(),
&source,
);
let (router, _) = OpenApiRouter::<()>::new()
.merge(auth)
.merge(did_log)
.split_for_parts();
router.layer(axum::middleware::from_fn(
vti_common::rate_limit::insert_default_connect_info_if_missing,
))
}
async fn get_from(app: &axum::Router, uri: &str, ip: &str) -> Response<Body> {
let req = HttpRequest::builder()
.uri(uri)
.header("x-forwarded-for", ip)
.body(Body::empty())
.unwrap();
app.clone().oneshot(req).await.unwrap()
}
fn tight() -> RateLimits {
RateLimits::new(Quota::new(3600, 2), Quota::new(3600, 3))
}
fn live_config(limits: (u64, u32, u64, u32)) -> Arc<RwLock<AppConfig>> {
let mut config = crate::test_support::test_app_config("unused".into());
config.server.rate_limit_interval_secs = limits.0;
config.server.rate_limit_burst = limits.1;
config.server.did_log_rate_limit_interval_secs = limits.2;
config.server.did_log_rate_limit_burst = limits.3;
Arc::new(RwLock::new(config))
}
async fn admitted(app: &axum::Router, uri: &str, ip: &str, cap: usize) -> usize {
for i in 0..cap {
if get_from(app, uri, ip).await.status() == StatusCode::TOO_MANY_REQUESTS {
return i;
}
}
cap
}
#[tokio::test]
async fn did_log_burst_does_not_spend_auth_budget() {
let app = two_branch_router(QuotaSource::Fixed(tight()));
assert_eq!(admitted(&app, "/did.jsonl", "198.51.100.1", 10).await, 3);
assert_eq!(
admitted(&app, "/auth", "198.51.100.1", 10).await,
2,
"exhausting did-log must not spend the auth bucket"
);
}
#[tokio::test]
async fn auth_burst_does_not_spend_did_log_budget() {
let app = two_branch_router(QuotaSource::Fixed(tight()));
assert_eq!(admitted(&app, "/auth", "198.51.100.2", 10).await, 2);
assert_eq!(
admitted(&app, "/did.jsonl", "198.51.100.2", 10).await,
3,
"exhausting auth must not spend the did-log bucket"
);
}
#[tokio::test]
async fn limits_are_per_ip() {
let app = two_branch_router(QuotaSource::Fixed(tight()));
assert_eq!(admitted(&app, "/auth", "198.51.100.3", 10).await, 2);
assert_eq!(
get_from(&app, "/auth", "198.51.100.4").await.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn rejection_carries_the_vta_429_contract() {
let app = two_branch_router(QuotaSource::Fixed(tight()));
for (uri, scope, n) in [("/auth", "auth", 2), ("/did.jsonl", "did-log", 3)] {
for _ in 0..n {
get_from(&app, uri, "198.51.100.5").await;
}
let r = get_from(&app, uri, "198.51.100.5").await;
assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS);
let h = r.headers();
assert_eq!(h[RATE_LIMIT_SOURCE_HEADER], "vta");
assert_eq!(h[RATE_LIMIT_SCOPE_HEADER], scope);
let retry: u64 = h[header::RETRY_AFTER].to_str().unwrap().parse().unwrap();
assert!(
(1..=3600).contains(&retry),
"retry-after must be within one interval, got {retry}"
);
assert_eq!(h[LEGACY_RATE_LIMIT_AFTER_HEADER], h[header::RETRY_AFTER]);
assert_eq!(h[header::CONTENT_TYPE], "application/json");
let body = to_bytes(r.into_body(), usize::MAX).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
body,
serde_json::json!({
"error": "rate_limited",
"limiter": scope,
"message": format!(
"Too Many Requests: rejected by the VTA's `{scope}` rate limiter. \
Retry after {retry} s."
),
"retryAfterSecs": retry,
})
);
}
}
#[tokio::test]
async fn live_quota_change_applies_without_rebuilding_the_router() {
let config = live_config((3600, 2, 3600, 3));
let app = two_branch_router(QuotaSource::Live(Arc::clone(&config)));
assert_eq!(admitted(&app, "/auth", "198.51.100.6", 20).await, 2);
config.write().await.server.rate_limit_burst = 5;
assert_eq!(
admitted(&app, "/auth", "198.51.100.6", 20).await,
5,
"a quota change swaps in fresh buckets at the new burst"
);
config.write().await.server.rate_limit_burst = 1;
assert_eq!(admitted(&app, "/auth", "198.51.100.6", 20).await, 1);
assert_eq!(admitted(&app, "/did.jsonl", "198.51.100.6", 20).await, 3);
}
#[tokio::test]
async fn unrelated_config_change_keeps_bucket_state() {
let config = live_config((3600, 2, 3600, 3));
let app = two_branch_router(QuotaSource::Live(Arc::clone(&config)));
assert_eq!(admitted(&app, "/auth", "198.51.100.7", 20).await, 2);
{
let mut c = config.write().await;
c.vta_name = Some("renamed".into());
c.server.did_log_rate_limit_burst = 50;
}
assert_eq!(
get_from(&app, "/auth", "198.51.100.7").await.status(),
StatusCode::TOO_MANY_REQUESTS,
"auth buckets must survive a change that leaves the auth quota alone"
);
}
#[tokio::test]
async fn live_source_honours_configured_quota_from_the_first_request() {
let config = live_config((3600, 1, 3600, 1));
let app = two_branch_router(QuotaSource::Live(config));
assert_eq!(admitted(&app, "/auth", "198.51.100.8", 20).await, 1);
}
#[tokio::test]
async fn no_attributable_client_is_refused_not_unlimited() {
for cidrs in [vec![], trusted_loopback()] {
let router = apply(
OpenApiRouter::<()>::new().route("/auth", get(ok)),
Limiter::Auth,
&cidrs,
&QuotaSource::Fixed(RateLimits::default()),
);
let (app, _) = router.split_for_parts();
let resp = get_from(&app, "/auth", "198.51.100.9").await;
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"cidrs={cidrs:?}"
);
}
}
#[test]
fn ceil_secs_rounds_up() {
assert_eq!(ceil_secs(Duration::from_secs(4)), 4);
assert_eq!(ceil_secs(Duration::from_millis(4001)), 5);
assert_eq!(ceil_secs(Duration::from_millis(1)), 1);
assert_eq!(ceil_secs(Duration::ZERO), 0);
}
#[test]
fn source_label_is_what_the_sdk_reads_as_vta() {
use vta_sdk::rate_limit::RateLimitSource;
assert_eq!(
RateLimitSource::from_source_header(Some(RATE_LIMIT_SOURCE_VTA)),
RateLimitSource::Vta
);
assert_eq!(RATE_LIMIT_SOURCE_HEADER, "x-rate-limit-source");
}
#[test]
fn scope_names_are_stable() {
assert_eq!(Limiter::Auth.name(), "auth");
assert_eq!(Limiter::DidLog.name(), "did-log");
assert_eq!(Limiter::BackupBlob.name(), "backup-blob");
assert_eq!(Limiter::AuthAuthenticate.name(), "auth-authenticate");
assert_eq!(Limiter::AuthRefresh.name(), "auth-refresh");
}
#[test]
fn zero_quota_is_clamped() {
let q = Quota::new(0, 0);
assert_eq!((q.interval_secs(), q.burst()), (1, 1));
let server = ServerConfig {
rate_limit_interval_secs: 0,
rate_limit_burst: 0,
did_log_rate_limit_interval_secs: 0,
did_log_rate_limit_burst: 0,
..Default::default()
};
let limits = RateLimits::from_server_config(&server);
assert_eq!(limits.quota(Limiter::DidLog), Quota::new(1, 1));
assert_eq!(limits.quota(Limiter::Auth), Quota::new(1, 1));
let _ = two_branch_router(QuotaSource::Fixed(limits));
}
#[test]
fn defaults_match_server_config_defaults() {
assert_eq!(
RateLimits::default(),
RateLimits::from_server_config(&ServerConfig::default()),
"the no-config defaults must match vta-config's [server] defaults"
);
}
#[test]
fn backup_blob_uses_the_auth_quota() {
let limits = tight();
assert_eq!(
limits.quota(Limiter::BackupBlob),
limits.quota(Limiter::Auth)
);
}
#[test]
fn the_auth_steps_use_the_auth_quota() {
let limits = tight();
for step in [Limiter::AuthAuthenticate, Limiter::AuthRefresh] {
assert_eq!(limits.quota(step), limits.quota(Limiter::Auth));
}
}
use vta_sdk::trust_tasks::{
TASK_AUTH_AUTHENTICATE_0_2, TASK_AUTH_AUTHENTICATE_0_3, TASK_AUTH_CHALLENGE_0_1,
TASK_AUTH_REFRESH_0_2,
};
fn trust_tasks_router() -> axum::Router {
let (router, _) = apply_trust_tasks(
OpenApiRouter::<()>::new().route("/trust-tasks", axum::routing::post(ok)),
&trusted_loopback(),
&QuotaSource::Fixed(tight()),
)
.split_for_parts();
router.layer(axum::middleware::from_fn(
vti_common::rate_limit::insert_default_connect_info_if_missing,
))
}
fn task_request(type_uri: &str, ip: &str) -> HttpRequest<Body> {
HttpRequest::builder()
.method("POST")
.uri("/trust-tasks")
.header("x-forwarded-for", ip)
.body(Body::from(
serde_json::json!({ "type": type_uri, "payload": {} }).to_string(),
))
.unwrap()
}
async fn send(app: &axum::Router, req: HttpRequest<Body>) -> Response<Body> {
app.clone().oneshot(req).await.unwrap()
}
#[tokio::test]
async fn spent_challenges_leave_authenticate_and_refresh_budgets_whole() {
let app = trust_tasks_router();
let ip = "198.51.100.20";
for _ in 0..2 {
let r = send(&app, task_request(TASK_AUTH_CHALLENGE_0_1, ip)).await;
assert_eq!(r.status(), StatusCode::OK);
}
let refused = send(&app, task_request(TASK_AUTH_CHALLENGE_0_1, ip)).await;
assert_eq!(refused.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(refused.headers()[RATE_LIMIT_SCOPE_HEADER], "auth");
for type_uri in [
TASK_AUTH_AUTHENTICATE_0_2,
TASK_AUTH_AUTHENTICATE_0_3,
TASK_AUTH_REFRESH_0_2,
] {
let r = send(&app, task_request(type_uri, ip)).await;
assert_eq!(r.status(), StatusCode::OK, "{type_uri} has its own budget");
}
let r = send(&app, task_request(TASK_AUTH_AUTHENTICATE_0_2, ip)).await;
assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(r.headers()[RATE_LIMIT_SCOPE_HEADER], "auth-authenticate");
}
#[tokio::test]
async fn the_step_is_read_from_the_document_not_the_header() {
let app = trust_tasks_router();
let ip = "198.51.100.21";
let mut statuses = Vec::new();
for _ in 0..3 {
let mut req = task_request(TASK_AUTH_CHALLENGE_0_1, ip);
req.headers_mut().insert(
"trust-task",
HeaderValue::from_static(TASK_AUTH_AUTHENTICATE_0_2),
);
let r = send(&app, req).await;
statuses.push(r.status());
}
assert_eq!(
statuses,
[
StatusCode::OK,
StatusCode::OK,
StatusCode::TOO_MANY_REQUESTS
]
);
}
#[tokio::test]
async fn an_oversized_anonymous_body_is_charged_and_refused() {
let app = trust_tasks_router();
let ip = "198.51.100.22";
let big = vec![b' '; crate::trust_tasks::PUBLIC_TASK_BODY_LIMIT + 1];
let oversized = || {
HttpRequest::builder()
.method("POST")
.uri("/trust-tasks")
.header("x-forwarded-for", ip)
.body(Body::from(big.clone()))
.unwrap()
};
assert_eq!(
send(&app, oversized()).await.status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
send(&app, oversized()).await.status(),
StatusCode::BAD_REQUEST
);
let r = send(&app, oversized()).await;
assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(r.headers()[RATE_LIMIT_SCOPE_HEADER], "auth");
}
#[tokio::test]
async fn a_labelled_request_over_budget_is_refused_without_reading_its_body() {
let app = trust_tasks_router();
let ip = "198.51.100.24";
for _ in 0..2 {
let mut req = task_request(TASK_AUTH_AUTHENTICATE_0_2, ip);
req.headers_mut().insert(
"trust-task",
HeaderValue::from_static(TASK_AUTH_AUTHENTICATE_0_2),
);
assert_eq!(send(&app, req).await.status(), StatusCode::OK);
}
let unreadable = Body::from_stream(futures_util::stream::once(async {
Err::<axum::body::Bytes, _>(std::io::Error::other("body must not be read"))
}));
let req = HttpRequest::builder()
.method("POST")
.uri("/trust-tasks")
.header("x-forwarded-for", ip)
.header("trust-task", TASK_AUTH_AUTHENTICATE_0_2)
.body(unreadable)
.unwrap();
let r = send(&app, req).await;
assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(r.headers()[RATE_LIMIT_SCOPE_HEADER], "auth-authenticate");
}
#[tokio::test]
async fn a_declared_oversized_body_is_refused_without_reading_it() {
let app = trust_tasks_router();
let endless = Body::from_stream(futures_util::stream::pending::<
Result<axum::body::Bytes, std::io::Error>,
>());
let req = HttpRequest::builder()
.method("POST")
.uri("/trust-tasks")
.header("x-forwarded-for", "198.51.100.25")
.header(
header::CONTENT_LENGTH,
(crate::trust_tasks::PUBLIC_TASK_BODY_LIMIT + 1).to_string(),
)
.body(endless)
.unwrap();
let r = tokio::time::timeout(Duration::from_secs(5), send(&app, req))
.await
.expect("refused without reading the body");
assert_eq!(r.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn a_credentialed_request_is_not_charged() {
let app = trust_tasks_router();
for _ in 0..5 {
let mut req = task_request(TASK_AUTH_CHALLENGE_0_1, "198.51.100.23");
req.headers_mut().insert(
header::AUTHORIZATION,
HeaderValue::from_static("Bearer some.jwt.value"),
);
assert_eq!(send(&app, req).await.status(), StatusCode::OK);
}
}
}