Skip to content

feat(auth): fail-closed auth for non-loopback web server binds (CRITICAL-02)#133

Merged
risjai merged 4 commits into
masterfrom
feat/fail-closed-auth
Apr 21, 2026
Merged

feat(auth): fail-closed auth for non-loopback web server binds (CRITICAL-02)#133
risjai merged 4 commits into
masterfrom
feat/fail-closed-auth

Conversation

@risjai

@risjai risjai commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

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:

  • List all recorded sessions and read full LLM prompts/responses (may contain API keys, PII, proprietary code)
  • Chain into the SSRF via POST /api/sessions/{id}/export/otel (CRITICAL-01)
  • Pollute or suppress hook events
  • Execute SQL via the MCP surface

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

  • Token precedence: --auth-token CLI flag → REWIND_AUTH_TOKEN env var → file at ~/.rewind/auth_token
  • Auto-generation: On first non-loopback start with no token, a 64-char hex token is generated, written to ~/.rewind/auth_token (chmod 0600 on unix), and printed to stderr (not stdout) for copy-paste
  • Escape hatch: --no-auth bypasses the fail-closed check — for operators who put Rewind behind a separate auth proxy. Prints a warning on non-loopback binds.
  • Scope: Middleware is attached to /api/*, /api/eval/*, /api/hooks/*, /api/ws, /v1/traces, /api/import/otel. /_rewind/health and the SPA static fallback remain open so liveness probes and the UI shell load without a token.
  • Constant-time comparison: subtle::ConstantTimeEq — prevents timing side channels on token comparison

Findings Addressed

Audit ID Title Status
CRITICAL-02 Full API exposure without auth when network-bound ✅ Fully addressed
CRITICAL-01 SSRF via OTel export endpoint ⏳ Indirectly mitigated (requires auth now); direct fix planned in PR 2
MEDIUM-09 WebSocket CSRF via missing Origin 🟡 Partially addressed (closed on non-loopback; loopback CSRF is follow-up)

Implementation

New files

  • crates/rewind-web/src/auth.rs — middleware + token resolver + 6 unit tests
  • crates/rewind-web/tests/auth_tests.rs — 12 integration tests (live-server HTTP via reqwest)

Modified

  • crates/rewind-web/Cargo.toml — adds subtle, rand, reqwest (dev)
  • crates/rewind-web/src/lib.rsAppState.auth_token field, WebServer::with_auth_token(), with_auth_disabled(), fail-closed check in run(), middleware attachment in build_router()
  • crates/rewind-cli/src/main.rs — new --auth-token / --no-auth flags on Commands::Web, resolve_web_auth_token() helper with stderr banner
  • crates/rewind-store/src/{db,lib}.rs — exposes dirs_path() publicly for the auth token file location
  • Four existing rewind-web integration tests — add auth_token: None to AppState literals (no behavioral change)

Behavior Matrix

Bind Token --no-auth Result
127.0.0.1 (loopback) none no ✅ Starts, routes open (backward compat)
127.0.0.1 set no ✅ Starts, routes require token
0.0.0.0 (non-loopback) none no Refuses to start (fail-closed)
0.0.0.0 none no (first run) ✅ Auto-generates token, routes require it
0.0.0.0 set no ✅ Starts, routes require token
0.0.0.0 none yes ⚠️ Starts with warning, routes open (escape hatch)

Test plan

  • cargo test --workspace — 342 tests passing, 0 failures
  • cargo 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)
  • Clippy on touched files (auth.rs, auth_tests.rs, lib.rs, main.rs) — zero new warnings
  • Manual: loopback default preservedrewind web on 127.0.0.1 → GET /api/sessions returns 200 without a token
  • Manual: fail-closedrewind web --host 0.0.0.0 with token file deleted → prints auto-gen banner and binds; delete file, re-run would error
  • Manual: token enforcement — unauth → 401, wrong token → 401, correct token → 200, /_rewind/health → 200 without token
  • Manual: --no-auth escape hatchrewind web --host 0.0.0.0 --no-auth → warns on stderr, binds, routes open
  • Manual: file perms — generated token file is 0600 on macOS/Linux
  • Manual: rewind record --web — still works on loopback without a token

Rollout 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:

  1. Read the token from the file and pass it as Authorization: Bearer <token> from callers, or
  2. Pre-set REWIND_AUTH_TOKEN in the container env, or
  3. Explicitly opt out with --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:

  1. This PR — Fail-closed auth (CRITICAL-02, partial MEDIUM-09)
  2. ⏳ SSRF allowlist in OTel export (CRITICAL-01)
  3. ⏳ Blob redaction + hop-by-hop header filtering (HIGH-01, HIGH-06, MEDIUM-06, MEDIUM-08)
  4. query_raw PRAGMA lockdown (HIGH-02)
  5. ⏳ Filesystem permissions + REWIND_DATA owner check (HIGH-03, LOW-07)

🤖 Generated with Claude Code

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>
@vercel

vercel Bot commented Apr 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
rewind Ready Ready Preview, Comment Apr 21, 2026 5:10am

@risjai risjai left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-ticket endpoint
  • 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_token allocation: bytes.iter().map(|b| format!(\"{:02x}\", b)).collect() does 32 small format! allocations. hex::encode(&bytes) or a single String::with_capacity(64) + write! loop is faster, though this is called once at startup so it's cosmetic.
  • resolve_with_env unused export: it's pub only for tests. Gate it behind #[cfg(test)] or a pub(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-auth warning only fires on non-loopback: if I pass --no-auth on 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 .layer on 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:

  1. No WS test — see issue #3.
  2. No test for the AlreadyExists race in token file creation — see #6.
  3. No test that body isn't read on 401 — see last bullet.
  4. spawn_server_with_token leaks TempDir via tmp.keep() — works, but leaves data directories behind on each test run. Refactor to keep the TempDir alive in the returned struct, dropped when the test ends. Minor.
  5. tokio::time::timeout(100ms) as a "still running" signal in run_allows_loopback_bind_without_token is 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; ⚠️ umask window (#6); ⚠️ warn-only on non-unix

Summary

  • Block on: rand version dedup (#1), middleware signature simplification (#2)
  • Before merge: decide WS auth strategy (#3), broaden is_loopback check (#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>
@risjai

risjai commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the detailed review. Pushed db053a9 addressing all must-fix + should-fix items plus the nits. Summary:

🔴 Blockers (both resolved)

🟡 Should-address (all resolved)

  • Add MCP server for AI assistant integration #3 WebSocket auth — went with the ticket-via-query-param route you suggested. Token is accepted via ?token=<tok> only on /api/ws (explicitly rejected on every other path to avoid Referer / server-log leakage). Added 3 new WS tests + 1 REST-rejects-?token= test. UI updated: web/src/hooks/use-websocket.ts appends the token when one is stored in localStorage.
  • Add direct recording mode — no proxy needed #4 is_loopback misses :: — confirmed via a one-off test that IpAddr::is_loopback() already returns false for both 0.0.0.0 and ::, so the current check catches both. Added an explicit run_refuses_ipv6_unspecified_bind_without_token regression test. (Your is_unspecified() suggestion would be looser than current behavior, which already treats 192.168.x.x etc. as non-loopback — so I left the check as-is.)
  • Bump Python SDK to v0.3.0 #5 rotation TODO — added inline in auth.rs with a pointer to a follow-up MEDIUM-10.
  • Migrate repo references to agentoptics org #6 TOCTOU + umask window — replaced fs::write + set_permissions with OpenOptions::new().create_new(true).mode(0o600).open(). On AlreadyExists we re-read the winner's token instead of panicking. Added a unit test for the second-caller-reads-winner path.

🟢 Nits

  • resolve_with_envpub(crate).
  • tmp.keep() leak → helper now returns (SocketAddr, TempDir) and all call sites hold the guard.
  • 100ms flaky timeout → replaced with a /_rewind/health probe loop.

UI wiring — without this the dashboard couldn't talk to an authenticated server at all. Added a tiny web/src/lib/auth.ts (localStorage + prompt on 401 + retry once) and wired it through api.ts and use-websocket.ts. SPA static assets remain open so the login prompt can render.

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:

  • curl http://...?token=... → 401 on REST ✓
  • curl -H "Authorization: Bearer ..." http://... → 200 ✓
  • curl -H "Upgrade: websocket" http://.../api/ws?token=..."101 Switching Protocols
  • curl -H "Upgrade: websocket" http://.../api/ws401 Unauthorized
  • Token file stat600

Follow-ups not in this commit (happy to file as separate issues):

  • Body-not-read-on-401 test (your last-bullet nit) — axum middleware runs before handler body extraction by default, but an explicit test would be good.
  • Ship-order PR Add CLI auto-bootstrap to pip install #2 (SSRF allowlist) is still next.

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 /api/auth/ws-ticket endpoint. Happy to swap.

@risjai

risjai commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up review — fix commit db053a9

Re-reviewed against the original review. All 6 must-fix/should-fix items addressed. Two small regressions flagged, otherwise approve.

Status vs original review

# 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 :: ⚠️ Partial 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:

  1. b'+' → ' ' is wrong for URL path/query token decoding in RFC 3986. + means literal + in URI query strings (only application/x-www-form-urlencoded specifies + 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.
  2. i + 2 < bytes.len() is off-by-one: for a trailing %AB at end-of-string, i + 2 == bytes.len() - 1 but the guard requires strict <, so a valid trailing escape gets treated as literal %, A, B. Should be i + 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 hits tracing macros (a simple req.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-61window.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.lock pkg count: one rand major (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 (test concurrent_token_creation_reads_winner)
  • Confirmed IPv6 :: fail-closed path (test run_refuses_ipv6_unspecified_bind_without_token)
  • Confirmed WS upgrade tests 101/401/401 for token/missing/wrong
  • Confirmed UI wiring: Authorization: Bearer on 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>
@risjai

risjai commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the re-review. Pushed e9ea072 with the blocker fix + one nit.

🔴 Blocker resolved — percent_decode swap
Replaced the hand-rolled decoder with percent_encoding::percent_decode_str (now explicit dep in rewind-web/Cargo.toml). The 30-line custom implementation is gone. Both bugs you identified are fixed:

  1. + → space is no longer translated; percent_decode_str implements RFC 3986 query semantics correctly.
  2. Off-by-one on trailing %XXpercent-encoding handles arbitrary-length boundaries correctly.

Regression tests for both paths:

  • ws_upgrade_accepts_token_with_literal_plus — token abc+def+ghi sent raw in the query → 101
  • ws_upgrade_accepts_token_with_trailing_percent_escape — token abc/ sent as abc%2F (trailing escape) → 101

Both tests fail on the old hand-rolled decoder (verified by reverting locally) and pass on e9ea072.

🟢 Nit resolved — run_allows_loopback_bind_without_token
The test now also hits /api/sessions unauthenticated (not just /_rewind/health, which is open regardless). This actually exercises the backward-compat guarantee.

🟡 Flagged as follow-up (non-blocking)

  • Access-log redaction of ?token= — no tower-http TraceLayer exists yet, so no leakage today. Will address when access-logging lands (noted in my task list, not this PR).
  • window.prompt UI — acceptable for the one-shot provisioning flow; a proper modal is a frontend follow-up.

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>
@risjai
risjai enabled auto-merge (squash) April 21, 2026 05:15
@risjai
risjai merged commit 5d1d012 into master Apr 21, 2026
7 checks passed
@risjai
risjai deleted the feat/fail-closed-auth branch April 21, 2026 05:15
risjai added a commit that referenced this pull request Apr 21, 2026
* 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>
risjai added a commit that referenced this pull request Apr 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant