Fix review findings: secrets in snapshots, URL spoofing, migration data loss

Adversarial review of the branch produced findings across four areas.
This addresses them, plus the Windows CI environment.

Secrets. commit_container_snapshot baked the container's full env into
the per-project snapshot image, so the shared OAuth token — and the AWS
keys, git token and gateway master key — outlived revocation and were
readable via docker inspect. Verified against Engine 29.6 that a commit
body's config merges over the container's: keys cannot be dropped but
can be overwritten, so all of them now commit as KEY=. clear_claude_token
additionally rewrites images from earlier builds and reports honestly
when a tag could not be rewritten.

The recommendation to move the token out of env entirely was not taken,
with reasoning: apiKeyHelper is a different auth method that outranks
CLAUDE_CODE_OAUTH_TOKEN rather than a transport for it, and no
file-based delivery exists. The durable exposure — the image — is what
is closed here. Separately noted, not fixed: entrypoint.sh captures the
token into the scheduler's .env inside the persisted volume.

URL spoofing. Three call sites reached openUrl with container-controlled
strings, one of which the review missed (the WebLinksAddon handler).
The sign-in URL was scraped from container output with a longest-match
tie-break and no userinfo check, so claude.ai@evil.tld rendered as
"claude.ai…" in a truncating element. There is now one sanitizer in
front of every sink — scheme allowlist, no userinfo, C0/C1 and quote
rejection, host allowlist for the sign-in case, first-match — and the
origin renders un-truncated. The toast is keyed so a changed URL
remounts, closing a bait-and-switch where the user read one URL and
clicked another.

Migration. The rollback pin was best-effort: a tag failure was logged
and the migration continued past remove_container, after which the
final commit overwrote the only copy of the old system layer. It now
aborts before anything destructive and reads the tag back. /var was
destroyed while the ordinary recreate path preserves it — making the
"safe" alternative to Reset more destructive than Reset's alternative;
data-bearing subtrees are now detected and disclosed in the pre-flight
rather than copied, since tarring a live database onto a different
base's packages is a corruption risk. resume_migration now verifies the
migration-state label instead of reporting success for a container that
never swapped. dismiss actually resolves the record rather than leaving
the feature permanently refusing to migrate. Start and Reset are guarded
while a migration is live.

Lifecycle. The gateway no longer publishes on 0.0.0.0 — bind address and
advertised URL are derived together so they cannot drift. Disabling it
now stops it. App exit runs teardown concurrently under a budget with a
visible shutting-down state instead of blocking for minutes. Auto-starts
retry when Docker is not up yet, and the polling-recovery path now
reconciles, so interrupted migrations are still recovered. Auth-bridge
forwards are capped, closing a container-driven fd exhaustion.

Windows CI. build-windows failed on this branch with "linker link.exe
not found". The runner had no MSVC build tools and the workflow assumed
a hand-provisioned machine, so a bare runner registers, accepts jobs and
fails at link time after downloading the whole crate graph. The job now
installs the VC++ workload when vswhere cannot find it, matching how it
already conditionally installs Rust and Node.

