Password Spraying in Axum
How Password Spraying Manifests in Axum
Password spraying is a credential stuffing variant where attackers try a few common passwords (e.g., Password123, admin) across many usernames to avoid account lockouts. In Axum applications, this attack exploits two common patterns: non-rate-limited authentication endpoints and inconsistent error responses that enable user enumeration.
Axum handlers often accept credentials via extractors like axum::extract::Json<LoginRequest> or axum::extract::Form<LoginRequest>. A vulnerable handler might look like this:
use axum::{extract::Json, response::IntoResponse, routing::post, Router};ntt#[derive(Deserialize)]nttstruct LoginRequest {ntt username: String,ntt password: String,ntt}nttnttasync fn login(Json(payload): Json) -> impl IntoResponse {ntt if payload.username == "admin" && payload.password == "secret" {ntt // Success response (e.g., JWT token)ntt return (StatusCode::OK, "{ \"token\": \"xyz\" }").into_response();ntt }ntt // Generic error message? Or specific?ntt (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response()ntt}nttnttlet app = Router::new().route("/login", post(login)); The vulnerability arises if:
- No rate limiting is applied to the
/loginroute. Axum does not include built-in rate limiting; developers must add middleware liketower::limit::rateor use crates such asaxum-rate-limiter. Without it, an attacker can submit hundreds of password guesses per minute. - Error messages differ for valid vs. invalid usernames. For example, returning
{"error":"User not found"}for unknown usernames but{"error":"Invalid password"}for known ones allows attackers to enumerate valid accounts before spraying.
Additionally, Axum applications using stateful session authentication (e.g., via axum::extract::Session) may expose session fixation risks if session IDs are not regenerated on login. While not directly password spraying, weak session handling can amplify the impact of compromised credentials.
Attackers automate spraying with tools like ffuf or custom scripts, targeting the login endpoint with a wordlist of usernames and a small set of common passwords. The lack of per-username or per-IP throttling in Axum routes makes this feasible.
Axum-Specific Detection
Detecting password spraying vulnerabilities in Axum requires testing the authentication endpoint's behavior under repeated, varied credential attempts. middleBrick's unauthenticated black-box scan performs this by:
- Probing the login endpoint with sequential requests using common passwords (e.g.,
password,123456) against a list of likely usernames (derived from the API's OpenAPI spec or common patterns). - Analyzing response consistency: If the status code, response body, or latency differs between requests with valid usernames/invalid passwords vs. invalid usernames, it indicates user enumeration (OWASP API:2).
- Checking for rate limiting headers (
Retry-After,X-RateLimit-*) or HTTP 429 responses after a threshold. Absence of these after 10–20 rapid requests suggests no rate limiting (OWASP API:4). - Correlating with OpenAPI spec: If the spec defines a
/loginoperation with200and401responses but no security scheme, middleBrick flags missing authentication enforcement.
For example, a scan against an Axum app might reveal:
| Test | Expected Secure Behavior | Vulnerable Observation |
|---|---|---|
| User enumeration | Same 401 response for any invalid credential pair | 401 {"error":"Invalid password"} for known user vs. 404 {"error":"User not found"} for unknown |
| Rate limiting | HTTP 429 after ~5–10 failed attempts per IP/username | All 401 responses, no 429, no Retry-After header |
middleBrick's Authentication and Rate Limiting checks (among its 12 parallel tests) capture these patterns. The scan takes 5–15 seconds, requiring no credentials or config—just the API URL. The resulting report assigns a risk score (A–F) and highlights findings with OWASP API Top 10 mapping (e.g., API2:2023 — Broken Authentication). For LLM/AI endpoints in Axum (e.g., /chat/completions), middleBrick also tests prompt injection and agency, but password spraying focuses on traditional auth endpoints.
To manually verify, use curl to send rapid requests and observe responses:
for i in {1..20}; do curl -X POST http://api.example.com/login -H "Content-Type: application/json" -d '{"username":"admin","password":"wrong"}'; doneIf all return 401 without delay or headers, rate limiting is absent.
Axum-Specific Remediation
Remediate password spraying in Axum by implementing per-username and per-IP rate limiting and consistent error responses. Axum's middleware ecosystem provides tools:
1. Apply rate limiting middleware
Use tower::limit::rate or a dedicated crate. Example with axum-rate-limiter:
use axum::{routing::post, Router};nttuse axum_rate_limiter::{RateLimiter, RateLimiterLayer};nttuse std::num::NonZeroU32;nttntt// Limit: 5 attempts per 60 seconds per IP address (keyed by peer IP)nttlet rate_limiter = RateLimiter::new(ntt NonZeroU32::new(5).unwrap(), // max requestsntt std::time::Duration::from_secs(60), // per periodntt axum_rate_limiter::key::IpKey, // rate limit by client IPntt);nttnttlet app = Router::new()ntt .route("/login", post(login))ntt .layer(RateLimiterLayer::new(rate_limiter));For per-username limits (more effective against spraying), use a custom key that includes the username (after validation to avoid DoS):
use axum::extract::Path;nttuse axum_rate_limiter::{RateLimiter, RateLimiterLayer, key::CustomKey};nttnttstruct LoginKey {ntt username: String,ntt}nttnttimpl CustomKey for LoginKey {ntt fn compute(&self) -> String {ntt format!("login:{}", self.username.to_ascii_lowercase())ntt }ntt}nttntt// In handler, extract username and apply layer conditionally?ntt// Better: apply a global layer but use a key that combines IP + username?ntt// However, rate limiter layers are applied before handler extraction.ntt// Alternative: use a middleware that wraps the login route specifically.Since Axum middleware runs before extractors, a more flexible approach is a custom middleware that checks the request path and body:
use axum::{body::Body, http::Request, middleware::Next, response::Response};nttuse std::sync::Arc;nttuse tokio::sync::RwLock;nttuse std::collections::HashMap;nttuse std::time::{Duration, Instant};nttnttstruct LoginAttempts {ntt // key: (ip, username_lower) -> (count, reset_time)ntt attempts: RwLock>,ntt}nttnttimpl LoginAttempts {ntt async fn is_allowed(&self, ip: &str, username: &str) -> bool {ntt let key = (ip.to_string(), username.to_ascii_lowercase());ntt let mut attempts = self.attempts.write().await;ntt let now = Instant::now();ntt if let Some((count, reset)) = attempts.get(&key) {ntt if now < *reset && *count >= 5 {ntt return false;ntt }ntt }ntt // Reset or incrementntt let count = attempts.entry(key).or_insert((0, now + Duration::from_secs(60))).0;ntt attempts.insert(key, (count + 1, reset));ntt truentt }ntt}nttnttasync fn rate_limit_middleware(ntt req: Request,ntt next: Next,ntt attempts: Arc,ntt) -> Response {ntt if req.uri().path() == "/login" && req.method() == "POST" {ntt // Extract IP and username from body (requires buffering)ntt // Note: this is simplified; in production, use extractors earlier or limit by IP only.ntt let ip = req.extensions().get::()ntt .map(|a| a.ip().to_string())ntt .unwrap_or_else(|| "unknown".to_string());ntt // Cannot easily extract body here without consuming; consider using a filterntt // For demo: apply per-IP rate limit only at this stage.ntt if !attempts.is_allowed(&ip, "").await {ntt return (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded").into_response();ntt }ntt }ntt next.run(req).awaitntt} 2. Normalize error responses
Ensure the login handler returns identical HTTP status codes, body structure, and timing for all authentication failures. In Axum:
use axum::{http::StatusCode, response::Json};nttuse serde_json::json;nttnttasync fn login(Json(payload): Json) -> impl IntoResponse {ntt // Always perform user lookup (constant-time check optional but recommended)ntt let user_exists = check_user_exists(&payload.username).await;ntt let password_valid = if user_exists {ntt verify_password(&payload.password, get_stored_hash(&payload.username)).awaitntt } else {ntt false // Still run password hash comparison to avoid timing leaks?ntt };nttntt if password_valid {ntt // Generate session/JWT...ntt (StatusCode::OK, Json(json!({ "token": "..." }))).into_response()ntt } else {ntt // Identical response regardless of whether username existsntt (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid credentials" }))).into_response()ntt }ntt} 3. Additional hardening:
- Use
bcryptorargon2for password hashing (viapassword-hashcrate). - Regenerate session IDs on login (
axum::extract::Sessionwithtower-sessions). - Consider multi-factor authentication for sensitive APIs.
After deploying fixes, rescan with middleBrick (via CLI: middlebrick scan https://api.example.com or GitHub Action in CI/CD) to verify the Authentication and Rate Limiting findings are resolved. The Pro plan's continuous monitoring can alert on regressions.