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
+403 -16
View File
@@ -176,6 +176,39 @@ fn build_claude_instructions(
/// stale-value neutralization pass can never disagree about the spelling.
pub const CLAUDE_OAUTH_TOKEN_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN";
/// Every managed env var whose *value* is a credential.
///
/// These are the names that must never survive into a snapshot image. A
/// container's env is visible to `docker inspect`, which is bad but bounded —
/// the container is recreated whenever the credential rotates, and removed
/// with the project. An **image**'s env is neither: `docker commit` copies the
/// container's full environment into `triple-c-snapshot-{id}:latest`, that tag
/// outlives every container built from it, and nothing about deleting a
/// keychain entry touches it. A ~1-year OAuth token baked in that way is
/// readable by `docker image inspect` for as long as the image exists, long
/// after the user has clicked Revoke.
///
/// [`commit_container_snapshot`] therefore blanks all of them at commit time,
/// and [`scrub_secrets_from_snapshots`] rewrites images committed before that
/// was true.
///
/// Blanked rather than omitted, because Docker's commit endpoint *merges* the
/// supplied config over the container's rather than replacing it: a key left
/// out of the list is inherited with its original value, so `KEY=` is the only
/// way to clear one. That matches how `MANAGED_AUTH_KEYS` already works at
/// create time, and Claude Code, the AWS SDK and git all treat an empty value
/// as unset.
pub const SECRET_ENV_KEYS: &[&str] = &[
CLAUDE_OAUTH_TOKEN_ENV,
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_BEARER_TOKEN_BEDROCK",
"GIT_TOKEN",
];
/// Env var name prefixes Triple-C manages itself; users cannot set these by hand.
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
@@ -998,11 +1031,16 @@ pub async fn create_container(
// ── Neutralize stale backend auth env vars ──────────────────────────────
// When a project switches backends (e.g. Bedrock → Anthropic) the container
// is recreated *from a snapshot image* committed off the previous container.
// `docker commit` always bakes the previous container's full ENV into that
// image, and the commit API cannot strip it. So any auth var set under the
// old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1, AWS_*) survives in the image
// ENV and stays active unless we explicitly override it at create time.
// is recreated *from a snapshot image* committed off the previous container,
// and `docker commit` copies that container's full ENV into the image. So
// any auth var set under the old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1,
// AWS_PROFILE, a model alias) survives in the image ENV and stays active
// unless we explicitly override it at create time.
//
// This pass is about *staleness*, not secrecy. It fixes the container it is
// building and does nothing to the image, so it is not — and never was —
// a defence against a credential baked into a snapshot. That is
// `commit_container_snapshot`'s job, via SECRET_ENV_KEYS.
// Create-time env takes precedence over image ENV, so we set every managed
// auth key the *current* backend did NOT set to an empty value, clearing the
// stale baked-in one.
@@ -1514,14 +1552,29 @@ chmod 600 "$HOME/.aws/credentials""#;
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
/// removal.
///
/// NOTE: `docker commit` always bakes the *running container's* full ENV into
/// the resulting image — passing an empty Config here does NOT strip it, and
/// the commit API gives no way to remove env vars. As a result auth vars (e.g.
/// CLAUDE_CODE_USE_BEDROCK, AWS_*, CLAUDE_CODE_OAUTH_TOKEN) are present in this
/// snapshot image's ENV — this image is local and per-project, never pushed.
/// `create_container` defends against that by explicitly overriding every
/// managed auth key for the active backend (see MANAGED_AUTH_KEYS), so a
/// backend switch does not inherit the previous backend's stale credentials.
/// ## Why this passes a Config instead of `Default::default()`
///
/// `docker commit` bakes the *running container's* full ENV into the resulting
/// image. An earlier version of this function passed an empty `Config` and a
/// comment asserting that the API "gives no way to remove env vars", with
/// `MANAGED_AUTH_KEYS` cited as the defence. That was wrong on both counts.
///
/// `MANAGED_AUTH_KEYS` defends the *next container* — it overrides the image's
/// stale value at create time — and does nothing whatsoever about the value
/// sitting in the image. So `docker image inspect triple-c-snapshot-<id>:latest
/// --format '{{json .Config.Env}}'` returned the shared ~1-year OAuth token,
/// and kept returning it after the user revoked the token, because
/// `clear_claude_token` only deletes a keychain entry.
///
/// And the API does allow it. Verified against Engine 29.6: the config in the
/// commit body is **merged over** the container's config, key by key for `Env`,
/// with unmentioned fields (`Cmd`, `WorkingDir`, `Labels`, …) inherited
/// untouched. A key cannot be *dropped*, but it can be set — so every name in
/// [`SECRET_ENV_KEYS`] is committed as `KEY=`, which is exactly the "empty
/// means unset" convention the rest of the auth plumbing already uses.
///
/// Non-secret env (PATH, TZ, model aliases, instructions) is inherited as
/// before, so nothing about the snapshot's behaviour changes.
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
let docker = get_docker()?;
let image_name = get_snapshot_image_name(project);
@@ -1540,8 +1593,8 @@ pub async fn commit_container_snapshot(container_id: &str, project: &Project) ->
..Default::default()
};
// Empty config — no env vars / cmd baked in
let config = Config::<String> {
env: Some(blanked_secret_env()),
..Default::default()
};
@@ -1554,6 +1607,258 @@ pub async fn commit_container_snapshot(container_id: &str, project: &Project) ->
Ok(())
}
/// `KEY=` for every name in [`SECRET_ENV_KEYS`] — the env override handed to
/// `docker commit` so no credential value reaches an image.
fn blanked_secret_env() -> Vec<String> {
SECRET_ENV_KEYS
.iter()
.map(|key| format!("{}=", key))
.collect()
}
/// Whether `env` (an image's `Config.Env`) holds a non-empty value for any
/// name in [`SECRET_ENV_KEYS`].
fn env_holds_a_secret(env: &[String]) -> bool {
env.iter().any(|entry| {
let Some((key, value)) = entry.split_once('=') else {
return false;
};
!value.is_empty() && SECRET_ENV_KEYS.contains(&key)
})
}
/// Outcome of [`scrub_secrets_from_snapshots`], so callers can tell the user
/// what actually happened rather than guessing.
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct SnapshotScrubReport {
/// Snapshot images that were found to hold a credential and were rewritten.
pub scrubbed: Vec<String>,
/// Snapshot images that hold a credential and could **not** be rewritten,
/// each with the reason. A non-empty list means the tag every future
/// container is built from still carries the credential.
pub failed: Vec<(String, String)>,
/// Tags that *were* rewritten, but whose superseded image object could not
/// be deleted — almost always because a container is still running off it.
///
/// Much weaker than `failed`, and normal rather than exceptional. The tag
/// is clean, so nothing new is built with the credential; what remains is
/// an untagged image whose config is still readable by id, for exactly as
/// long as the container using it survives — and that container already
/// holds the same value in its own env, so it is not a new exposure. The
/// rotation-id label mismatch recreates it on the next start, after which
/// an image prune collects the leftover.
pub superseded_retained: Vec<String>,
/// Set when the image list itself could not be read (Docker not running,
/// no permission). Nothing was scrubbed and nothing is known.
pub unavailable: Option<String>,
}
impl SnapshotScrubReport {
/// True when a credential is known to still be reachable through something
/// that will keep being used — a tag, or an unknown state.
pub fn left_something_behind(&self) -> bool {
!self.failed.is_empty() || self.unavailable.is_some()
}
}
/// Rewrite every `triple-c-snapshot-*` image whose ENV still carries a
/// credential, blanking the values in place.
///
/// Revoking a shared token has to mean something. Deleting the keychain entry
/// stops *new* containers getting it, but images committed before
/// [`commit_container_snapshot`] learned to strip secrets still have the token
/// in their config, and those images are the ones every future container of
/// that project is built from. This is the cleanup for them.
///
/// Mechanics: create (do not start) a throwaway container from the image, then
/// commit it straight back over the same tag with the secret keys blanked. The
/// new image shares every layer with the old one, so this costs no meaningful
/// disk and preserves the project's installed packages exactly. The superseded
/// image is then removed by id; if Docker refuses (some storage drivers will
/// not delete an image that is a parent of another), the report says so rather
/// than pretending the secret is gone.
///
/// Never fails the caller: an unreachable Docker engine is reported in the
/// return value, because the keychain deletion that precedes it must still
/// stand.
pub async fn scrub_secrets_from_snapshots() -> SnapshotScrubReport {
use bollard::image::ListImagesOptions;
let mut report = SnapshotScrubReport::default();
let docker = match get_docker() {
Ok(d) => d,
Err(e) => {
report.unavailable = Some(e);
return report;
}
};
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"reference".to_string(),
vec!["triple-c-snapshot-*".to_string()],
)]);
let images = match docker
.list_images(Some(ListImagesOptions {
filters,
..Default::default()
}))
.await
{
Ok(images) => images,
Err(e) => {
report.unavailable = Some(format!("Could not list snapshot images: {}", e));
return report;
}
};
for summary in images {
// `list_images` does not return Config, so inspect each candidate.
let details = match docker.inspect_image(&summary.id).await {
Ok(d) => d,
Err(e) => {
report
.failed
.push((summary.id.clone(), format!("could not inspect: {}", e)));
continue;
}
};
let env = details
.config
.as_ref()
.and_then(|c| c.env.clone())
.unwrap_or_default();
if !env_holds_a_secret(&env) {
continue;
}
if summary.repo_tags.is_empty() {
// The reference filter should make this impossible; if it happens,
// say so rather than silently leaving a credential in place.
report.failed.push((
summary.id.clone(),
"an untagged snapshot image holds a credential and cannot be rewritten"
.to_string(),
));
continue;
}
// Rewrite every tag this image answers to, so an old tag cannot keep
// serving the un-scrubbed config.
let mut all_tags_rewritten = true;
for tag in summary.repo_tags.iter() {
let (repo, tag_part) = match tag.rsplit_once(':') {
Some((r, t)) => (r.to_string(), t.to_string()),
None => (tag.clone(), "latest".to_string()),
};
if let Err(e) = rewrite_image_without_secrets(&docker, tag, &repo, &tag_part).await {
all_tags_rewritten = false;
report.failed.push((tag.clone(), e));
} else {
report.scrubbed.push(tag.clone());
}
}
// Drop the superseded image so its config stops being inspectable.
// Best effort by design — see the doc comment.
if all_tags_rewritten {
if let Err(e) = docker
.remove_image(
&summary.id,
Some(RemoveImageOptions {
force: false,
noprune: false,
}),
None,
)
.await
{
// Expected whenever the project's container is still around:
// Docker will not delete an image a container was created
// from. Not a failure of the scrub — see `superseded_retained`.
log::info!(
"Scrubbed snapshot {} but kept the superseded image {}: {}",
summary.repo_tags.join(", "),
summary.id,
e
);
report
.superseded_retained
.push(summary.repo_tags.join(", "));
}
}
}
report
}
/// Commit `source_image` back over `repo:tag` with [`SECRET_ENV_KEYS`] blanked.
async fn rewrite_image_without_secrets(
docker: &bollard::Docker,
source_image: &str,
repo: &str,
tag: &str,
) -> Result<(), String> {
let scratch_name = format!("triple-c-scrub-{}", uuid::Uuid::new_v4().simple());
let created = docker
.create_container(
Some(CreateContainerOptions {
name: scratch_name.clone(),
..Default::default()
}),
Config::<String> {
image: Some(source_image.to_string()),
// Deliberately nothing else. The container is never started;
// its only job is to be a config to commit from, and every
// field left unset here is inherited from the image and
// inherited back out by the commit. Setting `cmd` to a
// placeholder — the obvious way to satisfy an image with no
// CMD — would write that placeholder into the rewritten
// snapshot. The base image has an ENTRYPOINT, so `create`
// needs no command; an image with neither fails here and is
// reported rather than silently mangled.
..Default::default()
},
)
.await
.map_err(|e| format!("could not create a scratch container: {}", e))?;
let commit = docker
.commit_container(
CommitContainerOptions {
container: created.id.clone(),
repo: repo.to_string(),
tag: tag.to_string(),
// Nothing is running; pausing a created container is an error.
pause: false,
..Default::default()
},
Config::<String> {
env: Some(blanked_secret_env()),
..Default::default()
},
)
.await
.map_err(|e| format!("could not re-commit without the credential: {}", e));
// Remove the scratch container whatever happened to the commit.
if let Err(e) = docker
.remove_container(
&created.id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
{
log::warn!("Could not remove scratch container {}: {}", scratch_name, e);
}
commit.map(|_| ())
}
/// Remove the snapshot image for a project (used on Reset / project removal).
pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
let docker = get_docker()?;
@@ -1806,8 +2111,10 @@ pub async fn container_needs_recreation(
// re-acquired (rotated), revoked, or opted out of. Both "" means no token
// is in play, which is also what a container predating this feature reports
// — so existing installs are not recreated until a token actually exists.
// Recreation is the only way to change container env, and it is also what
// makes MANAGED_AUTH_KEYS blank a revoked token out of the snapshot image.
// Recreation is the only way to change a container's env, so it is the only
// way a revoked token stops being live in one. It does *not* clean the
// snapshot image — `clear_claude_token` calls
// `scrub_secrets_from_snapshots` for that.
let expected_claude_token = claude_token_label(project);
let container_claude_token = get_label("triple-c.claude-token-version").unwrap_or_default();
if container_claude_token != expected_claude_token {
@@ -2195,4 +2502,84 @@ mod tests {
// The new per-backend haiku override also defaults cleanly.
assert!(p.ollama_config.unwrap().haiku_model_id.is_none());
}
// ── Snapshot secret stripping ────────────────────────────────────────
// The bug these cover: `docker commit` copies the container's whole
// environment into `triple-c-snapshot-{id}:latest`, that image outlives
// every container built from it, and revoking the shared token used to
// touch only the keychain — so `docker image inspect` kept returning a
// live ~1-year OAuth credential indefinitely.
#[test]
fn the_commit_override_blanks_every_credential_bearing_key() {
let blanked = blanked_secret_env();
assert_eq!(blanked.len(), SECRET_ENV_KEYS.len());
for key in SECRET_ENV_KEYS {
assert!(
blanked.contains(&format!("{}=", key)),
"{} is not blanked at commit time",
key
);
}
// Blanked, never omitted: the commit endpoint merges this over the
// container's env key by key, so a name left out is inherited with its
// original value.
assert!(blanked.iter().all(|e| e.ends_with('=')));
}
#[test]
fn the_shared_claude_token_is_one_of_the_stripped_keys() {
assert!(SECRET_ENV_KEYS.contains(&CLAUDE_OAUTH_TOKEN_ENV));
}
#[test]
fn an_image_holding_a_credential_is_detected() {
let env = vec![
"PATH=/usr/bin".to_string(),
format!("{}=sk-ant-oat01-{}", CLAUDE_OAUTH_TOKEN_ENV, "x".repeat(90)),
];
assert!(env_holds_a_secret(&env));
}
#[test]
fn a_blanked_or_secret_free_image_is_left_alone() {
// Already scrubbed.
assert!(!env_holds_a_secret(&blanked_secret_env()));
// Never had one.
assert!(!env_holds_a_secret(&[
"PATH=/usr/bin".to_string(),
"TZ=UTC".to_string(),
format!("{}=claude-sonnet-4-5", ANTHROPIC_DEFAULT_SONNET_MODEL),
]));
// A non-secret var whose *name* merely contains a secret name.
assert!(!env_holds_a_secret(&[
format!("MY_{}=not-a-secret", CLAUDE_OAUTH_TOKEN_ENV)
]));
}
#[test]
fn a_value_containing_an_equals_sign_is_still_recognised() {
let env = vec!["ANTHROPIC_AUTH_TOKEN=abc=def==".to_string()];
assert!(env_holds_a_secret(&env));
}
#[test]
fn the_scrub_report_only_claims_success_when_nothing_is_left() {
let clean = SnapshotScrubReport {
scrubbed: vec!["triple-c-snapshot-a:latest".to_string()],
..Default::default()
};
assert!(!clean.left_something_behind());
let partial = SnapshotScrubReport {
failed: vec![("triple-c-snapshot-b:latest".to_string(), "nope".to_string())],
..Default::default()
};
assert!(partial.left_something_behind());
let blind = SnapshotScrubReport {
unavailable: Some("Docker is not running".to_string()),
..Default::default()
};
assert!(blind.left_something_behind());
}
}