harden(team-server): full audit-driven security pass (round 1 + round 2) - #2
Merged
Conversation
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>
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>
This was referenced Jul 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
f916108). Aremote-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— whichthen blocked the next launch with a spurious "un-pushed changes" error.
pushnow refuses (returning a downcastableNoCheckoutmarker) before anylease/pack/upload;
checkin_on_exitclassifies that marker as "nothing tocheck 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_statenow propagates its save error so
pull/retry_pushfail closed (releasing thein-hand token) if the acquired token can't be persisted, instead of letting the
session's changes be silently overwritten by the next pull.
438d316). The pulledsnapshot carries decrypted cookies, saved passwords, and card numbers; the
sha256 check was skipped whenever
x-snapshot-sha256was absent, so a proxythat 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 Datawas the last machine-key-encrypted store the snapshot dropped — itwas excluded outright and
PortableState.loginsstayed empty, so savedpasswords 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 —
loginscarries many version-varying columns Chromium owns): at packpassword_valueis decrypted into the portable state keyed by SQLiterowid;at unpack it's re-sealed with the destination os_crypt key in place. The raw DB
(+
-wal/-shm) travels; the account-boundLogin 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_placerequires the carried rowids to bea 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/PortableSecretget hand-writtenredacting
Debugso 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
spelling past the exact-string exclusion / portable-state checks and a
different normalized spelling to disk. Fixed by canonicalizing each entry once
(
normalize_rel: rejects.., colon/Windows drive-prefix, trailing dot/space,reserved device names; drops
./empty/leading-root////trailing-slash) andrunning every check on that same string.
is_excludedis now ASCII-case-insensitive and
Local Stateis prefix-excluded. Took three Codex rounds tofully close (leading-root →
./case aliasing →Local State/dir).through
verify_slot/hash_slot(spawn_blocking + shared login-throttle slot).current_versionafter the lock upsertcommits instead of returning the pre-checkout snapshot's version.
can't inherit another's checkout lease / lock token.
Med / Low
disk (was buffering up to 512 MiB in memory) behind a
download_slotssemaphore held for the whole transfer; on-disk blob length verified against the
recorded size; launcher retries a 429 with clamped Retry-After.
(serde-default footgun); atomic credential writes on Windows (temp+rename);
gc_snapshotsdeletes rows before blobs (dangling-row → orphan-blob); profileexport strips the live
remote_lock_tokenbefore 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 buildacross all three crates +tsc --noEmit.🤖 Generated with Claude Code