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 bc8b648d..893d6ee8 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 5eb8cc5d..560adbea 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 1beb9db2..31207aed 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 00000000..9abaebc1 --- /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 8cf26500..ace20d78 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 })