diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..7e1118c372 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,52 @@ +# Dependabot keeps dependencies patched and surfaces vulnerable transitive +# deps. Updates are grouped so routine minor/patch bumps land as a single PR +# per ecosystem instead of flooding the queue; security updates are always +# raised individually and promptly by Dependabot regardless of these groups. +version: 2 +updates: + # Rust workspace (root Cargo.lock covers all `crates/*` members). + - package-ecosystem: cargo + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + cargo-minor-patch: + update-types: + - minor + - patch + + # Frontend (pnpm). Dependabot reads client/pnpm-lock.yaml. + - package-ecosystem: npm + directory: "/client" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + client-minor-patch: + update-types: + - minor + - patch + + # Cloudflare lobby worker (npm). + - package-ecosystem: npm + directory: "/lobby-worker" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + lobby-worker-minor-patch: + update-types: + - minor + - patch + + # GitHub Actions used by the CI/deploy/release workflows. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + actions-all: + patterns: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..504f9ae62b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,54 @@ +name: CodeQL + +# Static analysis (SAST) for the TypeScript client + Cloudflare worker. +# +# Requires "Code scanning" to be enabled for the repository (Settings → +# Security → Code security). On a fork this also needs Actions enabled, and +# results only upload on a repo with code scanning available (public repos, or +# private repos with GitHub Advanced Security). Rust is intentionally not +# analyzed here — `cargo clippy -D warnings` and `cargo audit` cover the Rust +# side; add a `rust` language entry once the runner's CodeQL supports it. +on: + push: + branches: [main] + paths: + - 'client/**' + - 'lobby-worker/**' + - '.github/workflows/codeql.yml' + pull_request: + branches: [main] + paths: + - 'client/**' + - 'lobby-worker/**' + - '.github/workflows/codeql.yml' + schedule: + - cron: '0 7 * * 1' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (javascript-typescript) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:javascript-typescript" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000000..610c982b3b --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,77 @@ +name: Security + +# Dependency vulnerability scanning. Runs on dependency/lockfile changes, on +# every PR to main, and weekly so advisories filed against otherwise-unchanged +# dependencies are still caught. +on: + push: + branches: [main] + paths: + - 'Cargo.lock' + - 'Cargo.toml' + - 'client/pnpm-lock.yaml' + - 'client/package.json' + - 'lobby-worker/package-lock.json' + - 'lobby-worker/package.json' + - '.github/workflows/security.yml' + pull_request: + branches: [main] + schedule: + # Mondays 07:00 UTC. + - cron: '0 7 * * 1' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + cargo-audit: + name: cargo audit (RUSTSEC advisories) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + cache-shared-key: rust-debug + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-audit + + # Fails the job on known vulnerabilities in the Rust dependency tree. + # Unmaintained-crate notices are warnings and do not fail the build; add + # `--deny warnings` here once the tree is clean if you want to gate those + # too. Use a committed `audit.toml` to ignore individual advisory IDs. + - name: cargo audit + run: cargo audit + + js-audit: + name: npm/pnpm audit + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + # Reported but non-blocking for now: frontend dev-dependency advisories + # are noisy and rarely reach production. Review the job summary and flip + # `continue-on-error` to false (and tune `--audit-level`) to enforce. + - name: pnpm audit (client) + continue-on-error: true + run: cd client && pnpm audit --audit-level high + + - name: npm audit (lobby-worker) + continue-on-error: true + run: cd lobby-worker && npm audit --audit-level=high diff --git a/crates/phase-server/src/main.rs b/crates/phase-server/src/main.rs index ba6bd2d1c7..c8c0b01683 100644 --- a/crates/phase-server/src/main.rs +++ b/crates/phase-server/src/main.rs @@ -10,8 +10,9 @@ use std::sync::Arc; use std::time::Instant; use axum::extract::ws::{Message, WebSocket}; -use axum::extract::{State, WebSocketUpgrade}; -use axum::response::IntoResponse; +use axum::extract::{Request, State, WebSocketUpgrade}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; use clap::Parser; @@ -23,7 +24,7 @@ use engine::game::derived_views::derive_views; use engine::game::validate_name_deck_for_format; use engine::types::game_state::GameState; use engine::types::player::PlayerId; -use http::HeaderValue; +use http::{HeaderValue, StatusCode}; use lobby_broker::{check_build_commit, Broker, BrokerEnv, BuildCommitCheck, ConnState, Outbound}; use seat_reducer::types::{DeckChoice, DeckResolver, ReducerCtx}; use server_core::draft_session::DraftSessionManager; @@ -146,6 +147,12 @@ struct Cli { #[arg(long, env = "PHASE_CORS_ORIGIN")] cors_origin: Option, + /// Bearer token gating all `/admin/*` endpoints. When unset, the admin + /// endpoints are disabled entirely (they return 404) so they are never + /// reachable unauthenticated. Set to a high-entropy secret in production. + #[arg(long, env = "PHASE_ADMIN_TOKEN")] + admin_token: Option, + /// Emit logs as JSON (for production log aggregation) #[arg(long, env = "PHASE_LOG_JSON")] log_json: bool, @@ -683,19 +690,43 @@ async fn main() { .allow_origin(origin.parse::().expect("invalid CORS origin")), }; + // Operator bearer token for `/admin/*`. Empty/whitespace is treated as + // unset so a blank env var can't accidentally authorize every request. + let admin_token: Option> = cli + .admin_token + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(Arc::from); + if admin_token.is_none() { + info!("PHASE_ADMIN_TOKEN unset — /admin/* endpoints are disabled (404)"); + } + // Keep references for shutdown flush (Arcs are cheap to clone) let shutdown_state = state.clone(); let shutdown_draft_state = draft_sessions.clone(); let shutdown_game_db = game_db.clone(); - let app = Router::new() - .route("/ws", get(ws_handler)) - .route("/health", get(health)) + // Admin routes expose player reconnect tokens and force-delete, so they are + // gated behind the bearer-token middleware. `route_layer` runs the guard + // only on matched admin paths (unknown paths still 404 without auth work). + let admin_routes = Router::new() .route("/admin/drafts", get(admin::admin_list_drafts)) .route( "/admin/drafts/{code}", get(admin::admin_get_draft).delete(admin::admin_delete_draft), ) + .route_layer(middleware::from_fn_with_state( + admin_token, + require_admin_auth, + )); + + let app = Router::new() + .route("/ws", get(ws_handler)) + .route("/health", get(health)) + .merge(admin_routes) + // P2P backup endpoints are client-facing (the P2P draft host uploads + // snapshots), so they are not behind the operator token. .route("/p2p-draft-backup", post(admin::p2p_backup_store)) .route( "/p2p-draft-backup/{code}", @@ -798,6 +829,44 @@ async fn health() -> &'static str { "ok" } +/// Constant-time byte comparison, used so the admin token check does not leak +/// the secret through response timing. Length is allowed to short-circuit +/// (token length is not sensitive); the bytes themselves are compared in full. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +/// Bearer-token gate for `/admin/*`. Fails closed: when no token is configured +/// the endpoints behave as if they do not exist (404), so administrative draft +/// inspection/deletion (which exposes player reconnect tokens) is never +/// reachable unauthenticated. With a token configured, the request must carry +/// `Authorization: Bearer `. +async fn require_admin_auth( + State(token): State>>, + req: Request, + next: Next, +) -> Response { + let Some(expected) = token.as_deref() else { + return (StatusCode::NOT_FOUND, "Not found").into_response(); + }; + let provided = req + .headers() + .get(http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")); + match provided { + Some(tok) if constant_time_eq(tok.as_bytes(), expected.as_bytes()) => next.run(req).await, + _ => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), + } +} + #[derive(Clone)] struct AppState { sessions: SharedState,