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); +});