Skip to content

harden(team-server): full audit-driven security pass (round 1 + round 2) - #2

Merged
chovizzz merged 24 commits into
mainfrom
harden/team-server-round2
Jul 13, 2026
Merged

harden(team-server): full audit-driven security pass (round 1 + round 2)#2
chovizzz merged 24 commits into
mainfrom
harden/team-server-round2

Conversation

@chovizzz

@chovizzz chovizzz commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Two adversarial-review passes over the team-server / shared-crate / launcher
sync stack, each finding closed under a fresh Codex audit, plus follow-on
features and correctness fixes that complete the snapshot's cross-machine
coverage. Every item was implemented, built, tested, and re-reviewed until
Codex reported no findings.

Launcher-side sync correctness (latest commits)

  • Don't check in a shared env that was never checked out (f916108). A
    remote-bound profile launched before the team server was configured never
    acquires a lock; if the server was then configured mid-session, the exit hook
    ran a doomed empty-token checkin and marked the profile pending_push — which
    then blocked the next launch with a spurious "un-pushed changes" error.
    push now refuses (returning a downcastable NoCheckout marker) before any
    lease/pack/upload; checkin_on_exit classifies that marker as "nothing to
    check in" (no pending flag) vs a real failure — which also closes the TOCTOU
    a pre-check would have against a concurrent release/discard. set_checkout_state
    now propagates its save error so pull/retry_push fail closed (releasing the
    in-hand token) if the acquired token can't be persisted, instead of letting the
    session's changes be silently overwritten by the next pull.
  • Fail closed on a missing snapshot integrity header (438d316). The pulled
    snapshot carries decrypted cookies, saved passwords, and card numbers; the
    sha256 check was skipped whenever x-snapshot-sha256 was absent, so a proxy
    that stripped it (or the allowed plain-HTTP path) could hand unverified bytes
    to unpack. Download now requires a present, well-formed (64-char hex) header,
    verifies unconditionally, and accepts only 200 OK.

Follow-on: saved-password cross-machine sync

Login Data was the last machine-key-encrypted store the snapshot dropped — it
was excluded outright and PortableState.logins stayed empty, so saved
passwords were lost on every cross-machine pull. Now ported via the same "raw DB
travels + rekey in place" path as Web Data (not the cookies exclude+rebuild
path — logins carries many version-varying columns Chromium owns): at pack
password_value is decrypted into the portable state keyed by SQLite rowid;
at unpack it's re-sealed with the destination os_crypt key in place. The raw DB
(+ -wal/-shm) travels; the account-bound Login Data For Account
(+ sidecars) stays excluded (re-syncs from the signed-in account).

Fail-closed throughout (per Codex review): a non-empty v10 blob that won't
decrypt aborts the pack; reencrypt_in_place requires the carried rowids to be
a perfect bijection with the DB's non-empty password rows (checked in the write
txn) — a duplicate, a missing row, or a DB absent while passwords remain all
abort before any UPDATE, so a mismatch never leaves source-key ciphertext or
silently drops a password. PortableLogin/PortableSecret get hand-written
redacting Debug so a decrypted password / card number never hits a log line.
Threat model + explicit trust boundary documented in docs/team-server.md §2.1/§7.

Round 2

High

Med / Low

  • Add MCP health check and profile lookup helpers ProxyShard/ShardBrowser#19 stream snapshot downloads + bound concurrency — download streamed from
    disk (was buffering up to 512 MiB in memory) behind a download_slots
    semaphore held for the whole transfer; on-disk blob length verified against the
    recorded size; launcher retries a 429 with clamped Retry-After.
  • Improve launcher profile launch error states ProxyShard/ShardBrowser#20 polish bundle — UI save no longer wipes the remote checkout binding
    (serde-default footgun); atomic credential writes on Windows (temp+rename);
    gc_snapshots deletes rows before blobs (dangling-row → orphan-blob); profile
    export strips the live remote_lock_token before it reaches the clipboard.

Round 1

Weak-admin-password refusal on network bind, login brute-force + CPU-exhaustion
throttle, session-token-bound snapshot download + lock reclaim, checkin
authorized-before-read + streamed-to-disk, decompression-bomb bounds, fail-closed
snapshot pack/restore, cookie partition + source-key preservation, Web Data
os_crypt normalization across machines, TTL-aware lease renewal + terminal-error
stop, no-ghost ACL grants, clearable env folder/proxy, internal-error masking +
DB-error→4xx mapping, orphan-blob GC, owner-only (0600) credential writes,
uniform JSON body-rejection errors, upload concurrency cap, and the OpenAPI
contract for the team server.

