parlov 0.4.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
Documentation
//! RFC-compliant elicitation demo server.
//!
//! Implements 8 routes that produce existence oracle signals through genuine
//! business logic: resource lookup first, then constraint validation. Differentials
//! arise because unknown IDs return 404 before validation is ever reached.

#![deny(clippy::all)]

use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};

use axum::{
    Router,
    extract::{Path, State},
    http::{HeaderValue, StatusCode, header},
    response::{IntoResponse, Response},
    routing::{delete, get, patch, post},
};
use serde_json::Value;
use tokio::net::TcpListener;

/// IDs the server treats as existing resources.
pub const KNOWN_IDS: &[&str] = &["1", "42", "100"];

/// Email addresses already in use — uniqueness constraint for `/unique/{id}`.
const TAKEN_EMAILS: &[&str] = &["alice@example.com", "bob@example.com"];

/// Valid state machine values for `/state/{id}`.
const VALID_STATES: &[&str] = &["active", "pending", "suspended"];

/// Body size limit (bytes) enforced by `/oversized/{id}`.
const BODY_SIZE_LIMIT: u64 = 1024;

/// Per-resource request threshold before rate-limiting kicks in.
const RATE_LIMIT_THRESHOLD: u32 = 3;

/// Full-access token value (no scheme prefix) for `/scoped/{id}`.
const FULL_ACCESS_TOKEN: &str = "Bearer full-access";

fn is_known(id: &str) -> bool {
    KNOWN_IDS.contains(&id)
}

/// Shared rate-limit counter: resource ID → request count.
type RateCounter = Arc<Mutex<HashMap<String, u32>>>;

async fn oversized(
    Path(id): Path<String>,
    req: axum::http::Request<axum::body::Body>,
) -> Response {
    if !is_known(&id) {
        return StatusCode::NOT_FOUND.into_response();
    }
    let content_length: Option<u64> = req
        .headers()
        .get(header::CONTENT_LENGTH)
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.parse().ok());
    if content_length.unwrap_or(0) > BODY_SIZE_LIMIT {
        StatusCode::PAYLOAD_TOO_LARGE.into_response()
    } else {
        StatusCode::OK.into_response()
    }
}

async fn state_machine(
    Path(id): Path<String>,
    body: Option<axum::extract::Json<Value>>,
) -> Response {
    if !is_known(&id) {
        return StatusCode::NOT_FOUND.into_response();
    }
    let Some(axum::extract::Json(json)) = body else {
        return StatusCode::BAD_REQUEST.into_response();
    };
    match json.get("status").and_then(Value::as_str) {
        Some(s) if VALID_STATES.contains(&s) => StatusCode::OK.into_response(),
        Some(_) => StatusCode::CONFLICT.into_response(),
        None => StatusCode::BAD_REQUEST.into_response(),
    }
}

async fn unique(
    Path(id): Path<String>,
    body: Option<axum::extract::Json<Value>>,
) -> Response {
    if !is_known(&id) {
        return StatusCode::NOT_FOUND.into_response();
    }
    let Some(axum::extract::Json(json)) = body else {
        return StatusCode::BAD_REQUEST.into_response();
    };
    match json.get("email").and_then(Value::as_str) {
        Some(email) if TAKEN_EMAILS.contains(&email) => StatusCode::CONFLICT.into_response(),
        Some(_) => StatusCode::CREATED.into_response(),
        None => StatusCode::BAD_REQUEST.into_response(),
    }
}

async fn dependent(Path(id): Path<String>) -> Response {
    if is_known(&id) {
        StatusCode::CONFLICT.into_response()
    } else {
        StatusCode::NOT_FOUND.into_response()
    }
}

async fn ratelimited(
    Path(id): Path<String>,
    State(counter): State<RateCounter>,
) -> Response {
    if !is_known(&id) {
        return StatusCode::NOT_FOUND.into_response();
    }
    let count = {
        let mut map = counter.lock().expect("rate counter mutex must not be poisoned");
        let c = map.entry(id).or_insert(0);
        *c += 1;
        *c
    };
    if count > RATE_LIMIT_THRESHOLD {
        let mut resp = StatusCode::TOO_MANY_REQUESTS.into_response();
        resp.headers_mut()
            .insert(header::RETRY_AFTER, HeaderValue::from_static("30"));
        resp
    } else {
        StatusCode::OK.into_response()
    }
}

