Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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:
- "*"
54 changes: 54 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -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"
77 changes: 77 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -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
81 changes: 75 additions & 6 deletions crates/phase-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -146,6 +147,12 @@ struct Cli {
#[arg(long, env = "PHASE_CORS_ORIGIN")]
cors_origin: Option<String>,

/// 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<String>,

/// Emit logs as JSON (for production log aggregation)
#[arg(long, env = "PHASE_LOG_JSON")]
log_json: bool,
Expand Down Expand Up @@ -683,19 +690,43 @@ async fn main() {
.allow_origin(origin.parse::<HeaderValue>().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<Arc<str>> = 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}",
Expand Down Expand Up @@ -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 <token>`.
async fn require_admin_auth(
State(token): State<Option<Arc<str>>>,
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,
Expand Down
Loading