No automated suite runs in CI for this repo; verification is the shared crate's
unit tests (33), the team server's unit + e2e suites (10 + 10), and clean
cargo build across all three crates + tsc --noEmit.

🤖 Generated with Claude Code

chovizzz and others added 21 commits July 8, 2026 20:57
Lease renewal was hardcoded (renewer 30s, push guard 15s), decoupled from
the server's actual lease TTL — a small SHARDX_LEASE_TTL_SECS could lapse
before the next renew and let a peer steal the lock.

- server: checkout/lease responses now return lease_ttl_secs; config floors
  SHARDX_LEASE_TTL_SECS at 15s (warns+clamps below, blocks <=0 expired leases)
- client: renew at ~TTL/3 (clamped [5,300]); a failed renew backs off to a
  short 5s retry instead of a wide fixed gap; renewer/guard learn the TTL from
  each lease response
- launch: hold a LeaseGuard across pre-spawn preflight (proxy probe / geo
  resolve) so the lease stays fresh until the browser-side renewer takes over

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Web Data (autofill/payment) traveled in snapshots as-is, but its encrypted
columns (credit_cards.card_number_encrypted, local_stored_cvc / local_ibans
value_encrypted) are sealed with the SOURCE machine's os_crypt key — so on a
destination that mints its own key they became undecryptable orphans.

Mirror the cookie treatment, but WITHOUT rebuilding the (multi-table, version-
varying) schema: carry the decrypted secrets in PortableState.web_secrets and
re-encrypt them in place at unpack, keyed by each row's guid.

- new shared/src/webdata.rs: read (PRAGMA existence checks, row-level skip via
  Option<Vec<u8>>+flatten) + reencrypt_in_place (hardcoded table/column names —
  no injection surface — with a post-commit wal_checkpoint(TRUNCATE))
- portable.rs: PortableSecret + PortableState.web_secrets (serde default)
- snapshot.rs: pack reads secrets, unpack re-seals; Web Data DB kept in the tar,
  only -journal excluded so -wal/-shm survive a hard-kill checkin
- scope is local, guid-keyed payment data only; account/server-bound entries
  (unmasked_credit_cards, server_stored_cvc, token_service) re-sync on sign-in
- docs: threat model now names payment/autofill plaintext in the portable blob

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A lost lock (409), revoked ACL (403), gone env (404), dead token (401), or
malformed request (400) can never be recovered by retrying, but the renewer
and LeaseGuard kept re-leasing every 5s — pure log/request noise.

Wrap non-2xx responses in a typed HttpError (Display unchanged) so callers can
inspect the status via downcast, and stop the renewer/guard on a terminal
status instead of hammering. Network errors and 5xx/429/408 stay transient and
keep retrying.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 15s minimum lease TTL (added with TTL-aware renewal) clamps the test's
1s TTL, so waiting 1.3s no longer expires the lease. Age the lock row directly
in the DB instead (sqlx dev-dep), which is both faster and independent of the
TTL floor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A default bind of 0.0.0.0 + a bootstrapped admin/admin was a remote takeover
by default. Harden the startup posture:

- default SHARDX_BIND is now 127.0.0.1 (loopback); the Docker image still sets
  0.0.0.0 so containers stay reachable
- on a non-loopback bind, bootstrap refuses to start if the admin password is
  empty/short/a known placeholder, AND re-checks existing admin hashes so a DB
  already seeded with admin/admin (or set up on loopback then exposed) is caught
- loopback binds only warn; SHARDX_ALLOW_INSECURE_ADMIN=1 is an explicit escape
- docs/.env.example/README updated; examples no longer hand out passwords the
  guard would reject

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on token

Snapshot blobs carry plaintext cookies + payment secrets, but download only
checked owner_user_id — so any token of the same user could pull them. And the
underlying lock could be stolen: checkout re-minted a fresh token for the same
user+client without proving possession of the old one, so a second JWT could
learn the (exposed) client_id, re-checkout, and take over.

- download now requires x-client-id + x-lock-token headers matching the current
  lock row exactly (admin keeps a break-glass bypass, now audited with the
  holder); empty tokens are rejected outright