async fn headered(Path(id): Path<String>) -> Response {
    if !is_known(&id) {
        return StatusCode::NOT_FOUND.into_response();
    }
    let mut resp = StatusCode::OK.into_response();
    let h = resp.headers_mut();
    h.insert("x-ratelimit-limit", HeaderValue::from_static("100"));
    h.insert("x-ratelimit-remaining", HeaderValue::from_static("99"));
    resp
}

async fn forbidden(
    Path(id): Path<String>,
    req: axum::http::Request<axum::body::Body>,
) -> Response {
    if !is_known(&id) {
        return StatusCode::NOT_FOUND.into_response();
    }
    if req.headers().contains_key(header::AUTHORIZATION) {
        StatusCode::FORBIDDEN.into_response()
    } else {
        let mut resp = StatusCode::UNAUTHORIZED.into_response();
        resp.headers_mut().insert(
            header::WWW_AUTHENTICATE,
            HeaderValue::from_static("Bearer realm=\"test\""),
        );
        resp
    }
}

async fn scoped(
    Path(id): Path<String>,
    req: axum::http::Request<axum::body::Body>,
) -> Response {
    if !is_known(&id) {
        return StatusCode::NOT_FOUND.into_response();
    }
    match req
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
    {
        Some(token) if token == FULL_ACCESS_TOKEN => StatusCode::OK.into_response(),
        Some(_) => StatusCode::FORBIDDEN.into_response(),
        None => {
            let mut resp = StatusCode::UNAUTHORIZED.into_response();
            resp.headers_mut().insert(
                header::WWW_AUTHENTICATE,
                HeaderValue::from_static("Bearer realm=\"test\""),
            );
            resp
        }
    }
}

/// Builds the elicitation demo router with all 8 routes.
pub fn router(counter: RateCounter) -> Router {
    Router::new()
        .route("/oversized/{id}", post(oversized).put(oversized).patch(oversized))
        .route("/state/{id}", patch(state_machine).put(state_machine))
        .route("/unique/{id}", post(unique).put(unique))
        .route("/dependent/{id}", delete(dependent))
        .route("/ratelimited/{id}", get(ratelimited).head(ratelimited))
        .route("/headered/{id}", get(headered).head(headered))
        .route("/forbidden/{id}", get(forbidden).head(forbidden))
        .route("/scoped/{id}", get(scoped).head(scoped))
        .with_state(counter)
}

/// Spawns the RFC-compliant elicitation server on a random port.
///
/// Returns the bound socket address.
///
/// # Panics
///
/// Panics if the OS refuses to bind `127.0.0.1:0` or if `local_addr` cannot be read.
pub async fn spawn() -> SocketAddr {
    let counter: RateCounter = Arc::new(Mutex::new(HashMap::new()));
    let listener = TcpListener::bind("127.0.0.1:0")
        .await
        .expect("failed to bind elicit server listener");
    let addr = listener
        .local_addr()
        .expect("failed to read local_addr from listener");
    tokio::spawn(async move {
        axum::serve(listener, router(counter))
            .await
            .expect("elicit server failed");
    });
    addr
}

/// Spawns the elicitation server on the given port.
///
/// Returns the bound socket address.
///
/// # Panics
///
/// Panics if the OS refuses to bind `127.0.0.1:{port}` or if `local_addr` cannot be read.
pub async fn spawn_on(port: u16) -> SocketAddr {
    let counter: RateCounter = Arc::new(Mutex::new(HashMap::new()));
    let listener = TcpListener::bind(("127.0.0.1", port))
        .await
        .expect("failed to bind elicit server listener");
    let addr = listener
        .local_addr()
        .expect("failed to read local_addr from listener");
    tokio::spawn(async move {
        axum::serve(listener, router(counter))
            .await
            .expect("elicit server failed");
    });
    addr
}