diff --git a/.github/gitleaks.toml b/.github/gitleaks.toml
new file mode 100644
index 00000000..b6cbeafc
--- /dev/null
+++ b/.github/gitleaks.toml
@@ -0,0 +1,23 @@
+title = "rustle gitleaks"
+
+[allowlist]
+paths = [
+ "target/",
+ "**/target/",
+ "frontend/dist/",
+ "dist/",
+ "tests/ui/node_modules/",
+ "tests/ui/playwright-report/",
+ "tests/ui/test-results/",
+ "**/Cargo.lock",
+ "frontend/src/constants/wordlist.txt",
+ "frontend/src/constants/valid_guesses.txt",
+ "frontend/src/helpers/word_db.rs",
+]
+
+regexes = [
+ { id = "uuid-v4", regex = "[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}" },
+]
+
+[extend]
+useDefault = true
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
new file mode 100644
index 00000000..db924b86
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -0,0 +1,134 @@
+name: security
+
+on:
+ push:
+ branches: [ "master" ]
+ pull_request:
+ branches: [ "master" ]
+ schedule:
+ - cron: "0 6 * * 1"
+
+permissions:
+ contents: read
+
+jobs:
+ deny:
+ name: cargo-deny
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: cargo-deny install
+ run: |
+ cargo install --locked cargo-deny --version "^0.16"
+
+ - name: cargo-deny check
+ env:
+ CARGO_NET_GIT_FETCH_WITH_CLI: "true"
+ run: |
+ if [ ! -f deny.toml ]; then
+ cargo deny --init >/dev/null
+ fi
+ cargo deny check
+
+ audit:
+ name: cargo-audit
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: cargo-audit install
+ run: |
+ cargo install --locked cargo-audit --version "^0.21"
+
+ - name: cargo-audit run
+ env:
+ CARGO_NET_GIT_FETCH_WITH_CLI: "true"
+ run: |
+ cargo audit --deny warnings
+
+ gitleaks:
+ name: gitleaks
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: gitleaks scan
+ uses: gitleaks/gitleaks-action@v2
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITLEAKS_CONFIG: .github/gitleaks.toml
+
+ fuzz-smoke:
+ name: fuzz smoke (build only)
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Rust nightly
+ uses: dtolnay/rust-toolchain@nightly
+ with:
+ targets: x86_64-unknown-linux-gnu
+
+ - name: cargo-fuzz install
+ run: |
+ cargo install --locked cargo-fuzz --version "^0.13"
+
+ - name: Build fuzz_scorer
+ env:
+ CARGO_NET_GIT_FETCH_WITH_CLI: "true"
+ run: |
+ cargo +nightly fuzz build fuzz_scorer
+
+ sbom:
+ name: SBOM (CycloneDX)
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: cargo-cyclonedx install
+ run: |
+ cargo install --locked cargo-cyclonedx --version "^0.5"
+
+ - name: Generate SBOM
+ env:
+ CARGO_NET_GIT_FETCH_WITH_CLI: "true"
+ run: |
+ cargo cyclonedx --format json --override-filename root-cargo > /tmp/sbom.json || true
+ if [ -s /tmp/sbom.json ]; then
+ mkdir -p artifacts
+ cp /tmp/sbom.json artifacts/sbom.cargo.json
+ fi
+ cargo cyclonedx --all --format json --override-filename workspace-cargo -p backend -p rustle-frontend > /tmp/sbom-ws.json || true
+ if [ -s /tmp/sbom-ws.json ]; then
+ cp /tmp/sbom-ws.json artifacts/sbom.workspace.cargo.json
+ fi
+
+ - name: Upload SBOM
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: rustle-sbom
+ path: artifacts/
+ retention-days: 14
diff --git a/Cargo.lock b/Cargo.lock
index 06aa1acc..9cfb5ab9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -122,6 +122,8 @@ dependencies = [
"chrono",
"constant_time_eq 0.5.0",
"dotenvy",
+ "http-body-util",
+ "hyper",
"rand",
"reqwest",
"serde",
@@ -129,6 +131,7 @@ dependencies = [
"shared-backend",
"shared-core 3.0.39",
"tokio",
+ "tower",
"tower-http 0.7.0",
"tracing",
]
@@ -764,6 +767,25 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "h2"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http 1.4.2",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -875,6 +897,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
+ "h2",
"http 1.4.2",
"http-body",
"httparse",
diff --git a/Cargo.toml b/Cargo.toml
index 1338eb2e..c0cad50e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,6 +3,7 @@ members = [
"backend",
"frontend"
]
+exclude = ["fuzz"]
resolver = "2"
[profile.release]
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 377db630..591b9a3d 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -32,3 +32,7 @@ shared-core = { git = "https://github.com/studio2201/shared-assets.git", tag = "
shared-backend = { git = "https://github.com/studio2201/shared-assets.git", tag = "v3.0.33" }
[dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies"] }
+tower = { version = "0.5", features = ["util"] }
+http-body-util = "0.1"
+hyper = { version = "1", features = ["full"] }
+shared-backend = { git = "https://github.com/studio2201/shared-assets.git", tag = "v3.0.33" }
diff --git a/backend/src/lib.rs b/backend/src/lib.rs
new file mode 100644
index 00000000..fd1b4f56
--- /dev/null
+++ b/backend/src/lib.rs
@@ -0,0 +1,20 @@
+// Copyright (C) 2026 UberMetroid
+//
+// This file is part of Rustle.
+//
+// Rustle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Rustle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Rustle. If not, see .
+
+pub mod auth;
+pub mod routes;
+pub mod utils;
\ No newline at end of file
diff --git a/backend/tests/authz.rs b/backend/tests/authz.rs
new file mode 100644
index 00000000..fe1031d8
--- /dev/null
+++ b/backend/tests/authz.rs
@@ -0,0 +1,520 @@
+use std::net::SocketAddr;
+use std::sync::Arc;
+
+use axum::Router;
+use axum::body::Body;
+use axum::http::{Request, StatusCode, header};
+use axum::middleware;
+use axum::response::Response;
+use axum::routing::{get, post};
+use backend::auth::{
+ AppState, auth_check, auth_middleware, logout, pin_required, security_headers_middleware,
+ verify_pin,
+};
+use http_body_util::BodyExt;
+use shared_backend::server::ServerConfig;
+use tower::ServiceExt;
+
+fn make_config(pin: Option<&str>) -> Arc {
+ let cfg = ServerConfig {
+ port: 0,
+ site_title: "Rustle".to_string(),
+ base_url: "http://localhost".to_string(),
+ allowed_origins: "*".to_string(),
+ pin: pin.map(|p| p.to_string()),
+ enable_translation: false,
+ enable_themes: false,
+ enable_print: false,
+ show_version: true,
+ show_github: true,
+ trust_proxy: false,
+ trusted_proxies: vec![],
+ max_attempts: 5,
+ lockout_time_minutes: 15,
+ cookie_max_age_hours: 24,
+ shutdown_drain_seconds: 5,
+ };
+ Arc::new(cfg)
+}
+
+fn build_router(config: &Arc) -> Router {
+ let state = AppState::new(Arc::clone(config));
+
+ let api = Router::new()
+ .route("/pin-required", get(pin_required))
+ .route("/verify-pin", post(verify_pin))
+ .route("/auth-check", get(auth_check))
+ .route("/logout", post(logout));
+
+ Router::new()
+ .nest("/api", api)
+ .route("/", get(axum::response::Html::from("index")))
+ .route("/index.html", get(axum::response::Html::from("index")))
+ .route("/login.html", get(axum::response::Html::from("login")))
+ .route("/secret", get(axum::response::Html::from("secret-payload")))
+ .layer(middleware::from_fn_with_state(
+ state.clone(),
+ auth_middleware,
+ ))
+ .layer(middleware::from_fn(security_headers_middleware))
+ .with_state(state)
+}
+
+fn build_request(method: &str, uri: &str, extras: Vec<(&str, &str)>) -> Request {
+ let mut builder = Request::builder().method(method).uri(uri);
+ for (k, v) in &extras {
+ builder = builder.header(*k, *v);
+ }
+ builder
+ .header(header::HOST, "127.0.0.1")
+ .body(Body::empty())
+ .expect("build request")
+}
+
+fn build_request_with_body(method: &str, uri: &str, body: Vec) -> Request {
+ Request::builder()
+ .method(method)
+ .uri(uri)
+ .header(header::HOST, "127.0.0.1")
+ .header(header::CONTENT_TYPE, "application/json")
+ .body(Body::from(body))
+ .expect("build request")
+}
+
+async fn read_body(resp: Response) -> Vec {
+ let body = resp.into_body();
+ let bytes = body.collect().await.expect("collect body");
+ bytes.to_bytes().to_vec()
+}
+
+fn default_client_addr() -> SocketAddr {
+ "127.0.0.1:1234".parse().unwrap()
+}
+
+fn with_connect_info(mut req: Request, addr: SocketAddr) -> Request {
+ req.extensions_mut()
+ .insert(axum::extract::ConnectInfo(addr));
+ req
+}
+
+#[tokio::test]
+async fn auth_middleware_401_when_pin_set_and_no_credentials() {
+ let cfg = make_config(Some("1234"));
+ let app = build_router(&cfg);
+
+ let req = with_connect_info(
+ build_request("GET", "/secret", vec![]),
+ default_client_addr(),
+ );
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+}
+
+#[tokio::test]
+async fn auth_middleware_returns_security_headers() {
+ let cfg = make_config(Some("1234"));
+ let app = build_router(&cfg);
+
+ let req = with_connect_info(build_request("GET", "/", vec![]), default_client_addr());
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+
+ let h = resp.headers();
+ assert_eq!(h.get("x-frame-options").unwrap(), "DENY");
+ assert_eq!(h.get("x-content-type-options").unwrap(), "nosniff");
+ assert_eq!(
+ h.get("referrer-policy").unwrap(),
+ "strict-origin-when-cross-origin"
+ );
+ let csp = h
+ .get("content-security-policy")
+ .expect("csp header")
+ .to_str()
+ .unwrap();
+ assert!(csp.contains("default-src 'self'"));
+}
+
+#[tokio::test]
+async fn auth_middleware_bypass_html_paths() {
+ let cfg = make_config(Some("1234"));
+ let app = build_router(&cfg);
+
+ for path in ["/", "/index.html", "/login.html"] {
+ let req = with_connect_info(build_request("GET", path, vec![]), default_client_addr());
+ let resp = app.clone().oneshot(req).await.expect("response");
+ assert_eq!(
+ resp.status(),
+ StatusCode::OK,
+ "expected 200 for bypass path {path}, got {}",
+ resp.status()
+ );
+ }
+}
+
+#[tokio::test]
+async fn auth_middleware_bypass_auth_api_paths() {
+ let cfg = make_config(Some("1234"));
+ let app = build_router(&cfg);
+
+ let pairs: &[(&str, &str)] = &[
+ ("/api/pin-required", "GET"),
+ ("/api/auth-check", "GET"),
+ ("/api/verify-pin", "POST"),
+ ("/api/logout", "POST"),
+ ];
+
+ for (path, method) in pairs {
+ let req = with_connect_info(
+ build_request(method, path, vec![]),
+ default_client_addr(),
+ );
+ let resp = app.clone().oneshot(req).await.expect("response");
+ let status = resp.status();
+ assert_ne!(
+ status,
+ StatusCode::NOT_FOUND,
+ "auth API path {path} must be routed (404 means middleware intercepted)"
+ );
+ assert!(
+ !status.is_server_error(),
+ "auth API path {path} must not 5xx; got {status}"
+ );
+ }
+}
+
+#[tokio::test]
+async fn auth_middleware_open_when_no_pin() {
+ let cfg = make_config(None);
+ let app = build_router(&cfg);
+
+ let req = with_connect_info(build_request("GET", "/secret", vec![]), default_client_addr());
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+}
+
+#[tokio::test]
+async fn auth_middleware_wrong_x_pin_header_401() {
+ let cfg = make_config(Some("1234"));
+ let app = build_router(&cfg);
+
+ let req = with_connect_info(
+ build_request("GET", "/secret", vec![("x-pin", "wrong")]),
+ default_client_addr(),
+ );
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+}
+
+#[tokio::test]
+async fn auth_middleware_correct_x_pin_header_passes() {
+ let cfg = make_config(Some("1234"));
+ let app = build_router(&cfg);
+
+ let req = with_connect_info(
+ build_request("GET", "/secret", vec![("x-pin", "1234")]),
+ default_client_addr(),
+ );
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+}
+
+#[tokio::test]
+async fn auth_middleware_does_not_allow_html_path_traversal() {
+ let cfg = make_config(Some("1234"));
+ let app = build_router(&cfg);
+
+ let req = with_connect_info(
+ build_request("GET", "/secretfile.html/admin", vec![]),
+ default_client_addr(),
+ );
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+}
+
+#[tokio::test]
+async fn verify_pin_issue_cookie_has_required_attributes() {
+ let cfg = make_config(Some("12345678"));
+ let app = build_router(&cfg);
+
+ let body = serde_json::to_vec(&serde_json::json!({ "pin": "12345678" }))
+ .expect("serialize body");
+ let req = build_request_with_body("POST", "/api/verify-pin", body);
+ let req = with_connect_info(req, default_client_addr());
+
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+
+ let set_cookie = resp
+ .headers()
+ .get_all(header::SET_COOKIE)
+ .iter()
+ .filter_map(|v| v.to_str().ok())
+ .collect::>()
+ .join("\n");
+
+ assert!(set_cookie.contains("pin="), "must set pin cookie: {set_cookie}");
+ assert!(set_cookie.contains("HttpOnly"), "cookie must be HttpOnly");
+ assert!(
+ set_cookie.contains("SameSite=Strict"),
+ "cookie must be SameSite=Strict"
+ );
+ assert!(
+ set_cookie.contains("Path=/"),
+ "cookie must scope to Path=/"
+ );
+ assert!(set_cookie.contains("Max-Age="), "cookie must have Max-Age");
+}
+
+#[tokio::test]
+async fn verify_pin_rejects_wrong_pin() {
+ let cfg = make_config(Some("12345678"));
+ let app = build_router(&cfg);
+
+ let body = serde_json::to_vec(&serde_json::json!({ "pin": "wrong" }))
+ .expect("serialize body");
+ let req = build_request_with_body("POST", "/api/verify-pin", body);
+ let req = with_connect_info(req, default_client_addr());
+
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+
+ let set_cookie = resp
+ .headers()
+ .get_all(header::SET_COOKIE)
+ .iter()
+ .filter_map(|v| v.to_str().ok())
+ .collect::>()
+ .join("\n");
+ let has_clear = set_cookie
+ .lines()
+ .any(|l| l.contains("pin=") && l.contains("Max-Age=0"));
+ let has_session = set_cookie
+ .lines()
+ .any(|l| l.contains("pin=") && !l.contains("Max-Age=0"));
+ assert!(
+ !has_session,
+ "wrong PIN must not grant a non-clearing session cookie: {set_cookie}"
+ );
+ let _ = has_clear;
+}
+
+#[tokio::test]
+async fn verify_pin_rejects_empty_pin() {
+ let cfg = make_config(Some("12345678"));
+ let app = build_router(&cfg);
+
+ let body = serde_json::to_vec(&serde_json::json!({ "pin": "" }))
+ .expect("serialize body");
+ let req = build_request_with_body("POST", "/api/verify-pin", body);
+ let req = with_connect_info(req, default_client_addr());
+
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
+}
+
+#[tokio::test]
+async fn verify_pin_locks_out_after_max_attempts() {
+ let cfg = make_config(Some("12345678"));
+ let app = build_router(&cfg);
+
+ let addr: SocketAddr = "10.0.0.7:3000".parse().unwrap();
+ for _ in 0..cfg.max_attempts {
+ let body = serde_json::to_vec(&serde_json::json!({ "pin": "wrong" }))
+ .expect("serialize body");
+ let req = build_request_with_body("POST", "/api/verify-pin", body);
+ let req = with_connect_info(req, addr);
+ let _ = app.clone().oneshot(req).await.expect("response");
+ }
+
+ let body = serde_json::to_vec(&serde_json::json!({ "pin": "12345678" }))
+ .expect("serialize body");
+ let req = build_request_with_body("POST", "/api/verify-pin", body);
+ let req = with_connect_info(req, addr);
+ let resp = app.oneshot(req).await.expect("response");
+
+ assert_eq!(
+ resp.status(),
+ StatusCode::TOO_MANY_REQUESTS,
+ "after max_attempts failed attempts, even correct PIN must be 429"
+ );
+}
+
+#[tokio::test]
+async fn auth_middleware_lockout_returns_429() {
+ let cfg = make_config(Some("12345678"));
+ let app = build_router(&cfg);
+
+ let addr: SocketAddr = "10.0.0.7:3000".parse().unwrap();
+ for _ in 0..cfg.max_attempts {
+ let req = with_connect_info(
+ build_request("GET", "/secret", vec![("x-pin", "wrong")]),
+ addr,
+ );
+ let _ = app.clone().oneshot(req).await.expect("response");
+ }
+
+ let req = with_connect_info(
+ build_request("GET", "/secret", vec![("x-pin", "12345678")]),
+ addr,
+ );
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
+}
+
+#[tokio::test]
+async fn auth_check_says_unauthorized_when_no_session() {
+ let cfg = make_config(Some("12345678"));
+ let app = build_router(&cfg);
+
+ let req = with_connect_info(
+ build_request("GET", "/api/auth-check", vec![]),
+ default_client_addr(),
+ );
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+}
+
+#[tokio::test]
+async fn auth_check_says_ok_when_no_pin() {
+ let cfg = make_config(None);
+ let app = build_router(&cfg);
+
+ let req = with_connect_info(
+ build_request("GET", "/api/auth-check", vec![]),
+ default_client_addr(),
+ );
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+}
+
+#[tokio::test]
+async fn logout_clears_cookie_and_returns_ok() {
+ let cfg = make_config(Some("12345678"));
+ let app = build_router(&cfg);
+
+ let req = with_connect_info(
+ build_request("POST", "/api/logout", vec![]),
+ default_client_addr(),
+ );
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+
+ let set_cookie = resp
+ .headers()
+ .get_all(header::SET_COOKIE)
+ .iter()
+ .filter_map(|v| v.to_str().ok())
+ .collect::>()
+ .join("\n");
+
+ assert!(set_cookie.contains("pin="));
+ assert!(set_cookie.contains("Max-Age=0"));
+ assert!(set_cookie.contains("HttpOnly"));
+ assert!(set_cookie.contains("SameSite=Strict"));
+}
+
+#[tokio::test]
+async fn pin_required_reports_config_state() {
+ let cfg = make_config(Some("12345"));
+ let app = build_router(&cfg);
+
+ let req = with_connect_info(
+ build_request("GET", "/api/pin-required", vec![]),
+ default_client_addr(),
+ );
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+
+ let body = read_body(resp).await;
+ let json: serde_json::Value = serde_json::from_slice(&body).expect("parse json");
+ assert_eq!(json["required"].as_bool(), Some(true));
+ assert_eq!(json["length"].as_u64(), Some(5));
+}
+
+#[tokio::test]
+async fn pin_required_reports_no_pin_when_unset() {
+ let cfg = make_config(None);
+ let app = build_router(&cfg);
+
+ let req = with_connect_info(
+ build_request("GET", "/api/pin-required", vec![]),
+ default_client_addr(),
+ );
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+
+ let body = read_body(resp).await;
+ let json: serde_json::Value = serde_json::from_slice(&body).expect("parse json");
+ assert_eq!(json["required"].as_bool(), Some(false));
+ assert_eq!(json["length"].as_u64(), Some(0));
+}
+
+#[tokio::test]
+async fn cookie_path_traversal_does_not_bypass_auth() {
+ let cfg = make_config(Some("1234"));
+ let app = build_router(&cfg);
+
+ for path in [
+ "/api/pin-required/../secret",
+ "/secret/.",
+ "/secret/..",
+ "/secret%00.html",
+ ] {
+ let req = with_connect_info(
+ build_request("GET", path, vec![]),
+ default_client_addr(),
+ );
+ let resp = app.clone().oneshot(req).await.expect("response");
+ let status = resp.status();
+ assert_ne!(
+ status,
+ StatusCode::OK,
+ "path {path} unexpectedly returned 200"
+ );
+ }
+}
+
+#[tokio::test]
+async fn verify_pin_trims_whitespace() {
+ let cfg = make_config(Some("12345678"));
+ let app = build_router(&cfg);
+
+ let body = serde_json::to_vec(&serde_json::json!({ "pin": " 12345678\t\n" }))
+ .expect("serialize body");
+ let req = build_request_with_body("POST", "/api/verify-pin", body);
+ let req = with_connect_info(req, default_client_addr());
+
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+}
+
+#[tokio::test]
+async fn login_endpoint_not_bypassable_via_path_suffix() {
+ let cfg = make_config(Some("1234"));
+ let app = build_router(&cfg);
+
+ let req = with_connect_info(
+ build_request("GET", "/api/verify-pin/extra", vec![]),
+ default_client_addr(),
+ );
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+}
+
+#[tokio::test]
+async fn pin_length_mismatch_is_rejected() {
+ let long_pin = "x".repeat(1024);
+ let cfg = make_config(Some(&long_pin));
+ let app = build_router(&cfg);
+
+ let body = serde_json::to_vec(&serde_json::json!({ "pin": "x" })).expect("serialize body");
+ let req = build_request_with_body("POST", "/api/verify-pin", body);
+ let req = with_connect_info(req, default_client_addr());
+
+ let resp = app.oneshot(req).await.expect("response");
+ assert_eq!(
+ resp.status(),
+ StatusCode::UNAUTHORIZED,
+ "single-char PIN must not match 1024-char PIN"
+ );
+}
diff --git a/deny.toml b/deny.toml
new file mode 100644
index 00000000..de65992b
--- /dev/null
+++ b/deny.toml
@@ -0,0 +1,38 @@
+targets = []
+
+[advisories]
+db-path = "~/.cargo/advisory-db"
+db-urls = ["https://github.com/rustsec/advisory-db"]
+yanked = "warn"
+ignore = []
+
+[licenses]
+allow = [
+ "Apache-2.0",
+ "Apache-2.0 WITH LLVM-exception",
+ "MIT",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "ISC",
+ "Zlib",
+ "Unicode-3.0",
+ "CC0-1.0",
+ "OpenSSL",
+ "GPL-3.0-or-later",
+ "GPL-3.0",
+]
+confidence-threshold = 0.8
+
+[bans]
+multiple-versions = "warn"
+wildcards = "allow"
+highlight = "all"
+deny = []
+skip = []
+skip-tree = []
+
+[sources]
+unknown-registry = "deny"
+unknown-git = "warn"
+allow-registry = ["https://github.com/rust-lang/crates.io-index"]
+allow-git = ["https://github.com/studio2201/shared-assets.git"]
diff --git a/fuzz/.gitignore b/fuzz/.gitignore
new file mode 100644
index 00000000..6ca99f77
--- /dev/null
+++ b/fuzz/.gitignore
@@ -0,0 +1,3 @@
+corpus/
+artifacts/
+fuzz/target/
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
new file mode 100644
index 00000000..2cea3095
--- /dev/null
+++ b/fuzz/Cargo.toml
@@ -0,0 +1,50 @@
+[package]
+name = "backend-fuzz"
+version = "0.0.0"
+publish = false
+edition = "2024"
+
+[package.metadata]
+cargo-fuzz = true
+
+[dependencies]
+libfuzzer-sys = "0.4"
+chrono = { version = "0.4", features = ["serde"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+constant_time_eq = "0.5"
+base64 = "0.23"
+blowfish = "0.10"
+byteorder = "1.5"
+
+[[bin]]
+name = "fuzz_scorer"
+path = "fuzz_targets/fuzz_scorer.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "fuzz_encryption_roundtrip"
+path = "fuzz_targets/fuzz_encryption_roundtrip.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "fuzz_holiday_date_math"
+path = "fuzz_targets/fuzz_holiday_date_math.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "fuzz_pin_helper"
+path = "fuzz_targets/fuzz_pin_helper.rs"
+test = false
+doc = false
+bench = false
+
+[profile.release]
+opt-level = 3
+debug = true
\ No newline at end of file
diff --git a/fuzz/fuzz_targets/fuzz_encryption_roundtrip.rs b/fuzz/fuzz_targets/fuzz_encryption_roundtrip.rs
new file mode 100644
index 00000000..fc37de9c
--- /dev/null
+++ b/fuzz/fuzz_targets/fuzz_encryption_roundtrip.rs
@@ -0,0 +1,74 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+
+use base64::{Engine as _, engine::general_purpose::STANDARD};
+use blowfish::Blowfish;
+use blowfish::cipher::{BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
+
+const KEY: &[u8] = b"xcQUAHsik#Thq&LG*8es2DsZ$3bw^e";
+
+fn encrypt(data: &str) -> Result {
+ let bf = Blowfish::::new_from_slice(KEY).map_err(|e| e.to_string())?;
+
+ let mut bytes = Vec::with_capacity(data.len() + 8);
+ bytes.extend_from_slice(data.as_bytes());
+ let pad_len = (8 - (bytes.len() % 8)) % 8;
+ bytes.resize(bytes.len() + pad_len, 0);
+
+ for chunk in bytes.chunks_mut(8) {
+ let block = chunk.try_into().map_err(|_| "invalid block length")?;
+ bf.encrypt_block(block);
+ }
+
+ Ok(STANDARD.encode(&bytes))
+}
+
+fn decrypt(encoded: &str) -> Option {
+ let bf = Blowfish::::new_from_slice(KEY).ok()?;
+
+ let mut bytes = STANDARD.decode(encoded).ok()?;
+ if bytes.len() % 8 != 0 {
+ return None;
+ }
+
+ for chunk in bytes.chunks_mut(8) {
+ let block = chunk.try_into().ok()?;
+ bf.decrypt_block(block);
+ }
+
+ while let Some(&0) = bytes.last() {
+ bytes.pop();
+ }
+
+ String::from_utf8(bytes).ok()
+}
+
+fuzz_target!(|data: &[u8]| {
+ let s = String::from_utf8_lossy(data);
+
+ if let Ok(ciphertext) = encrypt(&s) {
+ let decoded = decrypt(&ciphertext);
+ if let Some(plain) = decoded {
+ let original = s.to_string();
+ let trimmed = plain.strip_suffix('\0').unwrap_or(&plain);
+ assert!(
+ trimmed == original || trimmed.trim_end_matches('\0') == original.trim_end_matches('\0'),
+ "roundtrip mismatch: input {original:?} plaintext {trimmed:?}"
+ );
+ }
+ }
+
+ let _ = decrypt(&s);
+
+ let random_len = data.len() % 64;
+ let mut pad_bytes = vec![0u8; random_len];
+ if random_len > 0 {
+ let copy_len = random_len.min(data.len());
+ pad_bytes[..copy_len].copy_from_slice(&data[..copy_len]);
+ }
+ let bad = STANDARD.encode(&pad_bytes);
+ if bad.len() % 8 != 0 {
+ assert!(decrypt(&bad).is_none());
+ }
+});
diff --git a/fuzz/fuzz_targets/fuzz_holiday_date_math.rs b/fuzz/fuzz_targets/fuzz_holiday_date_math.rs
new file mode 100644
index 00000000..b8f1715b
--- /dev/null
+++ b/fuzz/fuzz_targets/fuzz_holiday_date_math.rs
@@ -0,0 +1,125 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+
+fn get_easter_sunday(year: i32) -> chrono::NaiveDate {
+ let a = year % 19;
+ let b = year / 100;
+ let c = year % 100;
+ let d = b / 4;
+ let e = b % 4;
+ let f = (b + 8) / 25;
+ let g = (b - f + 1) / 3;
+ let h = (19 * a + b - d - g + 15) % 30;
+ let i = c / 4;
+ let k = c % 4;
+ let l = (32 + 2 * e + 2 * i - h - k) % 7;
+ let m = (a + 11 * h + 22 * l) / 451;
+ let month = ((h + l - 7 * m + 114) / 31) as u32;
+ let day = (((h + l - 7 * m + 114) % 31) + 1) as u32;
+ chrono::NaiveDate::from_ymd_opt(year, month, day).unwrap_or_default()
+}
+
+fn get_thanksgiving_thursday(year: i32) -> chrono::NaiveDate {
+ use chrono::Datelike;
+ let first_of_nov = chrono::NaiveDate::from_ymd_opt(year, 11, 1).unwrap_or_default();
+ let mut date = first_of_nov;
+ while date.weekday() != chrono::Weekday::Thu {
+ if let Some(next) = date.succ_opt() {
+ date = next;
+ } else {
+ break;
+ }
+ }
+ date + chrono::Duration::days(21)
+}
+
+fn get_holiday_for_date(
+ date: chrono::NaiveDate,
+) -> Option<(&'static str, &'static str)> {
+ use chrono::Datelike;
+ let year = date.year();
+ let month = date.month();
+ let day = date.day();
+
+ if (month == 12 && day == 31) || (month == 1 && day == 1) {
+ return Some(("newyear", "New Year's"));
+ }
+ if month == 2 && (12..=14).contains(&day) {
+ return Some(("valentine", "Valentine's Day"));
+ }
+ if month == 3 && (15..=17).contains(&day) {
+ return Some(("stpatrick", "St. Patrick's Day"));
+ }
+ let easter = get_easter_sunday(year);
+ if let (Some(good_friday), Some(easter_monday)) = (
+ easter.checked_sub_signed(chrono::Duration::days(2)),
+ easter.checked_add_signed(chrono::Duration::days(1)),
+ ) && date >= good_friday
+ && date <= easter_monday
+ {
+ return Some(("easter", "Easter"));
+ }
+ if month == 7 && (3..=5).contains(&day) {
+ return Some(("independence", "Independence Day"));
+ }
+ if month == 10 && (25..=31).contains(&day) {
+ return Some(("halloween", "Halloween"));
+ }
+ let thanksgiving = get_thanksgiving_thursday(year);
+ if let Some(thanksgiving_sunday) = thanksgiving.checked_add_signed(chrono::Duration::days(3))
+ && date >= thanksgiving
+ && date <= thanksgiving_sunday
+ {
+ return Some(("thanksgiving", "Thanksgiving"));
+ }
+ if month == 12 && (20..=26).contains(&day) {
+ return Some(("christmas", "Christmas"));
+ }
+ None
+}
+
+fn decode_year(data: &[u8]) -> i32 {
+ if data.len() < 4 {
+ return 2026;
+ }
+ let raw = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
+ let year = (raw % 800) as i32 + 1900;
+ year.clamp(1900, 2699)
+}
+
+fuzz_target!(|data: &[u8]| {
+ use chrono::Datelike;
+
+ if data.len() < 6 {
+ return;
+ }
+ let year = decode_year(data);
+ let month = (data[4] as u32 % 12) + 1;
+ let day = (data[5] as u32 % 31) + 1;
+
+ let candidate = chrono::NaiveDate::from_ymd_opt(year, month, day);
+ let date = match candidate {
+ Some(d) => d,
+ None => return,
+ };
+
+ let result = get_holiday_for_date(date);
+ let easter = get_easter_sunday(year);
+ let thanksgiving = get_thanksgiving_thursday(year);
+
+ if year >= 1900 && year <= 2699 {
+ let m = easter.month();
+ assert!(m == 3 || m == 4, "Easter month must be March or April");
+
+ let t = thanksgiving.month();
+ assert_eq!(t, 11, "Thanksgiving must be in November");
+
+ let wd = thanksgiving.weekday();
+ assert_eq!(wd, chrono::Weekday::Thu, "Thanksgiving must be a Thursday");
+ }
+
+ if let Some((_, name)) = result {
+ assert!(!name.is_empty(), "holiday name must not be empty");
+ }
+});
diff --git a/fuzz/fuzz_targets/fuzz_pin_helper.rs b/fuzz/fuzz_targets/fuzz_pin_helper.rs
new file mode 100644
index 00000000..040d1a02
--- /dev/null
+++ b/fuzz/fuzz_targets/fuzz_pin_helper.rs
@@ -0,0 +1,87 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+
+use constant_time_eq::constant_time_eq;
+
+fn extract_pin_cookie(cookie_header: &str) -> Option {
+ for pair in cookie_header.split(';') {
+ let pair = pair.trim();
+ if let Some(rest) = pair.strip_prefix("pin=") {
+ return Some(rest.trim().to_string());
+ }
+ }
+ None
+}
+
+fn is_authorized_like(cookie_value: Option<&str>, header_value: Option<&str>, pin: &str) -> bool {
+ match (cookie_value, header_value) {
+ (Some(cookie), _) => {
+ let trimmed = cookie.trim();
+ !trimmed.is_empty() && constant_time_eq(trimmed.as_bytes(), pin.as_bytes())
+ }
+ (None, Some(hdr)) => {
+ let trimmed = hdr.trim();
+ !trimmed.is_empty() && constant_time_eq(trimmed.as_bytes(), pin.as_bytes())
+ }
+ (None, None) => false,
+ }
+}
+
+fuzz_target!(|data: &[u8]| {
+ if data.len() < 2 {
+ return;
+ }
+ let split = (data[0] as usize) % data.len();
+ let cookie_part = std::str::from_utf8(&data[..split]).unwrap_or("");
+ let header_part = std::str::from_utf8(&data[split..]).unwrap_or("");
+
+ let pin_len = ((data[0] as usize) % 16) + 1;
+ let pin: String = (0..pin_len)
+ .map(|i| {
+ let idx = (split + i) % data.len();
+ data[idx] as char
+ })
+ .collect();
+
+ let cookie_match = extract_pin_cookie(cookie_part);
+ let auth_cookie = cookie_match.as_deref();
+ let auth_header = if header_part.is_empty() {
+ None
+ } else {
+ Some(header_part)
+ };
+
+ let authorized = is_authorized_like(auth_cookie, auth_header, &pin);
+
+ let cookie_matches = if let Some(c) = auth_cookie {
+ !c.is_empty() && constant_time_eq(c.as_bytes(), pin.as_bytes())
+ } else {
+ false
+ };
+ let header_matches = if let Some(h) = auth_header {
+ !h.is_empty() && constant_time_eq(h.as_bytes(), pin.as_bytes())
+ } else {
+ false
+ };
+ let expected = cookie_matches || header_matches;
+ assert_eq!(
+ authorized, expected,
+ "auth mismatch: cookie={:?} header={:?} pin={:?}",
+ auth_cookie, auth_header, pin
+ );
+
+ let long_cookie = format!("pin={}; other=stuff; pin={}", pin, pin);
+ let extracted = extract_pin_cookie(&long_cookie);
+ assert!(extracted.is_some(), "first pin cookie must be extracted");
+
+ if let Some(c) = &extracted {
+ assert!(c.starts_with(&pin), "first pin cookie must prefix match");
+ }
+
+ let crafted = "pin=; Path=/";
+ let only_empty = extract_pin_cookie(crafted);
+ if let Some(c) = only_empty {
+ assert!(c.is_empty(), "empty pin token must be detected as empty");
+ }
+});
diff --git a/fuzz/fuzz_targets/fuzz_scorer.rs b/fuzz/fuzz_targets/fuzz_scorer.rs
new file mode 100644
index 00000000..5370043a
--- /dev/null
+++ b/fuzz/fuzz_targets/fuzz_scorer.rs
@@ -0,0 +1,197 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum CharStatus {
+ Absent,
+ Present,
+ Correct,
+}
+
+fn get_guess_statuses(solution: &str, guess: &str) -> Vec {
+ let mut sol_chars = ['\0'; 10];
+ let mut sol_len = 0;
+ for (i, c) in solution.chars().enumerate() {
+ if i < 10 {
+ sol_chars[i] = c;
+ sol_len += 1;
+ }
+ }
+
+ let mut guess_chars = ['\0'; 10];
+ let mut guess_len = 0;
+ for (i, c) in guess.chars().enumerate() {
+ if i < 10 {
+ guess_chars[i] = c;
+ guess_len += 1;
+ }
+ }
+
+ let mut solution_chars_taken = [false; 10];
+ let mut statuses = vec![CharStatus::Absent; guess_len];
+
+ for i in 0..guess_len {
+ if i < sol_len && guess_chars[i] == sol_chars[i] {
+ statuses[i] = CharStatus::Correct;
+ solution_chars_taken[i] = true;
+ }
+ }
+
+ for i in 0..guess_len {
+ if statuses[i] == CharStatus::Correct {
+ continue;
+ }
+
+ let letter = guess_chars[i];
+ if !sol_chars[..sol_len].contains(&letter) {
+ statuses[i] = CharStatus::Absent;
+ continue;
+ }
+
+ let mut index_of_present_char = None;
+ for idx in 0..sol_len {
+ if sol_chars[idx] == letter && !solution_chars_taken[idx] {
+ index_of_present_char = Some(idx);
+ break;
+ }
+ }
+
+ if let Some(idx) = index_of_present_char {
+ statuses[i] = CharStatus::Present;
+ solution_chars_taken[idx] = true;
+ } else {
+ statuses[i] = CharStatus::Absent;
+ }
+ }
+
+ statuses
+}
+
+fn get_statuses(solution: &str, guesses: &[String]) -> std::collections::HashMap {
+ let mut char_obj = std::collections::HashMap::new();
+ let mut sol_chars = ['\0'; 10];
+ let mut sol_len = 0;
+ for (i, c) in solution.chars().enumerate() {
+ if i < 10 {
+ sol_chars[i] = c;
+ sol_len += 1;
+ }
+ }
+
+ for word in guesses {
+ let mut word_chars = ['\0'; 10];
+ let mut word_len = 0;
+ for (i, c) in word.chars().enumerate() {
+ if i < 10 {
+ word_chars[i] = c;
+ word_len += 1;
+ }
+ }
+
+ for (i, &letter) in word_chars[..word_len].iter().enumerate() {
+ if !sol_chars[..sol_len].contains(&letter) {
+ char_obj.insert(letter, CharStatus::Absent);
+ continue;
+ }
+
+ if i < sol_len && letter == sol_chars[i] {
+ char_obj.insert(letter, CharStatus::Correct);
+ continue;
+ }
+
+ if char_obj.get(&letter) != Some(&CharStatus::Correct) {
+ char_obj.insert(letter, CharStatus::Present);
+ }
+ }
+ }
+
+ char_obj
+}
+
+fn assert_consistency(solution: &str, guess: &str) {
+ if solution.is_empty() {
+ return;
+ }
+
+ let statuses = get_guess_statuses(solution, guess);
+
+ if guess.is_empty() {
+ assert!(statuses.is_empty());
+ return;
+ }
+
+ let guess_chars: Vec = guess.chars().take(10).collect();
+ let sol_chars: Vec = solution.chars().take(10).collect();
+
+ if statuses.len() != guess_chars.len() {
+ assert!(
+ statuses.len() == guess_chars.len(),
+ "statuses length mismatch: got {} expected {}",
+ statuses.len(),
+ guess_chars.len()
+ );
+ }
+
+ for (i, &c) in sol_chars.iter().enumerate() {
+ if i < guess_chars.len() && guess_chars[i] == c {
+ assert_eq!(
+ statuses[i],
+ CharStatus::Correct,
+ "position {i} should be Correct for solution {solution} guess {guess}"
+ );
+ }
+ }
+
+ let mut solved = true;
+ for (i, &c) in sol_chars.iter().enumerate() {
+ if i < guess_chars.len() && guess_chars[i] != c {
+ solved = false;
+ break;
+ }
+ if i >= guess_chars.len() {
+ solved = false;
+ break;
+ }
+ }
+
+ if guess_chars.len() == sol_chars.len() && solved {
+ let all_correct = statuses.iter().all(|s| *s == CharStatus::Correct);
+ assert!(
+ all_correct,
+ "fully correct guess must produce all Correct statuses, got {statuses:?}"
+ );
+ }
+
+ for (i, &status) in statuses.iter().enumerate() {
+ if i < sol_chars.len() && guess_chars[i] == sol_chars[i] {
+ assert_eq!(
+ status,
+ CharStatus::Correct,
+ "Correct position {i} wrongly classified as {status:?}"
+ );
+ }
+ }
+
+ let _: std::collections::HashMap =
+ get_statuses(solution, &[guess.to_string()]);
+}
+
+fuzz_target!(|data: &[u8]| {
+ let s = String::from_utf8_lossy(data);
+ let parts: Vec<&str> = s.split('\n').take(2).collect();
+ if parts.len() < 2 {
+ return;
+ }
+ let solution = parts[0];
+ let guess = parts[1];
+
+ assert_consistency(solution, guess);
+
+ let chars: Vec = solution.chars().collect();
+ let repeated: String = chars.iter().cycle().take(20).collect();
+ assert_consistency(solution, &repeated);
+
+ let noisy = format!("{}{}", guess, s);
+ let _ = get_guess_statuses(solution, &noisy);
+});