- checkout only reclaims a LIVE lock when the caller presents its current
  non-empty token; free/expired locks still need none; the client now sends its
  persisted lock_token so a crash-relaunch re-acquires its own lock
- migration 0003 clears legacy empty-token locks so the upgrade window isn't
  bounded only by TTL
- e2e: download requires the session token; live-lock reclaim requires the token
  and rotates it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cross-machine cookie rebuild forced top_frame_site_key='' and derived
has_cross_site_ancestor / source_scheme / source_port. But those are all part
of Chromium's cookie UNIQUE index, so a CHIPS-partitioned cookie restored to
the wrong (wider) scope — or collided with a same-name unpartitioned cookie and
one silently overwrote the other.

Carry the four unique-key components on PortableCookie (serde-default so legacy
snapshots still round-trip to the old derived values) and round-trip them in
cookies read/write. Adds a test that two cookies differing only by partition
key survive as distinct rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…m to disk

checkin buffered the whole multipart (up to 512MB) into memory and only then
checked the ACL / lock — so any authenticated non-holder could make the server
buffer large uploads at will (memory/bandwidth DoS).

Move the session identity to x-client-id / x-lock-token headers so ACL + a lock
pre-check run first; axum's Multipart is lazy, so an unauthorized request never
reads the body. The snapshot part is then streamed straight to a temp file,
hashed and size-capped incrementally, never buffered whole. The transaction's
conditional DELETE stays the atomic authority; download reuses the same
session_holds_lock helper.

Protocol change: identity for checkin/download is now headers, not body parts —
an old launcher's checkin fails (and degrades to a recoverable pending push)
until it upgrades.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A member-uploaded snapshot was unpacked with no limit on expanded bytes, entry
count, file size, path depth/length, or the portable-state blob — so a crafted
tar/gzip bomb could exhaust a puller's disk/CPU/memory.

- LimitReader caps total decompressed bytes (data + headers + extension bodies)
  at expand_cap(compressed) = min(4 GiB, max(64 MiB, 100× compressed)), failing
  closed so a truncated read can't masquerade as a clean end-of-archive
- iterate raw so tar-rs never read_all()s a GNU-longname / PAX extension body
  into memory before we can bound it; resolve GNU longnames ourselves under a
  64 KiB cap (so long IndexedDB paths still restore) and cap/skip PAX + longlink
- per-entry PAX-aware e.size() caps single-file and portable-state size; guard
  entry count, path depth, and path length
- pack sets sparse(false) and unpack rejects GNU-sparse rather than silently
  dropping the file; the longname state machine fails closed on dup/dangling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…state

Several snapshot paths turned a failure into empty state, which then clobbered
the shared environment or the local profile:

- pack propagated cookie/Web Data read errors (context + ?) instead of shipping
  empty cookies/secrets over a real read failure
- cookie decryption is now host-bound: try_decrypt_cookie verifies the
  SHA256(host_key) prefix and errors on a v10 blob that fails to decrypt OR
  whose prefix doesn't match — so an unauthenticated AES-CBC "fake padding
  success" on a wrong key can't smuggle a garbage value into the snapshot
  (legacy non-v10 rows still fall back to plaintext)
- unpack requires the portable state blob to be present and valid JSON — a
  missing/corrupt one is refused rather than rebuilt as empty cookies

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…austion

Login had no rate limiting, so it could be used for password brute-force or
Argon2 CPU exhaustion.

Add a LoginThrottle: exponential-backoff lockout keyed separately by client IP
(threshold 5) and username (threshold 15, looser so an attacker can't easily
lock a real user out), checked BEFORE any DB lookup or Argon2. Backoff escalates
across lock expiries within the failure window. A global semaphore bounds how
many Argon2 verifications run at once — closing the concurrent-first-wave gap —
and each runs on spawn_blocking so it never ties up an async worker. Failures
over the threshold return 429 + Retry-After (the longer of the two waits).

Client IP defaults to the real peer socket (unspoofable); X-Forwarded-For /
X-Real-IP is trusted only under SHARDX_TRUST_PROXY=1 and must parse as an IP.
Failed logins are audited with the source IP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Internal errors (sqlx/anyhow) were returned to the client verbatim, leaking SQL
text, file paths, and constraint names — and predictable client-caused DB
errors surfaced as 500s.

