Close gateway-secret desync, TOCTOU, and undisclosed custom-image gaps
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m53s
Build App (Preview) / build-linux (pull_request) Successful in 7m5s
Build App (Preview) / prune-previews (pull_request) Successful in 1s

Round 4 review findings:

- Disclose and warn on a custom Docker image the import would set (HIGH):
  it's the image every project container is created from, so an
  undisclosed change here was a sharper version of the redirected-base-URL
  problem round 3 already flagged for the model backends.
- Recreate a running gateway container when an import restores a new
  secret with the shape unchanged (MEDIUM): reconcile_gateway's shape
  comparison can't see a secret-only change, so the container would
  otherwise keep serving old key material indefinitely.
- Report keychain write failures back to the caller instead of only
  logging them (MEDIUM): apply_settings_import now returns
  SettingsImportOutcome with secret_restore_warnings so a partial restore
  can't read as unqualified success.
- Pin a hash of the previewed file's ciphertext and refuse to apply if it
  changed on disk (MEDIUM): closes a TOCTOU between preview and apply.
- Sanitize and cap every free-form string a preview surfaces, and move the
  warning boxes above the replace list in the UI (MEDIUM): an unbounded
  base URL or image name could otherwise push the security warnings below
  the scroll fold.
- Validate the Docker socket path on import the same as the SSH key and CA
  cert paths (LOW): it was the one mounted host path validate_settings_update
  didn't cover.
- Fix ExportedSecrets::is_empty() to treat whitespace-only as blank, like
  every other secret-presence check in this feature (LOW).
- Authenticate the file header as AEAD associated data (LOW, defense in
  depth) and correct two doc comments that overstated the password not
  being cached.
