From dfb6bc0856689700ddacbebbbdd1539b4794d47c Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:19:29 +0200 Subject: [PATCH] fix(core): never echo JSON parser text from the account store reader Bun 1.3.14 can include an unquoted token identifier in JSON.parse SyntaxError text. The account config and state files can hold Claustrum handles and OAuth access or refresh tokens; redact parser text before the reader surfaces corruption. --- packages/core/src/accounts.ts | 6 +++- packages/core/src/dump.ts | 3 +- packages/core/src/index.ts | 1 + packages/core/src/json.ts | 8 +++++ packages/opencode/src/tests/accounts.test.ts | 31 ++++++++++++++++++++ 5 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/json.ts diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index bc8b648d4..893d6ee8f 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -11,6 +11,7 @@ import { CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from './constants.ts' +import { parseJsonRedacted } from './json.ts' import { type LogLevel, log, logger } from './logger.ts' import { isTransientNetworkError } from './network-errors.ts' @@ -907,7 +908,10 @@ async function readJsonIfPresent(path: string): Promise<{ value: unknown }> { try { - return { exists: true, value: JSON.parse(await readFile(path, 'utf8')) } + return { + exists: true, + value: parseJsonRedacted(await readFile(path, 'utf8')), + } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return { exists: false, value: null } diff --git a/packages/core/src/dump.ts b/packages/core/src/dump.ts index 5eb8cc5d9..560adbeae 100644 --- a/packages/core/src/dump.ts +++ b/packages/core/src/dump.ts @@ -11,6 +11,7 @@ import { import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { extractBillingHeaderCCH } from './cch.ts' +import { parseJsonRedacted } from './json.ts' import { isSecretKey, logger, relayLog } from './logger.ts' type DumpHeaders = ConstructorParameters[0] @@ -535,7 +536,7 @@ function diffSummary(previousBodyText: string | undefined, bodyText: string) { function parseBody(bodyText: string): Record | null { try { - const parsed = JSON.parse(bodyText) + const parsed = parseJsonRedacted(bodyText) as Record | null return parsed != null && typeof parsed === 'object' && !Array.isArray(parsed) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1beb9db26..31207aeda 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export * from './commands/account.ts' export * from './constants.ts' export * from './dump.ts' export * from './fast.ts' +export * from './json.ts' export * from './killswitch.ts' export * from './logger.ts' export * from './logging.ts' diff --git a/packages/core/src/json.ts b/packages/core/src/json.ts new file mode 100644 index 000000000..9abaebc17 --- /dev/null +++ b/packages/core/src/json.ts @@ -0,0 +1,8 @@ +export function parseJsonRedacted(text: string): unknown { + try { + return JSON.parse(text) + } catch (error) { + if (error instanceof SyntaxError) throw new SyntaxError('invalid JSON') + throw error + } +} diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 8cf26500a..ace20d786 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -382,6 +382,37 @@ beforeEach(async () => { process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath }) +test('does not echo secrets from corrupt account stores', async () => { + const expectRedactedCorruption = async (path: string, content: string) => { + await writeFile(path, content, 'utf8') + let caught: unknown + try { + await loadAccounts(accountPath) + } catch (error) { + caught = error + } + + expect(caught instanceof Error).toBe(true) + const error = caught as Error + expect(error.message.includes('CANARY')).toBe(false) + expect(String(error.cause ?? '').includes('CANARY')).toBe(false) + expect(error.message).not.toContain('Unexpected identifier') + expect(error.message).toContain(path) + expect(error.message).toContain('fix or remove it') + } + + await writeFile(accountPath, JSON.stringify(baseStorage()), 'utf8') + await expectRedactedCorruption( + getAccountStatePath(accountPath), + '{"main":{"access":sk-ant-oat-CANARY-SECRET-0000}}', + ) + + await expectRedactedCorruption( + accountPath, + '{"main":{"claustrumHandle":claustrumHandle-CANARY-SECRET-0000}}', + ) +}) + afterEach(async () => { delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE await rm(tempDir, { recursive: true, force: true })