- any 5xx now returns a generic "internal server error" to the client; the real
  detail is logged server-side only
- From<sqlx::Error> maps unique-violation -> 409, foreign-key-violation -> 400,
  RowNotFound -> 404; everything else stays a (generic) 500

e2e: creating an env with a bogus folder_id returns 400 without leaking the
foreign-key/SQL text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The team-server bearer token (settings.json), per-profile checkout lock_token
(profile JSON), proxy credentials (proxies.json), and ProxyShard billing key
(psapi.json) were written world-readable, exposing them on a shared machine or
in a backup.

Add store::write_private: on Unix it opens/creates 0600 and tightens the fd's
mode BEFORE truncating+writing (so an existing 0644 file never holds the new
secret while still world-readable, and chmod goes through the fd — no path
race); Windows relies on the per-user %APPDATA% ACL. Route every credential
write through it, including the startup profile migration in runtime.rs and the
plaintext cookie export. Documents that 0600 guards local reads only (not
encryption; backups may not preserve modes) — logout/discard already clear the
token/lock_token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…enAPI

- ACL grant is now one atomic INSERT..SELECT..WHERE EXISTS(target) AND
  EXISTS(user) .. ON CONFLICT, returning 404 when either is missing — no
  ghost rows, and no check-then-write race (delete handlers already purge a
  deleted object's ACL rows)
- env update: folder_id/proxy_id are Option<Option<String>> (double_option),
  so a value sets, null clears, and omission leaves them — set targets are
  pre-checked for a clean 404 instead of a foreign-key 400
- add server/openapi.yaml documenting the team-server contract: auth/JWT,
  admin/member + use/edit model, the checkout lock flow, x-client-id/x-lock-token
  headers, error codes, and deployment notes

e2e: ghost grants (nonexistent env/user) are refused; env folder can be cleared
to null and a bad folder is 404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- server: blob::gc_orphans sweeps unreferenced <version>.blob files and stale
  incoming-*.tmp on startup. Compares by (env_id, version) identity (robust to
  data-dir path spelling) and fails CLOSED if the snapshots read errors, so a DB
  hiccup can never delete live blobs.
- launcher: store::write_private now writes a 0600 temp + fsync + atomic rename
  over the target — closing the world-readable/partial-write/open-fd-race window
  for credential files (Windows keeps the ACL-based plain write).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- bound concurrent checkin snapshot uploads with a semaphore (8 slots); over the
  cap returns 429 + Retry-After (the client's exit-checkin degrades to a
  recoverable pending push). The slot covers only receiving+writing the body and
  is released before the commit.
- add a 30s idle timeout per multipart read so a stalled connection can't pin an
  upload slot until the socket dies.
- flesh out server/openapi.yaml request-body schemas for the CRUD write
  endpoints and document checkin's 429.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
axum's built-in Json/Option<Json> extractor rejections are plain text, breaking
the documented `{ "error": ... }` contract, and Option<Json> silently swallowed
a malformed body into None.

Add AppJson<T> (maps a JSON parse rejection to a 400 { error }) and AppJsonOpt<T>
(absent body → None, but a present-yet-malformed body → 400 instead of a silent
None). Route the 12 CRUD JSON handlers through AppJson and the three lock bodies
(checkout/lease/release) through AppJsonOpt. Content-type detection is
case-insensitive and accepts application/*+json.

Known residual (left as-is): Multipart/Path extractor rejections and the
flattening of JSON 415/422/413 to 400.

e2e: a malformed JSON body returns a JSON error, not plain text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
src-tauri/src/cookies.rs was a 420-line second copy of the cookie + os_crypt
scheme, frozen at the pre-CHIPS behaviour: import hardcoded top_frame_site_key=''
(dropping partitioned-cookie scope) and export decrypted without the host-bound
check. Replace it with a thin delegator over shardx-core (the single source of
truth), so manual import/export gets the partition-key preservation and
host-bound decryption fixes for free.

- Cookie is now a re-export of shardx_core::PortableCookie (same 8 fields +
  httpOnly/sameSite aliases, plus serde-defaulted CHIPS/source fields — old JSON
  still imports)
- export bails before opening the key if there's no Cookies DB, so a read-only
  export of a never-launched profile can't mint a Local State key on Windows
- launcher openapi.yaml Cookie schema documents the four added fields

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fourth-batch hardening from the Codex adversarial review pass.

ProxyShard#15 snapshot path canonicalization (shared/src/snapshot.rs)
  Crafted archive paths could take one spelling past the exact-string
  exclusion / portable-state checks and a different normalized spelling
  to disk. Fix: canonicalize each entry ONCE via normalize_rel (rejects
  `..`, colon/Windows drive-prefix, trailing dot/space, reserved device
  names; drops `.`/empty/leading-root/`//`/trailing-slash), then run
  PORTABLE_FILE, is_excluded, depth, and safe_join on that same string.
  is_excluded is now ASCII-case-insensitive (Win/macOS alias `local
  state`, `Default/login data`) and `Local State` is prefix-excluded so
  a `Local State/foo` dir can't be planted at the protected path.
  safe_join re-normalizes as a final guard. New tests cover the leading-
  root/`.`/case/trailing-slash/reserved-name vectors.

ProxyShard#16 Argon2 off the async runtime + throttled (server/src/auth.rs, users.rs)
  Every password hash/verify now goes through verify_slot/hash_slot:
  spawn_blocking + the shared login-throttle slot, bounding a concurrent
  first wave that all passed locked_for before any failure was recorded.
  login, change_password, user create, reset_password all routed through.

ProxyShard#17 checkout version TOCTOU (server/src/routes/locks.rs)
  Re-read current_version from the row after the lock upsert commits,
  instead of returning the pre-checkout snapshot's version.

ProxyShard#18 clear remote-session fields on clone/import/export
  clone_profile and profile_import drop remote_env_id / remote_lock_token
  / remote_base_version / remote_pending_push so a copied profile can't
  inherit another's checkout lease.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…roxyShard#19)

Snapshot download previously read the whole blob (up to 512 MiB) into a
Vec<u8> before returning, with no concurrency cap — a burst of lock
holders pulling at once could exhaust server memory/bandwidth.

Server (server/src/routes/locks.rs, blob.rs, state.rs, main.rs):
  - Stream the blob straight from disk via tokio_util ReaderStream +
    axum Body::from_stream instead of buffering it.
  - New download_slots semaphore (8) mirrors upload_slots: acquire a
    permit AFTER the ACL + lock-token + row-existence checks (so an
    unauthorized/non-holder request can't consume a slot), then move it
    into a GuardedReader that owns it for the whole streamed transfer —
    released when the stream drains or the client disconnects. 429 +
    Retry-After when saturated.
  - Defense-in-depth: verify the on-disk blob length matches the
    recorded snap.size before streaming, so corruption/tampering fails
    loudly rather than serving a body that contradicts Content-Length.
  - Advertise Content-Length from snap.size.

Launcher (src-tauri/src/sync.rs):
  - download() retries a 429 up to 5 attempts, honoring a clamped
    (1..=10s) Retry-After, before surfacing an error — so a transient
    download-slot saturation degrades to a short wait instead of
    aborting the launch. The LeaseGuard keeps renewing across the wait.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…roxyShard#20)

Final Med/Low bundle from the round-2 Codex backlog.

① UI save no longer wipes the remote checkout binding (lib.rs)
   remote_env_id / remote_lock_token / remote_base_version /
   remote_pending_push are serde(default) fields the profile editor
   doesn't round-trip, so a generic save reset them to None/false —
   silently unlinking a checked-out profile, dropping a pending push,
   and stranding the server lock until lease expiry. save_profile_core
   now re-reads the persisted values from disk and carries them over any
   save of an existing profile; these are owned by the sync pipeline,
   not the editor. (remote_open builds StoredProfile directly and is
   unaffected.)

② Atomic credential writes on Windows (store.rs)
   write_private's non-unix branch was a bare fs::write — a crash
   mid-write could truncate an existing secret file. Now mirrors the
   unix path: sibling temp + write + sync_all + rename-over (MoveFileEx
   REPLACE_EXISTING, atomic on-volume); temp cleaned up on error.

③ gc_snapshots deletes rows before blobs (server/locks.rs)
   Removing blob files before the DB rows could leave rows pointing at
   missing blobs — download can't heal that and hard-errors. Delete the
   rows first (skip blob removal entirely if that fails); a crash now
   leaves only orphan blobs, which the orphan GC reclaims.

④ Export strips the checkout secret (App.tsx)
   bulkExport copied the full profile _meta — including the live
   remote_lock_token (holder can pull the env's plaintext cookies +
   secrets) — into the clipboard. Sanitize the four remote_* fields on
   the way out so the secret never lands in exported JSON. Import
   already strips them on the way in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chovizzz chovizzz changed the title harden(team-server): full audit-driven security pass (14 fixes) harden(team-server): full audit-driven security pass (round 1 + round 2) Jul 9, 2026
chovizzz and others added 3 commits July 9, 2026 21:04
Login Data was the last machine-key-encrypted store the team-server
snapshot dropped: it was excluded outright and PortableState.logins
stayed empty, so saved passwords were lost on every cross-machine pull.

Port it via the same "raw DB travels + rekey in place" path Web Data
uses (not the cookies "exclude + rebuild" path — logins carries many
version-varying columns Chromium owns). At pack time password_value is
decrypted into the portable state, keyed by SQLite rowid; at unpack time
it is re-sealed with the destination os_crypt key in place. The raw
Login Data DB (+ its -wal/-shm) now travels; the account-bound
Login Data For Account (+ sidecars) stays excluded — it re-syncs from
the signed-in Google account on the destination.

Fail-closed throughout (per Codex adversarial review):
- read: a non-empty v10 blob that won't decrypt aborts the pack; empty
  blobs (blacklisted sites) are skipped; password kept as Vec<u8>.
- reencrypt_in_place: the carried rowids must be a perfect bijection
  with the DB's non-empty password rows (checked inside the write txn) —
  a duplicate, a missing row, or a DB absent while passwords remain all
  abort before any UPDATE, so a mismatch never leaves source-key
  ciphertext (undecryptable) or silently drops a password.
- PortableLogin/PortableSecret: hand-written redacting Debug so a
  decrypted password / card number never lands in a log line.

Tests: rekey across keys, same realm+username multi-row, blacklisted
empty-blob skip, undecryptable-abort, set-mismatch-without-touching-rows,
and passwords committed only to an uncheckpointed -wal (unit + snapshot
E2E). shared 33 pass; server 10 unit + 10 e2e pass; src-tauri builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A remote-bound profile could be launched while the team server was not
yet configured (launch.rs skips checkout, so no lock is acquired). If the
server was then configured mid-session, the browser-exit hook called
checkin_on_exit → push with an empty lock_token: the server rejects it,
and the failure marked the profile pending_push — which then BLOCKED the
next launch with a spurious "un-pushed local changes" error, even though
nothing was ever checked out.

- push() now refuses before any lease/pack/upload when no non-empty lock
  token is held, returning a downcastable NoCheckout marker; the token is
  read once and passed to upload (closes a push→upload re-read race).
- checkin_on_exit classifies that marker: "nothing to check in" (no
  pending flag) vs a real failure (pending). Classifying instead of
  pre-checking also closes the TOCTOU where a concurrent release/discard
  clears the token between a pre-check and the push.
- set_checkout_state now propagates its save error; pull and retry_push
  fail closed if the acquired token can't be persisted (otherwise the
  session's changes would be silently overwritten by the next pull),
  releasing the in-hand token so the env frees immediately instead of
  waiting out the lease TTL. Clearing calls stay best-effort.

Guards every checkin caller (exit hook, retry_push, UI remote_push).
Found and hardened across three Codex adversarial-review rounds.

cargo build clean; cargo test --lib passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The snapshot a pull downloads carries decrypted cookies, saved passwords,
and card numbers — the most sensitive payload the client fetches. The
integrity check was skipped whenever the `x-snapshot-sha256` header was
absent (`if let Some(expected)`), so a proxy/CDN that stripped it, a
non-canonical/fallback server, or the allowed plain-HTTP path could hand
unverified bytes straight to unpack.

- download now requires a present, well-formed (64-char ASCII hex) header
  and verifies unconditionally; missing/blank/malformed aborts before the
  body is even read. The server always sends it (NOT NULL column), so this
  costs nothing against the in-repo server.
- Tighten the accepted status to exactly 200 OK (a 204/206/other 2xx from
  a proxy or future server shouldn't reach the integrity check).
- Extract pure `expected_snapshot_sha256` / `verify_snapshot_sha256`
  helpers with unit coverage (missing/blank/short/non-hex/case-normalize/
  match/mismatch).

Reviewed by Codex (No findings). cargo build clean; sync:: tests pass (6).

Co-Authored-By: Claude Fable 5 <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