This commit is contained in:
2026-08-27 14:24:06 -07:00
parent a606e3ab20
commit 97e58db3c1
12 changed files with 491 additions and 89 deletions
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import ImportSettingsModal from "./ImportSettingsModal";
import type { AppSettings, SettingsImportPreview } from "../../lib/types";
import type { AppSettings, SettingsImportOutcome, SettingsImportPreview } from "../../lib/types";
const previewSettingsImport = vi.fn();
const applySettingsImport = vi.fn();
@@ -30,8 +30,14 @@ const samplePreview: SettingsImportPreview = {
llamacpp_base_url: null,
openai_compatible_base_url: null,
gateway_api_base: null,
image_source: "registry",
custom_image_name: null,
};
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
return { settings, secret_restore_warnings: secretRestoreWarnings };
}
describe("ImportSettingsModal", () => {
it("keeps 'Choose file' disabled until a password is entered", () => {
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
@@ -43,7 +49,7 @@ describe("ImportSettingsModal", () => {
it("shows the preview and confirms with the same password used to open it", async () => {
previewSettingsImport.mockResolvedValue(samplePreview);
applySettingsImport.mockResolvedValue({} as AppSettings);
applySettingsImport.mockResolvedValue(outcome({} as AppSettings));
const onImported = vi.fn();
render(<ImportSettingsModal onClose={vi.fn()} onImported={onImported} />);
@@ -70,6 +76,38 @@ describe("ImportSettingsModal", () => {
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
});
it("warns about a custom Docker image every time, not just on change", async () => {
previewSettingsImport.mockResolvedValue({
...samplePreview,
image_source: "custom",
custom_image_name: "ghcr.io/attacker/triple-c:latest",
});
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
expect(
await screen.findByText(/custom docker image: ghcr\.io\/attacker\/triple-c:latest/i),
).toBeInTheDocument();
});
it("shows a secret-restore warning alongside success rather than hiding it", async () => {
previewSettingsImport.mockResolvedValue(samplePreview);
applySettingsImport.mockResolvedValue(
outcome({} as AppSettings, ["Could not restore the gateway master key: keychain locked"]),
);
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await screen.findByText(/2 global custom env vars/i);
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
expect(await screen.findByText(/could not restore the gateway master key/i)).toBeInTheDocument();
});
it("closes quietly when the file picker is dismissed", async () => {
previewSettingsImport.mockResolvedValue(null);
const onClose = vi.fn();
@@ -27,6 +27,7 @@ export default function ImportSettingsModal({ onClose, onImported }: Props) {
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<SettingsImportPreview | null>(null);
const [applied, setApplied] = useState(false);
const [secretWarnings, setSecretWarnings] = useState<string[]>([]);
const handleChooseFile = async () => {
setError(null);
@@ -46,9 +47,10 @@ export default function ImportSettingsModal({ onClose, onImported }: Props) {
setError(null);
setBusy(true);
try {
const settings = await applySettingsImport(password);
const outcome = await applySettingsImport(password);
setApplied(true);
onImported(settings);
setSecretWarnings(outcome.secret_restore_warnings);
onImported(outcome.settings);
} catch (e) {
setError(String(e));
} finally {
@@ -99,29 +101,46 @@ export default function ImportSettingsModal({ onClose, onImported }: Props) {
}
>
{applied ? (
<p className="text-[13px] text-[var(--success)]">Settings imported.</p>
<div className="space-y-2">
<p className="text-[13px] text-[var(--success)]">Settings imported.</p>
{secretWarnings.map((warning) => (
<p
key={warning}
className="px-2.5 py-2 text-xs text-[var(--error)] bg-[var(--error-muted)] border border-[var(--error)]/40 rounded-[var(--radius-control)] leading-snug"
>
{warning}
</p>
))}
</div>
) : preview ? (
<div className="space-y-3">
<p className="text-xs text-[var(--text-secondary)]">
Exported {new Date(preview.exported_at).toLocaleString()} from Triple-C{" "}
{preview.app_version}.
</p>
<div>
<p className="text-[13px] font-medium text-[var(--text-primary)]">This will replace:</p>
<ul className="mt-1 list-disc pl-4 text-[13px] text-[var(--text-secondary)] space-y-0.5">
{describeImport(preview).map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
{/* Warnings render before the replace list, deliberately: the list
* below can run long, and the one thing here that most needs to
* stay above the fold while scrolling is "this turns on a
* network-listening service" or "this runs a different image" —
* not a bullet buried among ordinary settings. */}
{describeImportWarnings(preview).map((warning) => (
<p
key={warning}
className="px-2.5 py-2 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] leading-snug"
className="px-2.5 py-2 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] leading-snug break-all"
>
{warning}
</p>
))}
<div>
<p className="text-[13px] font-medium text-[var(--text-primary)]">This will replace:</p>
<ul className="mt-1 list-disc pl-4 text-[13px] text-[var(--text-secondary)] space-y-0.5">
{describeImport(preview).map((item) => (
<li key={item} className="break-all">
{item}
</li>
))}
</ul>
</div>
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
</div>
) : (
+21
View File
@@ -18,6 +18,8 @@ function preview(overrides: Partial<SettingsImportPreview> = {}): SettingsImport
llamacpp_base_url: null,
openai_compatible_base_url: null,
gateway_api_base: null,
image_source: "registry",
custom_image_name: null,
...overrides,
};
}
@@ -76,6 +78,18 @@ describe("describeImport", () => {
expect(items.some((i) => i.includes("llama.cpp"))).toBe(false);
expect(items.some((i) => i.includes("OpenAI-compatible"))).toBe(false);
});
it("names a custom Docker image when set, falling back to a placeholder if unnamed", () => {
expect(
describeImport(preview({ image_source: "custom", custom_image_name: "ghcr.io/me/triple-c" })),
).toContain("Docker image: ghcr.io/me/triple-c");
expect(describeImport(preview({ image_source: "custom", custom_image_name: null }))).toContain(
"Docker image: (no image name set)",
);
expect(describeImport(preview({ image_source: "registry" })).some((i) => i.includes("Docker image"))).toBe(
false,
);
});
});
describe("describeImportWarnings", () => {
@@ -101,4 +115,11 @@ describe("describeImportWarnings", () => {
"Includes a web terminal access token that will activate the next time the web terminal is turned on.",
]);
});
it("warns about a custom Docker image every time, not only when it changes", () => {
expect(
describeImportWarnings(preview({ image_source: "custom", custom_image_name: "evil:latest" })),
).toEqual(["Runs every project container from a custom Docker image: evil:latest."]);
expect(describeImportWarnings(preview({ image_source: "registry" }))).toEqual([]);
});
});
+12
View File
@@ -25,6 +25,9 @@ export function describeImport(preview: SettingsImportPreview): string[] {
items.push(`OpenAI-compatible server: ${preview.openai_compatible_base_url}`);
}
if (preview.gateway_api_base) items.push(`Gateway upstream: ${preview.gateway_api_base}`);
if (preview.image_source === "custom") {
items.push(`Docker image: ${preview.custom_image_name ?? "(no image name set)"}`);
}
return items;
}
@@ -41,6 +44,10 @@ export function describeImport(preview: SettingsImportPreview): string[] {
* would silently become live the next time someone flips the terminal on
* through the UI, with no import-time signal that it wasn't freshly
* generated.
*
* A custom Docker image gets a warning every time, not just on change: it's
* the image every project container is created from, so it's worth calling
* out regardless of what was configured before the import.
*/
export function describeImportWarnings(preview: SettingsImportPreview): string[] {
const warnings: string[] = [];
@@ -51,5 +58,10 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[]
"Includes a web terminal access token that will activate the next time the web terminal is turned on.",
);
}
if (preview.image_source === "custom") {
warnings.push(
`Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`,
);
}
return warnings;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -49,7 +49,7 @@ export const exportSettings = (password: string) =>
export const previewSettingsImport = (password: string) =>
invoke<SettingsImportPreview | null>("preview_settings_import", { password });
export const applySettingsImport = (password: string) =>
invoke<AppSettings>("apply_settings_import", { password });
invoke<SettingsImportOutcome>("apply_settings_import", { password });
// AWS
export const awsSsoRefresh = (projectId: string) =>
+14
View File
@@ -316,6 +316,20 @@ export interface SettingsImportPreview {
llamacpp_base_url: string | null;
openai_compatible_base_url: string | null;
gateway_api_base: string | null;
/** Whether the import sets a custom Docker image, and its name if so —
* this is the image every project container is created from, so worth
* more attention than an ordinary setting. */
image_source: ImageSource;
custom_image_name: string | null;
}
/** What `apply_settings_import` returns: the settings that were actually
* saved, plus a note for each keychain secret the import carried but could
* not be restored (a partial keychain failure must not read as unqualified
* success just because the settings half went through). */
export interface SettingsImportOutcome {
settings: AppSettings;
secret_restore_warnings: string[];
}
/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride