Skip to content

Commit 94c18b8

Browse files
committed
fix(auth): harden restore containment checks
1 parent 4b779ca commit 94c18b8

4 files changed

Lines changed: 206 additions & 7 deletions

File tree

lib/codex-manager.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
assertNamedBackupRestorePath,
6161
importAccounts,
6262
getNamedBackupsDirectoryPath,
63+
isNamedBackupContainmentError,
6364
listNamedBackups,
6465
NAMED_BACKUP_ASSESS_CONCURRENCY,
6566
findMatchingAccountIndex,
@@ -3875,7 +3876,8 @@ async function runAuthLogin(): Promise<number> {
38753876
menuResult.mode === "check" ||
38763877
menuResult.mode === "deep-check" ||
38773878
menuResult.mode === "forecast" ||
3878-
menuResult.mode === "fix";
3879+
menuResult.mode === "fix" ||
3880+
menuResult.mode === "restore-backup";
38793881
if (modeTouchesQuotaCache) {
38803882
const pendingQuotaRefresh = pendingMenuQuotaRefresh;
38813883
if (pendingQuotaRefresh) {
@@ -4262,6 +4264,9 @@ async function runBackupRestoreManager(
42624264
assessments.push(result.value);
42634265
continue;
42644266
}
4267+
if (isNamedBackupContainmentError(result.reason)) {
4268+
throw result.reason;
4269+
}
42654270
const backupName = chunk[resultIndex]?.name ?? "unknown";
42664271
const reason =
42674272
result.reason instanceof Error

lib/storage.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AsyncLocalStorage } from "node:async_hooks";
22
import { createHash } from "node:crypto";
3-
import { existsSync, promises as fs, type Dirent } from "node:fs";
3+
import { existsSync, promises as fs, realpathSync, type Dirent } from "node:fs";
44
import { basename, dirname, isAbsolute, join, relative } from "node:path";
55
import { ACCOUNT_LIMITS } from "./constants.js";
66
import { createLogger } from "./logger.js";
@@ -1737,7 +1737,7 @@ async function retryTransientFilesystemOperation<T>(
17371737
throw error;
17381738
}
17391739
const baseDelayMs = TRANSIENT_FILESYSTEM_BASE_DELAY_MS * 2 ** attempt;
1740-
const jitterMs = Math.floor(Math.random() * 10);
1740+
const jitterMs = Math.floor(Math.random() * baseDelayMs);
17411741
await new Promise((resolve) =>
17421742
setTimeout(resolve, baseDelayMs + jitterMs),
17431743
);
@@ -2009,12 +2009,26 @@ async function findExistingNamedBackupPath(
20092009
return undefined;
20102010
}
20112011

2012+
function resolvePathForNamedBackupContainment(path: string): string {
2013+
const resolvedPath = resolvePath(path);
2014+
if (!existsSync(resolvedPath)) {
2015+
return resolvedPath;
2016+
}
2017+
try {
2018+
return realpathSync.native(resolvedPath);
2019+
} catch {
2020+
return resolvedPath;
2021+
}
2022+
}
2023+
20122024
export function assertNamedBackupRestorePath(
20132025
path: string,
20142026
backupRoot: string,
20152027
): string {
20162028
const resolvedPath = resolvePath(path);
2017-
const relativePath = relative(resolvePath(backupRoot), resolvedPath);
2029+
const resolvedBackupRoot = resolvePathForNamedBackupContainment(backupRoot);
2030+
const containedPath = resolvePathForNamedBackupContainment(resolvedPath);
2031+
const relativePath = relative(resolvedBackupRoot, containedPath);
20182032
const firstSegment = relativePath.split(/[\\/]/)[0];
20192033
if (
20202034
relativePath.length === 0 ||
@@ -2026,7 +2040,7 @@ export function assertNamedBackupRestorePath(
20262040
return resolvedPath;
20272041
}
20282042

2029-
function isNamedBackupContainmentError(error: unknown): boolean {
2043+
export function isNamedBackupContainmentError(error: unknown): boolean {
20302044
return (
20312045
error instanceof Error &&
20322046
/escapes backup directory/i.test(error.message)

test/codex-manager-cli.test.ts

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { resolve } from "node:path";
2+
import { fileURLToPath } from "node:url";
23
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
34

4-
const MOCK_BACKUP_DIR = resolve(process.cwd(), ".vitest-mock-backups");
5+
const MOCK_BACKUP_DIR = fileURLToPath(
6+
new URL("./.vitest-mock-backups", import.meta.url),
7+
);
58
const mockBackupPath = (name: string): string =>
69
resolve(MOCK_BACKUP_DIR, `${name}.json`);
710

@@ -2899,6 +2902,55 @@ describe("codex manager cli commands", () => {
28992902
}
29002903
});
29012904

2905+
it("propagates containment errors from batch backup assessment and returns to the login menu", async () => {
2906+
setInteractiveTTY(true);
2907+
loadAccountsMock.mockResolvedValue(null);
2908+
const now = Date.now();
2909+
listNamedBackupsMock.mockResolvedValue([
2910+
{
2911+
name: "escaped-backup",
2912+
path: mockBackupPath("escaped-backup"),
2913+
createdAt: null,
2914+
updatedAt: now,
2915+
sizeBytes: 128,
2916+
version: 3,
2917+
accountCount: 1,
2918+
schemaErrors: [],
2919+
valid: true,
2920+
loadError: undefined,
2921+
},
2922+
]);
2923+
assessNamedBackupRestoreMock.mockRejectedValueOnce(
2924+
new Error("Backup path escapes backup directory"),
2925+
);
2926+
promptLoginModeMock
2927+
.mockResolvedValueOnce({ mode: "restore-backup" })
2928+
.mockResolvedValueOnce({ mode: "cancel" });
2929+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
2930+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
2931+
2932+
try {
2933+
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
2934+
const exitCode = await runCodexMultiAuthCli(["auth", "login"]);
2935+
2936+
expect(exitCode).toBe(0);
2937+
expect(promptLoginModeMock).toHaveBeenCalledTimes(2);
2938+
expect(selectMock).not.toHaveBeenCalled();
2939+
expect(importAccountsMock).not.toHaveBeenCalled();
2940+
expect(errorSpy).toHaveBeenCalledWith(
2941+
expect.stringContaining(
2942+
"Restore failed: Backup path escapes backup directory",
2943+
),
2944+
);
2945+
expect(warnSpy).not.toHaveBeenCalledWith(
2946+
expect.stringContaining('Skipped backup assessment for "escaped-backup"'),
2947+
);
2948+
} finally {
2949+
errorSpy.mockRestore();
2950+
warnSpy.mockRestore();
2951+
}
2952+
});
2953+
29022954
it("keeps healthy backups selectable when one assessment fails", async () => {
29032955
setInteractiveTTY(true);
29042956
loadAccountsMock.mockResolvedValue(null);
@@ -4845,6 +4897,96 @@ describe("codex manager cli commands", () => {
48454897
}
48464898
});
48474899

4900+
it("waits for an in-flight menu quota refresh before starting backup restore", async () => {
4901+
setInteractiveTTY(true);
4902+
const now = Date.now();
4903+
loadAccountsMock.mockResolvedValue({
4904+
version: 3,
4905+
activeIndex: 0,
4906+
activeIndexByFamily: { codex: 0 },
4907+
accounts: [
4908+
{
4909+
email: "restore@example.com",
4910+
accountId: "acc-restore",
4911+
accessToken: "access-restore",
4912+
expiresAt: now + 3_600_000,
4913+
refreshToken: "refresh-restore",
4914+
addedAt: now,
4915+
lastUsed: now,
4916+
enabled: true,
4917+
},
4918+
],
4919+
});
4920+
loadDashboardDisplaySettingsMock.mockResolvedValue({
4921+
showPerAccountRows: true,
4922+
showQuotaDetails: true,
4923+
showForecastReasons: true,
4924+
showRecommendations: true,
4925+
showLiveProbeNotes: true,
4926+
menuAutoFetchLimits: true,
4927+
menuShowFetchStatus: true,
4928+
menuQuotaTtlMs: 60_000,
4929+
menuSortEnabled: true,
4930+
menuSortMode: "ready-first",
4931+
menuSortPinCurrent: true,
4932+
menuSortQuickSwitchVisibleRow: true,
4933+
});
4934+
let currentQuotaCache: {
4935+
byAccountId: Record<string, unknown>;
4936+
byEmail: Record<string, unknown>;
4937+
} = {
4938+
byAccountId: {},
4939+
byEmail: {},
4940+
};
4941+
loadQuotaCacheMock.mockImplementation(async () =>
4942+
structuredClone(currentQuotaCache),
4943+
);
4944+
saveQuotaCacheMock.mockImplementation(async (value: typeof currentQuotaCache) => {
4945+
currentQuotaCache = structuredClone(value);
4946+
});
4947+
const fetchStarted = createDeferred<void>();
4948+
const releaseFetch = createDeferred<void>();
4949+
fetchCodexQuotaSnapshotMock.mockImplementation(async () => {
4950+
fetchStarted.resolve();
4951+
await releaseFetch.promise;
4952+
return {
4953+
status: 200,
4954+
model: "gpt-5-codex",
4955+
primary: {},
4956+
secondary: {},
4957+
};
4958+
});
4959+
listNamedBackupsMock.mockResolvedValue([]);
4960+
promptLoginModeMock
4961+
.mockResolvedValueOnce({ mode: "restore-backup" })
4962+
.mockResolvedValueOnce({ mode: "cancel" });
4963+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
4964+
4965+
try {
4966+
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
4967+
const runPromise = runCodexMultiAuthCli(["auth", "login"]);
4968+
4969+
await fetchStarted.promise;
4970+
await Promise.resolve();
4971+
4972+
expect(listNamedBackupsMock).not.toHaveBeenCalled();
4973+
4974+
releaseFetch.resolve();
4975+
4976+
const exitCode = await runPromise;
4977+
4978+
expect(exitCode).toBe(0);
4979+
expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1);
4980+
expect(listNamedBackupsMock).toHaveBeenCalledTimes(1);
4981+
expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan(
4982+
listNamedBackupsMock.mock.invocationCallOrder[0] ??
4983+
Number.POSITIVE_INFINITY,
4984+
);
4985+
} finally {
4986+
logSpy.mockRestore();
4987+
}
4988+
});
4989+
48484990
it("skips a second destructive action while reset is already running", async () => {
48494991
const now = Date.now();
48504992
const skipMessage =

test/storage.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { existsSync, promises as fs } from "node:fs";
22
import { tmpdir } from "node:os";
3-
import { dirname, join } from "node:path";
3+
import { dirname, join, resolve } from "node:path";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import { ACCOUNT_LIMITS } from "../lib/constants.js";
66
import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js";
77
import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js";
88
import { removeWithRetry } from "./helpers/remove-with-retry.js";
99
import {
1010
assessNamedBackupRestore,
11+
assertNamedBackupRestorePath,
1112
buildNamedBackupPath,
1213
clearAccounts,
1314
clearFlaggedAccounts,
@@ -1858,6 +1859,43 @@ describe("storage", () => {
18581859
}
18591860
});
18601861

1862+
it("rejects backup paths whose real path escapes the backups directory through symlinked directories", async () => {
1863+
const backupRoot = join(dirname(testStoragePath), "backups");
1864+
const outsideRoot = join(testWorkDir, "outside");
1865+
const linkedRoot = join(backupRoot, "linked");
1866+
const outsideBackupPath = join(outsideRoot, "escape.json");
1867+
await fs.mkdir(backupRoot, { recursive: true });
1868+
await fs.mkdir(outsideRoot, { recursive: true });
1869+
await fs.writeFile(
1870+
outsideBackupPath,
1871+
JSON.stringify({
1872+
version: 3,
1873+
activeIndex: 0,
1874+
accounts: [
1875+
{
1876+
accountId: "linked-escape",
1877+
refreshToken: "ref-linked-escape",
1878+
addedAt: 1,
1879+
lastUsed: 1,
1880+
},
1881+
],
1882+
}),
1883+
"utf-8",
1884+
);
1885+
await fs.symlink(
1886+
resolve(outsideRoot),
1887+
linkedRoot,
1888+
process.platform === "win32" ? "junction" : "dir",
1889+
);
1890+
1891+
expect(() =>
1892+
assertNamedBackupRestorePath(
1893+
join(linkedRoot, "escape.json"),
1894+
backupRoot,
1895+
),
1896+
).toThrow(/escapes backup directory/i);
1897+
});
1898+
18611899
it("rejects named backup listings whose resolved paths escape the backups directory", async () => {
18621900
const backupRoot = join(dirname(testStoragePath), "backups");
18631901
const originalReaddir = fs.readdir.bind(fs);

0 commit comments

Comments
 (0)