cd662141e0
Introduces a new `validate` module to centralize input validation for web requests. This module contains functions to check the shape and format of various user-supplied strings before they are processed by the main application logic. Specifically, this commit adds validation for: - Login slugs: Ensures slugs conform to a strict pattern (lowercase alphanumerics, `_`, `-`) to prevent path traversal and other attacks. - Magic link tokens: Validates token length and character set to ensure they are URL-safe and within expected bounds. - Recorded at timestamps: Enforces a strict RFC3339 format with second granularity (`YYYY-MM-DDTHH:MM:SSZ`) to prevent filename parsing issues and ensure consistent data grouping. These validators are integrated into the `handle_login_submit`, `handle_consume` (magic link), and `handle_upload` routes. This change enhances security by preventing malformed inputs from reaching sensitive parts of the application and improves robustness by catching data format errors early. Unit and integration tests have been added to verify the functionality of these validators and their integration into the respective endpoints.
168 lines
5.8 KiB
Rust
168 lines
5.8 KiB
Rust
//! End-to-end checks that boundary validators in `crate::validate` are
|
|
//! actually wired into their respective handlers and produce the right
|
|
//! response shape per endpoint.
|
|
//!
|
|
//! Per-validator unit tests live in `server/src/validate.rs`; this file
|
|
//! tests the *integration* — the wiring, not the predicate.
|
|
|
|
mod common;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode, header};
|
|
use tower::util::ServiceExt;
|
|
|
|
use doctate_common::UPLOAD_PATH;
|
|
|
|
use common::{
|
|
TestConfig, body_string, extract_session_cookie, login_request, multipart_upload_body,
|
|
test_user, test_user_with_password,
|
|
};
|
|
|
|
const API_KEY: &str = "key-dr_test";
|
|
|
|
fn upload_request(content_type: &str, body: Vec<u8>) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(UPLOAD_PATH)
|
|
.header("Content-Type", content_type)
|
|
.header("X-API-Key", API_KEY)
|
|
.body(Body::from(body))
|
|
.unwrap()
|
|
}
|
|
|
|
/// Drift like the Wear OS bug (microseconds) must be rejected with 400
|
|
/// at the upload boundary, not silently accepted into the filename.
|
|
#[tokio::test]
|
|
async fn upload_rejects_microsecond_recorded_at_with_400() {
|
|
let cfg = TestConfig::new()
|
|
.with_label("validate")
|
|
.with_user(test_user("dr_test"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let case_id = "550e8400-e29b-41d4-a716-446655440000";
|
|
let (ct, body) = multipart_upload_body(case_id, "2026-04-27T10:34:12.987072Z", b"audio");
|
|
|
|
let resp = app.oneshot(upload_request(&ct, body)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
/// RFC3339 with offset (`+00:00`) is technically valid time, but our
|
|
/// filename pipeline is `Z`-only — we reject anything else, even if a
|
|
/// future client thinks it's being clever.
|
|
#[tokio::test]
|
|
async fn upload_rejects_offset_form_recorded_at_with_400() {
|
|
let cfg = TestConfig::new()
|
|
.with_label("validate")
|
|
.with_user(test_user("dr_test"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let case_id = "550e8400-e29b-41d4-a716-446655440000";
|
|
let (ct, body) = multipart_upload_body(case_id, "2026-04-27T10:34:12+00:00", b"audio");
|
|
|
|
let resp = app.oneshot(upload_request(&ct, body)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
/// Sanity guard: the strict shape we *do* accept still works, so the
|
|
/// validator hasn't accidentally locked everything out.
|
|
#[tokio::test]
|
|
async fn upload_accepts_strict_recorded_at_shape() {
|
|
let cfg = TestConfig::new()
|
|
.with_label("validate")
|
|
.with_user(test_user("dr_test"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let case_id = "550e8400-e29b-41d4-a716-446655440000";
|
|
let (ct, body) = multipart_upload_body(case_id, "2026-04-15T10:32:00Z", b"audio");
|
|
|
|
let resp = app.oneshot(upload_request(&ct, body)).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
/// A path-traversal-shaped slug must fold into the same "Login
|
|
/// fehlgeschlagen" response as a wrong password — *not* leak via 400
|
|
/// that the slug shape was the problem.
|
|
#[tokio::test]
|
|
async fn login_with_traversal_slug_returns_login_failed_page() {
|
|
let cfg = TestConfig::new()
|
|
.with_user(test_user_with_password("dr_a", "secret"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let resp = app
|
|
.oneshot(login_request("../etc/passwd", "anything"))
|
|
.await
|
|
.unwrap();
|
|
// Same shape as wrong-password: 200 OK with the error page, no cookie.
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert!(extract_session_cookie(&resp).is_none());
|
|
let body = body_string(resp).await;
|
|
assert!(body.contains("Login fehlgeschlagen"), "got: {body}");
|
|
}
|
|
|
|
/// Uppercase / non-ASCII slugs likewise must not bypass shape rules,
|
|
/// even if they happen to coincide with no configured user.
|
|
#[tokio::test]
|
|
async fn login_with_uppercase_slug_returns_login_failed_page() {
|
|
let cfg = TestConfig::new()
|
|
.with_user(test_user_with_password("dr_a", "secret"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let resp = app.oneshot(login_request("Dr_A", "secret")).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert!(extract_session_cookie(&resp).is_none());
|
|
}
|
|
|
|
/// A malformed magic-link token must not 400 (would leak shape) — it
|
|
/// should redirect to /web/login, identical to the unknown-token path.
|
|
#[tokio::test]
|
|
async fn magic_consume_with_malformed_token_redirects_to_login() {
|
|
let cfg = TestConfig::new()
|
|
.with_label("validate")
|
|
.with_user(test_user("dr_a"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
// Too short — would never have come from `generate_token`.
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/web/magic?token=abc")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
let loc = resp
|
|
.headers()
|
|
.get(header::LOCATION)
|
|
.expect("redirect must set Location")
|
|
.to_str()
|
|
.unwrap();
|
|
assert_eq!(loc, "/web/login");
|
|
}
|
|
|
|
/// And the same for tokens that pass the length check but contain
|
|
/// bytes outside the URL-safe alphabet.
|
|
#[tokio::test]
|
|
async fn magic_consume_with_non_urlsafe_token_redirects_to_login() {
|
|
let cfg = TestConfig::new()
|
|
.with_label("validate")
|
|
.with_user(test_user("dr_a"))
|
|
.build();
|
|
let app = doctate_server::create_router(cfg);
|
|
|
|
let bad = "%2E%2E%2Fetc%2Fpasswd%2E%2E%2Fetc"; // url-encoded ./../etc/passwd…
|
|
let uri = format!("/web/magic?token={bad}");
|
|
let resp = app
|
|
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
|
}
|