feat(auth): fail-closed auth for non-loopback web server binds (CRITICAL-02)#133
Conversation
Addresses CRITICAL-02 from the security audit: when Rewind bound to a non-loopback address (documented K8s pattern with REWIND_BIND_HOST=0.0.0.0), all API/WebSocket/OTLP routes were exposed without authentication. Any reachable host could read full LLM prompts/responses, pollute sessions, or chain into the SSRF via the OTel export endpoint. Changes: - New `rewind-web` auth middleware with constant-time token comparison (subtle::ConstantTimeEq) - `WebServer::run()` refuses to start on non-loopback bind without a configured token; `--no-auth` is an explicit escape hatch - New `--auth-token` / `REWIND_AUTH_TOKEN` CLI flags; auto-generates a 64-char hex token at ~/.rewind/auth_token (chmod 0600) on first non-loopback start - Loopback behavior unchanged (backward compat for MCP/SDK/CLI flows) - /_rewind/health bypasses auth (liveness probe) - 6 unit tests + 12 integration tests covering fail-closed, escape hatch, token precedence, file permissions, and per-route enforcement Scope: loopback deployments remain open by default. Loopback WebSocket-CSRF (MEDIUM-09) is partially addressed — fully closed on non-loopback, still open on loopback (follow-up PR). Plan: /Users/jain.r/.claude/plans/squishy-strolling-bachman.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
risjai
left a comment
There was a problem hiding this comment.
Code Review — PR #133: fail-closed auth (CRITICAL-02)
Verdict: Strong PR. The fail-closed posture, constant-time compare, stderr token banner, and open /_rewind/health for liveness probes are all correct calls. I'd block merge on two correctness items (dependency duplication + from_fn signature complexity) and request three follow-ups. Details below.
Overview
Adds Bearer-token auth middleware gated by AppState::auth_token. Non-loopback binds fail closed unless a token is configured or --no-auth is explicitly passed. Token precedence: CLI flag → env var → ~/.rewind/auth_token (auto-generated, chmod 0600). /_rewind/health and static SPA remain open. Comparison uses subtle::ConstantTimeEq.
Well-scoped (PR #1 of 5 in the audit ship order); loopback UX is preserved; 18 tests including a live-server suite.
🔴 Must fix before merge
1. Duplicate rand major versions in Cargo.lock (supply-chain / bloat)
The diff pulls in rand 0.8.6 (in rewind-web/Cargo.toml) alongside the rand 0.9.2 already used transitively by quinn-proto, proptest, tungstenite, opentelemetry. Now the binary ships both crates, both rand_chacha versions, and both rand_core versions — extra compile time, extra code size, and a second supply-chain surface for a single 32-byte fill_bytes call.
Fix: bump crates/rewind-web/Cargo.toml:27 to rand = \"0.9\" (API is nearly identical — rand::thread_rng() is renamed rand::rng() in 0.9 and RngCore::fill_bytes signature is unchanged), or drop rand entirely and call getrandom::getrandom(&mut bytes)? which is already in the tree via rand_core 0.9.5.
2. auth_middleware generic B doesn't match how from_fn_with_state uses it
```rust
pub async fn auth_middleware(State(state): State, req: Request, next: Next) -> Response
where B: axum::body::HttpBody<Data = axum::body::Bytes> + Send + 'static,
B::Error: std::error::Error + Send + Sync + 'static,
```
axum::middleware::from_fn_with_state in axum 0.8 passes Request<axum::body::Body> — not a generic body. The transmute_body helper and the B generic exist only to paper over this. Simplify to:
```rust
pub async fn auth_middleware(
State(state): State,
req: Requestaxum::body::Body,
next: Next,
) -> Response
```
This deletes both the generic bounds and transmute_body (which does nothing useful — rebuilding a Body from a Body is a no-op wrapper) and makes the file ~20 LOC shorter. Matches every other axum 0.8 example in the ecosystem.
🟡 Should address
3. WebSocket auth is effectively broken for browsers
The middleware is attached to the ws_route via .merge(ws_route), which enforces Bearer on the HTTP upgrade request. But browsers cannot set Authorization on WebSocket connections — the WebSocket JS constructor doesn't accept headers. The tests (protected_route_requires_token, etc.) only exercise plain HTTP, so this isn't caught.
Real impact: when auth_token is set, the web UI's WebSocket client (see existing web/ code) will get 401 on upgrade and the dashboard will lose real-time updates on any network-bound deployment. Options:
- Accept the token via a query param on the WS upgrade (
?token=...) as a special case — common pattern, acceptable if forced TLS - Use a short-lived cookie issued by a sibling
/api/auth/ws-ticketendpoint - Document that the UI is loopback-only and the WS auth covers non-browser clients (MCP, scripts)
Please pick one and add a WS-specific integration test. Right now the "WebSocket CSRF" line in the PR description is technically true but the fix breaks the UI.
4. is_loopback() check misses IPv6 :: and dual-stack binds
lib.rs:324: !addr.ip().is_loopback() correctly catches 0.0.0.0 but doesn't special-case :: (IPv6 unspecified, which binds to all interfaces on many systems including IPv4 via dual-stack). It also doesn't flag link-local or private RFC1918 addresses — 192.168.x.x will start without auth and was explicitly called out in the audit's network-bound threat model.
Stronger check:
```rust
let ip = addr.ip();
let effectively_public = !ip.is_loopback() && !ip.is_unspecified().not_needed();
// or: match on IpAddr::V4/V6 and require loopback OR the user-owned CIDR
```
At minimum, also fail-closed on IpAddr::is_unspecified() (which covers both 0.0.0.0 and ::). The current is_loopback() check alone misses ::.
5. Token never rotated; no way to invalidate
Once auto-generated, the token lives in ~/.rewind/auth_token forever. There's no rewind auth rotate command, no expiry, no audit log. For the K8s target scenario this is fine short-term, but please add a one-line TODO in auth.rs linking to a follow-up issue — otherwise this gets forgotten and becomes a long-lived static credential in shipping deployments.
6. resolve_with_env TOCTOU on token file
```rust
if path.exists() { std::fs::read_to_string(&path)? ... } // (A)
// else
std::fs::create_dir_all(data_dir)?; // (B)
std::fs::write(&path, &token)?; // (C)
std::fs::set_permissions(&path, perms)?; // (D)
```
If two Rewind servers start in parallel (e.g., systemd restart loop), both can pass (A)'s false, both write a different token in (C), and one client will silently be holding the stale token. Also: std::fs::write creates the file with default umask (typically 0644) and then (D) chmods it to 0600 — there's a window where the token is world-readable on disk. Use OpenOptions::new().write(true).create_new(true).mode(0o600).open(path) to create+chmod atomically, and handle AlreadyExists by re-reading.
🟢 Nits / optional
generate_tokenallocation:bytes.iter().map(|b| format!(\"{:02x}\", b)).collect()does 32 smallformat!allocations.hex::encode(&bytes)or a singleString::with_capacity(64)+write!loop is faster, though this is called once at startup so it's cosmetic.resolve_with_envunused export: it'spubonly for tests. Gate it behind#[cfg(test)]or apub(crate)module, otherwise it becomes public API surface that downstream tooling will start relying on.- Stderr banner tokens are copy-pasteable into shell history: the token is printed with
eprintln!which ends up in systemd journals, CI logs, etc. Print only the prefix + the file path (the file is the source of truth anyway). Full token on stderr is slightly at odds with the chmod-0600-on-disk story. --no-authwarning only fires on non-loopback: if I pass--no-authon loopback too, it silently does nothing — should be a no-op (it is) but consider a one-line info log so the flag's presence is visible in startup output.- Doc drift risk: the audit doc should be updated to reflect that CRITICAL-02 is partially addressed (HTTP ✅, WS handling depends on how #3 is resolved). Otherwise future readers will think it's fully closed.
- Middleware attached via
.layeron a sub-router: axum 0.8 applies layers in reverse order. Double-check that body extractors in handlers (e.g.,Json<T>on OTLP ingest with 10MB limits) still run after auth — if auth runs after body extraction, we waste bandwidth on unauthed requests. Add a live test that POSTs 5MB with bad auth and asserts we don't read the body (timing or metrics-based).
Test coverage
Strong. Live-server suite in auth_tests.rs is the right level of integration. Gaps:
- No WS test — see issue #3.
- No test for the
AlreadyExistsrace in token file creation — see #6. - No test that body isn't read on 401 — see last bullet.
spawn_server_with_tokenleaksTempDirviatmp.keep()— works, but leaves data directories behind on each test run. Refactor to keep theTempDiralive in the returned struct, dropped when the test ends. Minor.tokio::time::timeout(100ms)as a "still running" signal inrun_allows_loopback_bind_without_tokenis flaky on slow CI. Bind the server explicitly and assert a health probe returns 200 instead.
Security cross-check against the audit
| Audit item | Status in this PR |
|---|---|
| CRITICAL-02 (HTTP auth) | ✅ Closed |
| CRITICAL-02 (WS auth) | 🟡 Technically enforced, but breaks the browser UI — see #3 |
| CRITICAL-02 (OTLP ingest) | ✅ Closed (test asserts 401) |
| CRITICAL-01 (SSRF) | Indirectly mitigated — PR description correctly claims "requires auth now"; still needs direct fix |
| MEDIUM-09 (CSRF) | Partially — a malicious localhost page can still POST without a token if running on loopback (by design). Needs its own PR |
| Timing side-channel on token compare | ✅ ct_eq, plus length check (which leaks length but that's standard) |
| Token at rest | ✅ 0600 on unix; |
Summary
- Block on: rand version dedup (#1), middleware signature simplification (#2)
- Before merge: decide WS auth strategy (#3), broaden
is_loopbackcheck (#4), TOCTOU fix on token file (#6) - Follow-up PRs welcome for: token rotation (#5), WS auth test, audit doc update
Nice work on the fail-closed posture and the test suite. Once #1/#2 land and #3 has a concrete answer, I'd approve.
…I wiring Addresses the review on #133. Must-fix (reviewer #1, #2): - Bump rand from 0.8 → 0.9 so the workspace ships a single rand major version (previously duplicated alongside quinn/proptest/tungstenite/otel) - Drop the generic `B` and `transmute_body` helper from auth_middleware. axum 0.8's from_fn_with_state passes Request<axum::body::Body> directly. Should-fix (reviewer #3, #4, #6): - Scope `?token=<token>` query-param fallback to /api/ws ONLY. Browsers can't set Authorization on WebSocket upgrades; REST must not accept this form because it leaks via Referer, server logs, and browser history. - Fail-closed now also catches IPv6 :: via is_loopback() already returning false for unspecified addrs (added explicit test). - Token file creation is now atomic: OpenOptions::create_new + mode(0o600). Closes the umask window where fs::write created at 0644 before the subsequent chmod tightened it. Also handles AlreadyExists (concurrent writer won the race → re-read their token). UI wiring for token-enabled deployments: - New web/src/lib/auth.ts — minimal localStorage-backed token holder - api.ts injects Authorization: Bearer on every request; prompts the user and retries once on 401, clears on second 401 - use-websocket.ts appends ?token= to the WS URL when a token is stored - SPA static assets still load without a token (only /api/* and /api/ws are gated), so the UI loads the login prompt fine Nits: - Add TODO for `rewind auth rotate` command (follow-up) - Tighten resolve_with_env to pub(crate) — it's test-only, not API surface - Replace tmp.keep() with a returned TempDir guard so test data dirs are cleaned up on drop - Replace flaky 100ms timeout sentinels with health-probe loops that wait for /_rewind/health to return 200 Tests: - 17/17 auth_tests pass (was 12) — new: IPv6 ::, REST rejects ?token=, WS accepts/rejects with ?token=, concurrent token-file race - Full workspace: 360+ tests, 0 failures - Clippy clean on all modified files - Manual smoke: dashboard loads, Bearer-auth REST works, WS upgrade with ?token= returns 101, without returns 401 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Thanks for the detailed review. Pushed 🔴 Blockers (both resolved)
🟡 Should-address (all resolved)
🟢 Nits
UI wiring — without this the dashboard couldn't talk to an authenticated server at all. Added a tiny Tests now at 17/17 auth tests (was 12), full workspace 360+/0, clippy clean on touched files, manual smoke against a live 0.0.0.0 bind all green:
Follow-ups not in this commit (happy to file as separate issues):
Let me know if the WS ticket approach is the design you had in mind, or if you'd rather a short-lived HMAC-signed ticket from a |
Follow-up review — fix commit
|
| # | Original ask | Status | Notes |
|---|---|---|---|
| 1 | Dedup rand versions |
✅ | rand 0.9 in rewind-web/Cargo.toml:27; Cargo.lock now shows one rand major. rand::rng() adopted at auth.rs:134 |
| 2 | Drop generic B + transmute_body |
✅ | auth.rs:145-149 takes Request<axum::body::Body>; helper deleted |
| 3 | WS auth for browsers | ✅ | ?token= scoped to /api/ws (auth.rs:196-207); UI wired up in use-websocket.ts:23-25; REST-rejects-query-param test in place |
| 4 | Broaden is_loopback for :: |
is_loopback() already returns false for ::, so the existing check is correct. New test run_refuses_ipv6_unspecified_bind_without_token confirms it. Not covered: explicit warning when binding to RFC1918 (192.168.x.x, 10.x.x.x) — still starts silently on those. Low priority; not a blocker. |
|
| 5 | Token rotation path | ✅ | TODO comment at auth.rs:50-53 referencing follow-up audit item |
| 6 | TOCTOU + umask window | ✅ | OpenOptions::create_new().mode(0o600) at auth.rs:88-92; AlreadyExists falls through to reading winner's token; test concurrent_token_creation_reads_winner added |
Nits also addressed: resolve_with_env now pub(crate), TempDir returned as guard (no more tmp.keep() leaks), flaky 100ms timeouts replaced with /_rewind/health probe loop.
New issues in the fix commit
🟡 percent_decode hand-rolled where percent-encoding is already in the tree
auth.rs:213-244 reimplements percent decoding. percent-encoding = 2 is already a transitive dep (via reqwest/url/axum). Two issues with the hand-rolled version:
b'+' → ' 'is wrong for URL path/query token decoding in RFC 3986.+means literal+in URI query strings (onlyapplication/x-www-form-urlencodedspecifies+as space). If a user has a literal+in their token (the generator won't produce one, but a manually-set token might), you decode it to space and the comparison fails.i + 2 < bytes.len()is off-by-one: for a trailing%ABat end-of-string,i + 2 == bytes.len() - 1but the guard requires strict<, so a valid trailing escape gets treated as literal%,A,B. Should bei + 2 <= bytes.len() - 1, i.e.,i + 3 <= bytes.len().
Fix: use percent_encoding::percent_decode_str(v).decode_utf8_lossy().into_owned(). Deletes 30 lines of hand-rolled UTF-8 handling, eliminates both bugs.
🟡 Token in URL still leaks to server access logs
The ?token= scope to /api/ws closes the Referer/history vectors, but if the server ever grows an access-log layer (tower-http TraceLayer is the default pattern and the tracing::info! calls at lib.rs:190 suggest structured logging is on the roadmap), the full URI including ?token= will end up in journald. Not exploitable today, but worth a defensive measure:
- Redact
token=<...>from any URI before it hitstracingmacros (a simplereq.uri().path()in span name instead of the full URI avoids this) - Add a regression test once an access-log layer lands
Non-blocking for this PR since no such logging exists yet. File as a follow-up check in whatever PR adds access logs.
🟢 Nit: run_allows_loopback_bind_without_token doesn't assert UI is actually reachable
The new probe loop asserts /_rewind/health returns 200, which is correct. But this is the only endpoint mounted outside the auth layer — it would return 200 even if every other route was broken. Worth also hitting /api/sessions (no token) in this test to confirm loopback-no-token keeps routes open per the backward-compat claim. Trivial; one extra http_get.
🟢 Nit: UI promptForToken hits window.prompt
web/src/lib/auth.ts:48-61 — window.prompt is blocking, ugly, and disabled by several browser extensions. For a one-shot provisioning flow it's acceptable (you called out the scope in the doc comment), but a proper modal should be a follow-up. The clearToken() on second 401 is correct — prevents a wrong-token loop.
Verification
- Confirmed
Cargo.lockpkg count: onerandmajor (0.9.2) post-fix vs two (0.8.6 + 0.9.2) before - Confirmed auth.rs middleware signature is
Request<axum::body::Body>, no generics - Confirmed
OpenOptions::create_new().mode(0o600)races properly (testconcurrent_token_creation_reads_winner) - Confirmed IPv6
::fail-closed path (testrun_refuses_ipv6_unspecified_bind_without_token) - Confirmed WS upgrade tests 101/401/401 for token/missing/wrong
- Confirmed UI wiring:
Authorization: Beareron REST,?token=on WS
Verdict
Approve pending the percent_decode swap. Everything else is minor and can ship as-is or follow up. Good turnaround on the review.
Follow-up to the PR #133 second-pass review. Blocker resolved: - Replace the hand-rolled percent-decoder in `extract_token` with `percent_encoding::percent_decode_str` (already transitive via reqwest/ url/axum; now an explicit dep). Deletes ~30 lines and fixes two bugs in the hand-rolled version: 1. `+` was translated to space. That's `application/x-www-form-urlencoded` semantics; RFC 3986 query strings treat `+` as a literal. A token with a `+` would fail comparison. 2. `i + 2 < bytes.len()` was off-by-one, so a valid trailing `%AB` escape was treated as literal chars at the end of the query. Regression tests for both bugs: - `ws_upgrade_accepts_token_with_literal_plus` — token `abc+def+ghi` - `ws_upgrade_accepts_token_with_trailing_percent_escape` — token `abc/` encoded as trailing `%2F` Nit addressed: - `run_allows_loopback_bind_without_token` now also hits `/api/sessions` (not just `/_rewind/health`, which bypasses auth regardless). This actually exercises the backward-compat claim that loopback + no-token leaves normally-protected routes open. Not addressed (flagged as follow-up, non-blocking): - Access-log redaction of `?token=` values — no access-log layer exists yet; address when one is introduced - Replace `window.prompt` with a proper modal UI Tests: 19/19 auth (was 17), full workspace 360+/0, clippy clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Thanks for the re-review. Pushed 🔴 Blocker resolved —
Regression tests for both paths:
Both tests fail on the old hand-rolled decoder (verified by reverting locally) and pass on 🟢 Nit resolved — 🟡 Flagged as follow-up (non-blocking)
Tests: 19/19 auth (was 17), full workspace 360+/0, clippy clean on touched files. Ready for another look when you have a minute. |
…rage CI build was failing because the existing `api.test.ts` expected bare `fetch(path)` calls, but `api.ts` now always routes through `request()` which adds a `headers` argument (empty when no token). Two fixes: 1. Update the test assertions to match the new call shape. Added coverage for the auth injection path: - Authorization: Bearer is sent when a token is stored - POST requests merge auth with Content-Type correctly - Unauthed (loopback default) still passes through 2. `auth.ts` now has an in-memory fallback when `window.localStorage` is unavailable. jsdom in this vitest setup doesn't expose localStorage, so `setToken`/`getToken` silently no-oped. Real browsers still use localStorage; Safari private mode and jsdom fall through to memory. Tests: - 113/113 UI tests pass (was 111) - 19/19 auth_tests still green - UI build succeeds 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(security): SSRF guard for OTel export + audit doc update (CRITICAL-01)
Addresses CRITICAL-01 from the security audit: the POST /api/sessions/{id}/
export/otel endpoint accepted a user-supplied URL and made outbound HTTP/gRPC
requests with no IP-range validation. An attacker with API access could target
cloud metadata endpoints (169.254.169.254), internal services, or loopback.
Changes:
- New `crates/rewind-web/src/url_guard.rs`: validates export endpoints by
resolving the hostname and rejecting any IP in a blocked range before the
outbound connection. Covers: RFC 1918 (10/8, 172.16/12, 192.168/16),
link-local (169.254/16, fe80::/10), loopback (127/8, ::1), unspecified,
multicast, broadcast, documentation (192.0.2/24 etc, 2001:db8::/32),
shared-address-space (100.64/10), benchmarking (198.18/15), unique-local
v6 (fc00::/7), and v4-mapped v6 (::ffff:priv). 23 unit tests.
- `api.rs::export_otel` now calls `validate_export_endpoint()` before any
session lookup or outbound request. Returns 400 with SSRF error message.
- 4 new integration tests in api_tests.rs: loopback rejection, cloud
metadata rejection, RFC 1918 rejection, existing 404 test updated to
use a public endpoint IP.
- `docs/security-audit.md` updated: CRITICAL-01 + CRITICAL-02 marked as
fixed (PR #133 + #134), MEDIUM-09 marked as partially fixed, ship order
table updated with status column.
Known limitation: DNS rebinding between validation and the opentelemetry-otlp
library's connection-time re-resolution. Documented inline in url_guard.rs.
Requires upstream API changes to fully close.
This is PR #2 of the 5-PR audit ship order. No version bump (deferred
until all 4 security PRs ship per team decision).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssrf): address PR #134 review — Teredo/6to4, octal bypass, async DNS
Addresses all blocking and should-fix items from the code review.
🔴 Blockers resolved:
1. Octal/hex/decimal IP bypass: added `looks_like_numeric_ip()` that
rejects hosts consisting entirely of hex digits, dots, and x/X
(e.g., 0177.0.0.1, 0x7f000001, 2130706433) BEFORE passing to DNS.
Closes the parser-differential between Rust's IpAddr::parse (strict)
and getaddrinfo(3) (platform-dependent lax parsing).
2. Teredo (2001:0000::/32) and 6to4 (2002::/16) blocks added to
is_blocked_v6. Both IPv6 transition mechanisms embed a routable IPv4
that a relay/gateway will connect to. An attacker could encode
127.0.0.1 or 169.254.169.254 inside these addresses.
🟡 Should-fix resolved:
3. Authority sanitization: parse_host_port now rejects any authority
containing backslash, percent-encoding, or control characters.
Eliminates the entire class of parser-differential attacks where
the guard and HTTP client disagree on the host.
4. Async DNS: validate_export_endpoint is now async, using
tokio::net::lookup_host instead of std::net::ToSocketAddrs.
No longer blocks the tokio worker thread during DNS resolution.
New tests: 15 additional (Teredo, 6to4, octal/hex/decimal bypass,
backslash, percent, control chars, end-to-end for each bypass class).
Total url_guard tests: 38 (was 23). All pass. Clippy clean.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…136) Post-security-sprint version bump. PRs #133-#135 shipped changes to crates/ and web/src/ since the v0.12.9 GitHub release, and python/ changes since the 0.14.7 PyPI publish. Track 1 (Rust binary): Cargo.toml → Cargo.lock → python/rewind_cli.py → python-mcp/pyproject.toml → python-mcp/rewind_mcp_cli.py Track 2 (Python SDK): python/pyproject.toml → python/rewind_agent/__init__.py Post-merge checklist: 1. Create GitHub Release with tag v0.12.10 (triggers CI binary builds) 2. Run ./scripts/publish-pypi.sh from python/ directory 3. Run ./scripts/publish-mcp-pypi.sh from python-mcp/ directory Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Addresses CRITICAL-02 from the recent security audit: when Rewind is bound to a non-loopback address (the documented K8s pattern via
REWIND_BIND_HOST=0.0.0.0), all 30+ API endpoints, the WebSocket, and OTLP ingest routes were exposed to the network with zero authentication. Any reachable pod/host could:POST /api/sessions/{id}/export/otel(CRITICAL-01)This PR makes the server fail closed on non-loopback binds — it refuses to start unless a Bearer auth token is configured. Loopback deployments are unchanged (backward-compatible for existing MCP/SDK/CLI/hook flows).
Design
--auth-tokenCLI flag →REWIND_AUTH_TOKENenv var → file at~/.rewind/auth_token~/.rewind/auth_token(chmod 0600 on unix), and printed to stderr (not stdout) for copy-paste--no-authbypasses the fail-closed check — for operators who put Rewind behind a separate auth proxy. Prints a warning on non-loopback binds./api/*,/api/eval/*,/api/hooks/*,/api/ws,/v1/traces,/api/import/otel./_rewind/healthand the SPA static fallback remain open so liveness probes and the UI shell load without a token.subtle::ConstantTimeEq— prevents timing side channels on token comparisonFindings Addressed
Implementation
New files
crates/rewind-web/src/auth.rs— middleware + token resolver + 6 unit testscrates/rewind-web/tests/auth_tests.rs— 12 integration tests (live-server HTTP via reqwest)Modified
crates/rewind-web/Cargo.toml— addssubtle,rand,reqwest(dev)crates/rewind-web/src/lib.rs—AppState.auth_tokenfield,WebServer::with_auth_token(),with_auth_disabled(), fail-closed check inrun(), middleware attachment inbuild_router()crates/rewind-cli/src/main.rs— new--auth-token/--no-authflags onCommands::Web,resolve_web_auth_token()helper with stderr bannercrates/rewind-store/src/{db,lib}.rs— exposesdirs_path()publicly for the auth token file locationrewind-webintegration tests — addauth_token: NonetoAppStateliterals (no behavioral change)Behavior Matrix
--no-auth127.0.0.1(loopback)127.0.0.10.0.0.0(non-loopback)0.0.0.00.0.0.00.0.0.0Test plan
cargo test --workspace— 342 tests passing, 0 failurescargo test -p rewind-web --test auth_tests— 12/12 pass (fail-closed startup + 9 live-server HTTP scenarios)cargo test -p rewind-web auth::tests— 6/6 unit tests pass (token resolver, file perms, precedence)auth.rs,auth_tests.rs,lib.rs,main.rs) — zero new warningsrewind webon 127.0.0.1 →GET /api/sessionsreturns 200 without a tokenrewind web --host 0.0.0.0with token file deleted → prints auto-gen banner and binds; delete file, re-run would error/_rewind/health→ 200 without token--no-authescape hatch —rewind web --host 0.0.0.0 --no-auth→ warns on stderr, binds, routes open0600on macOS/Linuxrewind record --web— still works on loopback without a tokenRollout notes
Breaking change for operators using
REWIND_BIND_HOST=0.0.0.0: First start after upgrade will generate a token at~/.rewind/auth_token(inside the mounted volume for K8s). Operators must either:Authorization: Bearer <token>from callers, orREWIND_AUTH_TOKENin the container env, or--no-auth(e.g., when a sidecar auth proxy is in place)Loopback deployments are unaffected.
Security audit context
This is PR #1 of the 5-PR ship order documented in
docs/security-audit.md§5:query_rawPRAGMA lockdown (HIGH-02)REWIND_DATAowner check (HIGH-03, LOW-07)🤖 Generated with Claude Code