192 Rust tests, 274 frontend tests, both builds clean, zero warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 19:35:39 -07:00
co-authored by Claude Opus 5
parent eb1324cb16
commit 2de00b3c55
43 changed files with 4348 additions and 346 deletions
+212 -71
View File
@@ -23,11 +23,20 @@
//! * The flow needs a way to deliver the pasted code, hence
//! [`submit_claude_token_code`] and the stdin channel below. Without it the
//! command would simply sit at the prompt until it timed out.
//! * [`crate::auth_bridge`] is *not* required for this particular command,
//! since there is no container-local callback to reach. It is still enabled
//! for the duration (and restored afterwards) as designed: it costs nothing
//! here and keeps the flow working if a future CLI version, or the plain
//! `claude login` path, goes back to a loopback redirect.
//! * [`crate::auth_bridge`] is **not involved**. There is no container-local
//! listener to reach, so there is nothing for it to bridge.
//!
//! An earlier version turned the bridge on for the duration "in case a
//! future CLI version goes back to a loopback redirect", and turned it off
//! again afterwards. That was wrong twice over. The bridge flag is
//! *persisted* to `projects.json`, and the restore only ran if the future
//! completed — so a force-quit, kill or panic inside the 15-minute
//! [`SETUP_TIMEOUT`] left it latched on, and the app re-armed an
//! unauthenticated loopback port mirror on every subsequent launch, for a
//! project whose owner had never opted in. And it bought nothing: the
//! speculative future it defended against would need code changes here
//! anyway. The bridge remains available as the per-project setting it always
//! was; this command does not touch it.
//!
//! ## Handling of the token itself
//!
@@ -66,10 +75,27 @@ const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60);
const TOKEN_PREFIX: &str = "sk-ant-oat01-";
/// Minimum number of body characters after [`TOKEN_PREFIX`] for a match to be
/// believed. Real tokens run to ~90 characters; this is set well below that but
/// far above anything prose would produce, so documentation-style decoys like
/// `sk-ant-oat01-...` or `sk-ant-oat01-<your-token>` are rejected.
const MIN_TOKEN_BODY: usize = 32;
/// believed. Real `setup-token` credentials run to ~90 body characters.
///
/// This must be close to that real length, not merely "longer than prose".
/// The earlier value of 32 accepted a *fragment* of a token — which is what a
/// line wrap produces if `stty cols 200` fails and the pty falls back to 80
/// columns, splitting the value across two lines. Two bad things follow from
/// accepting one:
///
/// * the fragment gets stored as if it were the credential, so every
/// container authenticates with a token that cannot work, and the failure
/// surfaces far from its cause;
/// * [`SecretRedactor`] masks the leading fragment because it carries the
/// `sk-ant-` marker, but the *tail* on the next line carries no marker and
/// is emitted to the UI in clear.
///
/// Set below the real length by enough margin to survive a modest change in
/// token format, and far above any fragment an 80-column wrap can produce (the
/// prefix alone eats 13 columns, so the longest possible first line fragment is
/// ~67). Rejecting a real-but-shorter token is a loud, recoverable failure —
/// "printed no recognisable token" — whereas accepting a fragment is silent.
const MIN_TOKEN_BODY: usize = 80;
/// Redaction is deliberately broader than extraction: anything shaped like an
/// Anthropic credential is masked on its way to the UI, not just `oat01` ones.
@@ -361,6 +387,20 @@ fn strip_ansi_prefix(bytes: &[u8]) -> (String, usize) {
(out, i)
}
/// Cap on the bytes [`AnsiStripper`] will hold waiting for a control sequence
/// to terminate.
///
/// [`strip_ansi_prefix`] stops at the first *incomplete* sequence and the
/// remainder is carried to the next chunk — which is correct while the
/// sequence really is going to end, and unbounded when it never does. A single
/// `ESC ]` with no BEL and no ST swallows everything the container prints for
/// the rest of the 15-minute [`SETUP_TIMEOUT`], and the buffer grows with it;
/// `transcript` and [`SecretRedactor::pending`] are both already capped, so
/// this was the one remaining way to make the flow eat memory. Generous enough
/// that no legitimate sequence comes close (OSC 8 hyperlinks are the longest
/// thing Claude Code emits, in the low hundreds of bytes).
const MAX_ANSI_CARRY: usize = 64 * 1024;
/// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete
/// trailing sequence over to the next chunk.
#[derive(Default)]
@@ -371,8 +411,29 @@ struct AnsiStripper {
impl AnsiStripper {
fn push(&mut self, chunk: &[u8]) -> String {
self.carry.extend_from_slice(chunk);
let (out, consumed) = strip_ansi_prefix(&self.carry);
let (mut out, consumed) = strip_ansi_prefix(&self.carry);
self.carry.drain(..consumed);
// Past the cap the leading sequence is not going to terminate. Drop
// its introducer and re-strip: the bytes behind it are then treated as
// ordinary text rather than discarded, so a token hiding inside a
// runaway OSC still reaches the parser. Dropping one byte per
// overflowing chunk is enough — the cap can only be re-crossed by a
// fresh chunk, which re-enters here.
if self.carry.len() > MAX_ANSI_CARRY {
log::warn!(
"`claude setup-token` emitted an unterminated control sequence \
longer than {} bytes — treating it as text",
MAX_ANSI_CARRY
);
}
while self.carry.len() > MAX_ANSI_CARRY {
self.carry.drain(..1);
let (more, consumed) = strip_ansi_prefix(&self.carry);
self.carry.drain(..consumed);
out.push_str(&more);
}
out
}
}
@@ -606,65 +667,23 @@ pub async fn acquire_claude_token(
*cancel_slot().lock().await = Some(cancel_tx);
}
let bridge_was_enabled = project.auth_bridge_enabled;
// No auth-bridge elevation here — see the module docs. `setup-token` has no
// container-local callback to bridge, and the flag that would enable one is
// *persisted*, so a crash anywhere inside the 15-minute timeout used to
// leave an unauthenticated port mirror armed for good.
emit_progress(
&app_handle,
&project_id,
"Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.",
);
let result = async {
// See the module docs: 2.1.226's `setup-token` redirects to an
// Anthropic-hosted callback, so no container-local listener needs
// bridging. Enabled anyway, per design, to cover CLI versions and login
// paths that do use a loopback redirect. Temporary elevation — the
// prior setting is restored below whatever happens.
if !bridge_was_enabled {
state
.projects_store
.set_auth_bridge_enabled(&project_id, true)?;
emit_progress(
&app_handle,
&project_id,
"Auth bridge enabled for the duration of login.",
);
}
// Called unconditionally, and idempotent: the flag may already have
// been on while the poller was not running (e.g. enabled before start).
state
.auth_bridge
.start(
project_id.clone(),
container_id.clone(),
app_handle.clone(),
state.projects_store.clone(),
)
.await;
let result =
run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await;
emit_progress(
&app_handle,
&project_id,
"Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.",
);
run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await
}
.await;
// Release the flow, then restore the bridge — both unconditionally, so a
// failed or cancelled login leaves nothing latched on.
// Release the flow. Nothing else needs unwinding: this command changes no
// persisted state until the token itself is stored, which is the point.
*pending_input().lock().await = None;
*cancel_slot().lock().await = None;
if !bridge_was_enabled {
// Stop the poller first: it awaits teardown, so host ports are provably
// released before the flag goes back.
state.auth_bridge.stop(&project_id).await;
if let Err(e) = state
.projects_store
.set_auth_bridge_enabled(&project_id, false)
{
log::warn!(
"Failed to restore the auth bridge setting for project {}: {}",
project_id,
e
);
}
}
let token = result?;
secure::store_claude_oauth_token(&token)?;
@@ -736,14 +755,69 @@ pub async fn has_claude_token() -> Result<bool, String> {
Ok(secure::has_claude_oauth_token())
}
/// Forget the shared Claude token. Containers keep the injected value until
/// each is next started, at which point the rotation-id label mismatch forces a
/// recreation that blanks the env var.
/// What [`clear_claude_token`] managed to reach. The keychain entry is always
/// gone by the time this is returned — the rest is about copies of the token
/// that live outside it.
#[derive(Debug, Default, serde::Serialize)]
pub struct ClearTokenOutcome {
/// Snapshot images that were holding the token and have been rewritten.
pub snapshots_scrubbed: Vec<String>,
/// Images still holding it, with the reason each could not be rewritten.
/// Non-empty means the revocation is **incomplete** and the UI must say so.
pub snapshots_failed: Vec<String>,
/// Rewritten, but the pre-rewrite image object could not be deleted because
/// a container is still running off it. Worth mentioning, not worth
/// alarming about — see `SnapshotScrubReport::superseded_retained`.
pub snapshots_superseded: Vec<String>,
/// Set when Docker could not be reached at all, so nothing is known about
/// what is still on disk.
pub docker_unavailable: Option<String>,
}
/// Forget the shared Claude token.
///
/// Deleting the keychain entry is the easy half. The token also exists in two
/// other places, and a "Revoke" button that leaves either of them behind is
/// telling the user something untrue:
///
/// * **Running containers** hold it in their environment. That resolves
/// itself: the rotation id in `triple-c.claude-token-version` no longer
/// matches, so the next start recreates the container and
/// `MANAGED_AUTH_KEYS` blanks the variable.
/// * **Snapshot images** hold it in `Config.Env`, and nothing resolves that on
/// its own — the image outlives every container built from it, and
/// `docker image inspect` will keep printing a live ~1-year credential for
/// as long as the image exists. New commits no longer bake it in (see
/// [`crate::docker::container::commit_container_snapshot`]), but images
/// committed by earlier builds have to be rewritten, which is what
/// [`scrub_secrets_from_snapshots`] does here.
///
/// The keychain deletion is never rolled back if the scrub fails; a partially
/// completed revocation is still better than none, and the outcome is reported
/// so the UI can be explicit about what is left.
#[tauri::command]
pub async fn clear_claude_token() -> Result<(), String> {
pub async fn clear_claude_token() -> Result<ClearTokenOutcome, String> {
secure::delete_claude_oauth_token()?;
log::info!("Cleared the shared Claude authentication token");
Ok(())
let report = crate::docker::container::scrub_secrets_from_snapshots().await;
if report.left_something_behind() {
log::warn!(
"Revoked the shared Claude token but {} snapshot image(s) may still contain it",
report.failed.len()
);
}
Ok(ClearTokenOutcome {
snapshots_scrubbed: report.scrubbed,
snapshots_failed: report
.failed
.into_iter()
.map(|(image, reason)| format!("{}: {}", image, reason))
.collect(),
snapshots_superseded: report.superseded_retained,
docker_unavailable: report.unavailable,
})
}
#[cfg(test)]
@@ -938,5 +1012,72 @@ mod tests {
visible.push_str(&s.push(b"1mb"));
assert_eq!(visible, "ab");
}
}
// ── Truncated credentials ────────────────────────────────────────────
// `stty cols 200` runs before Claude Code starts precisely so the ~103
// character token never wraps. If that fails, the pty falls back to 80
// columns and the token arrives split across two lines. The old floor of
// 32 body characters believed the first half.
#[test]
fn a_token_shorter_than_a_real_one_is_rejected() {
let fragment = format!("{}{}", TOKEN_PREFIX, "M".repeat(MIN_TOKEN_BODY - 1));
assert_eq!(
parse_setup_token(&format!("Your token: {}\n", fragment)),
None
);
}
#[test]
fn a_line_wrapped_token_yields_nothing_rather_than_half_a_credential() {
let tok = token('N');
// Column 12 is where `Your token: ` ends, so an 80-column pty breaks
// the value 68 characters in.
let wrapped = format!("Your token: {}\n{}\n", &tok[..68], &tok[68..]);
assert_eq!(
parse_setup_token(&wrapped),
None,
"a wrapped token must fail loudly, not be stored truncated"
);
}
#[test]
fn a_real_length_token_is_still_accepted() {
// Guards the floor from being raised past what Anthropic actually mints.
let tok = token('O');
assert_eq!(tok.len() - TOKEN_PREFIX.len(), 90);
assert!(90 > MIN_TOKEN_BODY);
assert_eq!(parse_setup_token(&format!("{}\n", tok)), Some(tok));
}
// ── Bounded buffering ────────────────────────────────────────────────
#[test]
fn an_unterminated_control_sequence_cannot_grow_the_carry_without_bound() {
let mut s = AnsiStripper::default();
// An OSC introducer with no BEL and no ST. `strip_ansi_prefix` cannot
// know it has ended, so every byte after it is carried — for the whole
// 15-minute timeout, if nothing caps it.
let mut seen = s.push(b"\x1b]8;id=1;");
for _ in 0..40 {
seen.push_str(&s.push(&vec![b'A'; 4096]));
}
assert!(
s.carry.len() <= MAX_ANSI_CARRY,
"carry grew to {} bytes",
s.carry.len()
);
assert!(!seen.is_empty(), "the withheld text must be released, not dropped");
}
#[test]
fn a_token_printed_after_a_runaway_sequence_is_still_found() {
let tok = token('Q');
let mut s = AnsiStripper::default();
let mut seen = s.push(b"\x1b]8;id=1;");
for _ in 0..40 {
seen.push_str(&s.push(&vec![b'A'; 4096]));
}
seen.push_str(&s.push(format!("\nYour token: {}\n", tok).as_bytes()));
assert_eq!(parse_setup_token(&seen), Some(tok));
}
}