security(status): redact proxy and TLS-sensitive values in /status (#1672)

* security(status): redact proxy and TLS-sensitive values in /status

Make /status safe to share in public issues and screenshots by ensuring
proxy credentials, mTLS private key/cert paths, CA bundle paths, and
token-bearing URLs are never printed verbatim.

- Proxy URL: wrap with redactUrlForStatus (reuses redactUrlForDisplay
  for credential + sensitive query-param masking; additionally strips
  the URL fragment, which can carry tokens).
- NODE_EXTRA_CA_CERTS / CLAUDE_CODE_CLIENT_CERT: wrap with
  redactPathForStatus, which shortens a leading $HOME to ~ so paths
  stay useful without leaking usernames or home directory layout.
- CLAUDE_CODE_CLIENT_KEY: show the literal 'configured' rather than
  the path or value of a private key.

Adds two small reusable helpers in src/utils/statusRedaction.ts plus
unit tests, and extends status.test.ts with an integration test that
asserts the full buildAPIProviderProperties output is leak-free when
proxy credentials and mTLS env vars are set.

* fix(status): address status redaction review feedback

* fix(status): redact provider base URL secrets

* fix(status): unify URL status redaction
This commit is contained in:
Bogdan
2026-06-18 08:53:31 +08:00
committed by GitHub
parent 5af6f95c46
commit e5cb589031
4 changed files with 482 additions and 36 deletions
+202
View File
@@ -27,6 +27,10 @@ function restoreEnv(): void {
async function readPropertyValue(
label: string,
provider:
| 'firstParty'
| 'bedrock'
| 'vertex'
| 'foundry'
| 'openai'
| 'codex'
| 'nvidia-nim'
@@ -45,6 +49,31 @@ async function readPropertyValue(
?.value
}
async function readAPIProviderProperties(
provider:
| 'firstParty'
| 'bedrock'
| 'vertex'
| 'foundry'
| 'openai'
| 'gemini'
| 'mistral',
) {
mock.restore()
mock.module('./model/providers.js', () => ({
getAPIProvider: () => provider,
getAPIProviderForStatsig: () => provider,
isFirstPartyAnthropicBaseUrl: () => true,
isGithubNativeAnthropicMode: () => false,
}))
mock.module('./mtls.js', () => ({
getMTLSConfig: () => undefined,
}))
const nonce = `${Date.now()}-${Math.random()}`
const { buildAPIProviderProperties } = await import(`./status.js?ts=${nonce}`)
return buildAPIProviderProperties()
}
beforeEach(async () => {
await acquireSharedMutationLock('utils/status.test.ts')
})
@@ -108,3 +137,176 @@ test('buildAPIProviderProperties redacts credentials in OpenAI-compatible base U
'https://redacted:redacted@example.com/v1?api_key=redacted&model=qwen',
)
})
test('buildAPIProviderProperties redacts token-bearing OpenAI-compatible base URLs', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL =
'https://api.example.test/v1?token=OPENAI_LEAK&mode=test#access_token=fragment-leak'
const properties = await readAPIProviderProperties('openai')
const value = properties.find(property => property.label === 'OpenAI base URL')
?.value
expect(value).toBe('https://api.example.test/v1?token=redacted&mode=test')
const serialized = JSON.stringify(properties)
expect(serialized).not.toContain('OPENAI_LEAK')
expect(serialized).not.toContain('fragment-leak')
})
test('buildAPIProviderProperties redacts token-bearing Gemini base URLs', async () => {
process.env.CLAUDE_CODE_USE_GEMINI = '1'
process.env.GEMINI_BASE_URL =
'https://gemini.example.test/v1?api_key=GEMINI_LEAK&mode=test#secret=fragment-leak'
const properties = await readAPIProviderProperties('gemini')
const value = properties.find(property => property.label === 'Gemini base URL')
?.value
expect(value).toBe('https://gemini.example.test/v1?api_key=redacted&mode=test')
const serialized = JSON.stringify(properties)
expect(serialized).not.toContain('GEMINI_LEAK')
expect(serialized).not.toContain('fragment-leak')
})
test('buildAPIProviderProperties redacts token-bearing direct provider base URLs', async () => {
const cases = [
{
provider: 'firstParty' as const,
env: 'ANTHROPIC_BASE_URL',
label: 'Anthropic base URL',
},
{
provider: 'bedrock' as const,
env: 'BEDROCK_BASE_URL',
label: 'Bedrock base URL',
},
{
provider: 'vertex' as const,
env: 'VERTEX_BASE_URL',
label: 'Vertex base URL',
},
{
provider: 'foundry' as const,
env: 'ANTHROPIC_FOUNDRY_BASE_URL',
label: 'Microsoft Foundry base URL',
},
{
provider: 'mistral' as const,
env: 'MISTRAL_BASE_URL',
label: 'Mistral base URL',
},
]
for (const { provider, env, label } of cases) {
restoreEnv()
const host = provider.toLowerCase()
process.env[env] =
`https://${host}.example.test/v1?authorization=${provider}-leak&mode=test#token=fragment-leak`
const properties = await readAPIProviderProperties(provider)
const value = properties.find(property => property.label === label)?.value
expect(value).toBe(
`https://${host}.example.test/v1?authorization=redacted&mode=test`,
)
const serialized = JSON.stringify(properties)
expect(serialized).not.toContain(`${provider}-leak`)
expect(serialized).not.toContain('fragment-leak')
}
})
test('buildAPIProviderProperties redacts proxy credentials and mTLS paths', async () => {
const home = '/home/openclaude-status-test'
process.env.HOME = home
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.http_proxy
delete process.env.https_proxy
delete process.env.HTTP_PROXY
process.env.HTTPS_PROXY = 'https://alice:secret@proxy.example.com:8080'
process.env.NODE_EXTRA_CA_CERTS = `${home}/.config/ca-bundle.crt`
process.env.CLAUDE_CODE_CLIENT_CERT = `${home}/.config/client.crt`
process.env.CLAUDE_CODE_CLIENT_KEY = `${home}/.config/client.key`
mock.restore()
mock.module('./model/providers.js', () => ({
getAPIProvider: () => 'openai',
getAPIProviderForStatsig: () => 'openai',
isFirstPartyAnthropicBaseUrl: () => true,
isGithubNativeAnthropicMode: () => false,
}))
// getProxyUrl accepts env directly (not memoized), so no stub needed.
mock.module('./mtls.js', () => ({
getMTLSConfig: () => ({
cert: 'loaded-cert',
key: 'loaded-key',
}),
}))
const nonce = `${Date.now()}-${Math.random()}`
const { buildAPIProviderProperties } = await import(`./status.js?ts=${nonce}`)
const properties = buildAPIProviderProperties()
const byLabel = (label: string): unknown =>
properties.find(property => property.label === label)?.value
// Proxy URL is fully redacted: no username, no password.
expect(byLabel('Proxy')).toBe(
'https://redacted:redacted@proxy.example.com:8080/',
)
// CA cert path: home directory shortened to ~.
expect(byLabel('Additional CA cert(s)')).toBe('~/.config/ca-bundle.crt')
// mTLS cert path: home directory shortened to ~.
expect(byLabel('mTLS client cert')).toBe('~/.config/client.crt')
// mTLS client key: never reveal the private key path.
expect(byLabel('mTLS client key')).toBe('configured')
// No raw secrets leak into any property value. Cover every home-directory
// source redactPathForStatus consults (HOME, USERPROFILE, os.homedir()).
const serialized = JSON.stringify(properties)
expect(serialized).not.toContain('alice')
expect(serialized).not.toContain('secret')
const homeCandidates = [
process.env.HOME,
process.env.USERPROFILE,
].filter((v): v is string => Boolean(v))
for (const candidate of homeCandidates) {
expect(serialized).not.toContain(candidate)
}
})
test('buildAPIProviderProperties redacts proxy credentials from lowercase https_proxy', async () => {
// getProxyUrl() prefers the lowercase variant. Confirm the redaction path
// covers it — both env spellings flow through the same redactUrlForStatus.
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.http_proxy
delete process.env.HTTPS_PROXY
delete process.env.HTTP_PROXY
process.env.https_proxy = 'https://bob:hunter2@proxy.example.com:9090'
mock.restore()
mock.module('./model/providers.js', () => ({
getAPIProvider: () => 'openai',
getAPIProviderForStatsig: () => 'openai',
isFirstPartyAnthropicBaseUrl: () => true,
isGithubNativeAnthropicMode: () => false,
}))
mock.module('./mtls.js', () => ({
getMTLSConfig: () => undefined,
}))
const nonce = `${Date.now()}-${Math.random()}`
const { buildAPIProviderProperties } = await import(`./status.js?ts=${nonce}`)
const properties = buildAPIProviderProperties()
const byLabel = (label: string): unknown =>
properties.find(property => property.label === label)?.value
expect(byLabel('Proxy')).toBe(
'https://redacted:redacted@proxy.example.com:9090/',
)
const serialized = JSON.stringify(properties)
expect(serialized).not.toContain('bob')
expect(serialized).not.toContain('hunter2')
})
+37 -36
View File
@@ -22,7 +22,7 @@ import { getEnabledSettingSources, getSettingSourceDisplayNameCapitalized } from
import { getManagedFileSettingsPresence, getPolicySettingsOrigin, getSettingsForSource } from './settings/settings.js';
import type { ThemeName } from './theme.js';
import { getKnownProviderSecretEnvKeys, redactSecretValueForDisplay, type SecretValueSource } from './providerSecrets.js';
import { redactUrlForDisplay } from './urlRedaction.js';
import { redactPathForStatus, redactUrlForStatus } from './statusRedaction.js';
export type Property = {
label?: string;
value: React.ReactNode | Array<string>;
@@ -115,8 +115,7 @@ function pushRedactedProperty(
value: redactSecretValueForDisplay(value, secretSource) ?? value
});
}
function pushRedactedBaseUrlProperty(
function pushRedactedUrlProperty(
properties: Property[],
label: string,
value: string | undefined,
@@ -126,12 +125,11 @@ function pushRedactedBaseUrlProperty(
return;
}
pushRedactedProperty(
properties,
const redactedUrl = redactUrlForStatus(value);
properties.push({
label,
redactUrlForDisplay(value),
secretSource,
);
value: redactSecretValueForDisplay(redactedUrl, secretSource) ?? redactedUrl
});
}
export function buildSandboxProperties(): Property[] {
if (process.env.USER_TYPE !== 'ant') {
@@ -363,15 +361,19 @@ export function buildAPIProviderProperties(): Property[] {
});
}
if (apiProvider === 'firstParty') {
const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL;
if (anthropicBaseUrl) {
pushRedactedBaseUrlProperty(properties, 'Anthropic base URL', anthropicBaseUrl, secretSource);
}
pushRedactedUrlProperty(
properties,
'Anthropic base URL',
process.env.ANTHROPIC_BASE_URL,
secretSource,
);
} else if (apiProvider === 'bedrock') {
const bedrockBaseUrl = process.env.BEDROCK_BASE_URL;
if (bedrockBaseUrl) {
pushRedactedBaseUrlProperty(properties, 'Bedrock base URL', bedrockBaseUrl, secretSource);
}
pushRedactedUrlProperty(
properties,
'Bedrock base URL',
process.env.BEDROCK_BASE_URL,
secretSource,
);
properties.push({
label: 'AWS region',
value: getAWSRegion()
@@ -382,10 +384,12 @@ export function buildAPIProviderProperties(): Property[] {
});
}
} else if (apiProvider === 'vertex') {
const vertexBaseUrl = process.env.VERTEX_BASE_URL;
if (vertexBaseUrl) {
pushRedactedBaseUrlProperty(properties, 'Vertex base URL', vertexBaseUrl, secretSource);
}
pushRedactedUrlProperty(
properties,
'Vertex base URL',
process.env.VERTEX_BASE_URL,
secretSource,
);
const gcpProject = process.env.ANTHROPIC_VERTEX_PROJECT_ID;
if (gcpProject) {
properties.push({
@@ -403,10 +407,12 @@ export function buildAPIProviderProperties(): Property[] {
});
}
} else if (apiProvider === 'foundry') {
const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL;
if (foundryBaseUrl) {
pushRedactedBaseUrlProperty(properties, 'Microsoft Foundry base URL', foundryBaseUrl, secretSource);
}
pushRedactedUrlProperty(
properties,
'Microsoft Foundry base URL',
process.env.ANTHROPIC_FOUNDRY_BASE_URL,
secretSource,
);
const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE;
if (foundryResource) {
properties.push({
@@ -422,7 +428,7 @@ export function buildAPIProviderProperties(): Property[] {
} else if (apiProvider in OPENAI_COMPATIBLE_STATUS_METADATA) {
const metadata =
OPENAI_COMPATIBLE_STATUS_METADATA[apiProvider]!;
pushRedactedBaseUrlProperty(
pushRedactedUrlProperty(
properties,
metadata.baseUrlLabel,
process.env.OPENAI_BASE_URL,
@@ -443,40 +449,35 @@ export function buildAPIProviderProperties(): Property[] {
}
} else if (apiProvider === 'gemini') {
const geminiBaseUrl = process.env.GEMINI_BASE_URL;
pushRedactedBaseUrlProperty(properties, 'Gemini base URL', geminiBaseUrl, secretSource);
pushRedactedUrlProperty(properties, 'Gemini base URL', geminiBaseUrl, secretSource);
const geminiModel = process.env.GEMINI_MODEL;
pushRedactedProperty(properties, 'Model', geminiModel, secretSource);
} else if (apiProvider === 'mistral') {
const mistralBaseUrl = process.env.MISTRAL_BASE_URL;
pushRedactedBaseUrlProperty(properties, 'Mistral base URL', mistralBaseUrl, secretSource);
pushRedactedUrlProperty(properties, 'Mistral base URL', mistralBaseUrl, secretSource);
const mistralModel = process.env.MISTRAL_MODEL;
pushRedactedProperty(properties, 'Model', mistralModel, secretSource);
}
const proxyUrl = getProxyUrl();
if (proxyUrl) {
properties.push({
label: 'Proxy',
value: proxyUrl
});
}
pushRedactedUrlProperty(properties, 'Proxy', proxyUrl, secretSource);
const mtlsConfig = getMTLSConfig();
if (process.env.NODE_EXTRA_CA_CERTS) {
properties.push({
label: 'Additional CA cert(s)',
value: process.env.NODE_EXTRA_CA_CERTS
value: redactPathForStatus(process.env.NODE_EXTRA_CA_CERTS)
});
}
if (mtlsConfig) {
if (mtlsConfig.cert && process.env.CLAUDE_CODE_CLIENT_CERT) {
properties.push({
label: 'mTLS client cert',
value: process.env.CLAUDE_CODE_CLIENT_CERT
value: redactPathForStatus(process.env.CLAUDE_CODE_CLIENT_CERT)
});
}
if (mtlsConfig.key && process.env.CLAUDE_CODE_CLIENT_KEY) {
properties.push({
label: 'mTLS client key',
value: process.env.CLAUDE_CODE_CLIENT_KEY
value: 'configured'
});
}
}
+172
View File
@@ -0,0 +1,172 @@
import { homedir } from 'os'
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'
import { redactPathForStatus, redactUrlForStatus } from './statusRedaction.ts'
const REAL_HOMEDIR = homedir()
const ORIGINAL_HOME = process.env.HOME
const ORIGINAL_USERPROFILE = process.env.USERPROFILE
function restoreEnvValue(key: 'HOME' | 'USERPROFILE', value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
describe('redactUrlForStatus', () => {
test('redacts username and password in proxy URLs', () => {
const redacted = redactUrlForStatus(
'https://alice:secret@proxy.example.com:8080',
)
expect(redacted).not.toContain('alice')
expect(redacted).not.toContain('secret')
expect(redacted).toBe(
'https://redacted:redacted@proxy.example.com:8080/',
)
})
test('redacts token-like query parameters', () => {
const redacted = redactUrlForStatus(
'https://proxy.example.com:8080?token=abc123',
)
expect(redacted).not.toContain('abc123')
expect(redacted).toBe('https://proxy.example.com:8080/?token=redacted')
})
test('redacts password-only credentials', () => {
const redacted = redactUrlForStatus(
'https://:s3cret@proxy.example.com:8080',
)
expect(redacted).not.toContain('s3cret')
expect(redacted).toBe('https://:redacted@proxy.example.com:8080/')
})
test('removes fragments (may carry tokens)', () => {
const redacted = redactUrlForStatus(
'https://proxy.example.com:8080/path#top',
)
expect(redacted).toBe('https://proxy.example.com:8080/path')
expect(redacted).not.toContain('#')
})
test('removes fragment that carries a token-like value', () => {
const redacted = redactUrlForStatus(
'https://proxy.example.com:8080#access_token=leaked',
)
expect(redacted).not.toContain('leaked')
expect(redacted).not.toContain('#')
})
test('keeps local proxy URLs useful', () => {
const redacted = redactUrlForStatus('http://localhost:8888')
expect(redacted).toMatch(/^http:\/\/localhost:8888\/?$/)
})
test('keeps non-sensitive query params', () => {
const redacted = redactUrlForStatus('http://localhost:8888?model=llama3')
expect(redacted).toMatch(/^http:\/\/localhost:8888\/?\?model=llama3$/)
})
test('still redacts creds when the URL is malformed (regex fallback)', () => {
// No scheme -> `new URL()` throws; redactUrlForDisplay falls back to a
// regex that must still scrub the userinfo.
const redacted = redactUrlForStatus('//alice:secret@proxy.example.com:8080')
expect(redacted).not.toContain('alice')
expect(redacted).not.toContain('secret')
expect(redacted).toContain('redacted')
})
test('returns empty string as-is', () => {
expect(redactUrlForStatus('')).toBe('')
})
})
describe('redactPathForStatus', () => {
beforeEach(async () => {
await acquireSharedMutationLock('utils/statusRedaction.test.ts')
})
afterEach(() => {
try {
// Defensive: tests below mutate env. Restore so subsequent suites see
// the real environment.
restoreEnvValue('HOME', ORIGINAL_HOME)
restoreEnvValue('USERPROFILE', ORIGINAL_USERPROFILE)
} finally {
releaseSharedMutationLock()
}
})
test('shortens POSIX home directory paths to ~', () => {
const result = redactPathForStatus(`${REAL_HOMEDIR}/secrets/client.key`)
expect(result).toBe('~/secrets/client.key')
expect(result).not.toContain(REAL_HOMEDIR)
})
test('handles the home directory exactly', () => {
expect(redactPathForStatus(REAL_HOMEDIR)).toBe('~')
})
test('redacts via USERPROFILE when HOME does not match (Windows-style)', () => {
// Simulate a Windows path where HOME is unset/irrelevant but
// USERPROFILE points at the profile dir. On a POSIX test host
// os.homedir() returns the POSIX home and would mask this case,
// so unset HOME to prove USERPROFILE is consulted independently.
const fakeProfile = 'C:\\Users\\bob'
delete process.env.HOME
process.env.USERPROFILE = fakeProfile
const result = redactPathForStatus(`${fakeProfile}\\secrets\\client.key`)
expect(result).toBe('~\\secrets\\client.key')
expect(result).not.toContain('bob')
})
test('redacts Windows home paths when path casing differs', () => {
const fakeProfile = 'C:\\Users\\Bob'
delete process.env.HOME
process.env.USERPROFILE = fakeProfile
const result = redactPathForStatus('c:\\users\\bob\\secrets\\client.key')
expect(result).toBe('~\\secrets\\client.key')
expect(result.toLowerCase()).not.toContain('bob')
})
test('falls back to os.homedir() when HOME and USERPROFILE are unset', () => {
// Container/sandbox scenario: no env hints, rely on the OS passwd db.
delete process.env.HOME
delete process.env.USERPROFILE
const osHome = homedir()
// Skip on hosts where os.homedir() is '/' (filtered out by the helper).
if (!osHome || osHome === '/') return
const result = redactPathForStatus(`${osHome}/.config/client.key`)
expect(result).toBe('~/.config/client.key')
expect(result).not.toContain(osHome)
})
test('does not redact a path that merely contains "home" as a segment', () => {
// E.g. `/opt/home/backup/ca.crt` — substring match must not trigger.
expect(redactPathForStatus('/opt/home/backup/ca.crt')).toBe(
'/opt/home/backup/ca.crt',
)
})
test('leaves non-home absolute paths unchanged', () => {
expect(redactPathForStatus('/etc/ssl/certs/ca-certificates.crt')).toBe(
'/etc/ssl/certs/ca-certificates.crt',
)
})
test('returns empty string as-is', () => {
expect(redactPathForStatus('')).toBe('')
})
})
+71
View File
@@ -0,0 +1,71 @@
import { homedir } from 'os'
import { redactUrlForDisplay } from './urlRedaction.js'
/**
* Redact a URL for /status and other public-safe diagnostic surfaces.
*
* Wraps {@link redactUrlForDisplay} (which masks user/password and sensitive
* query params) and additionally drops the fragment, which can carry tokens
* or session IDs and is not useful when debugging proxy/TLS issues.
*
* Returned URLs are safe to paste in public issues or screenshots.
*/
export function redactUrlForStatus(rawUrl: string): string {
if (!rawUrl) return rawUrl
const redacted = redactUrlForDisplay(rawUrl)
// Drop the fragment. On the well-formed path (new URL succeeded) the
// produced string contains at most one '#', which is the fragment
// delimiter. On the malformed/regex-fallback path there is normally no
// '#' (userinfo containing '#' broke URL parsing and the regex consumed
// it); slicing at a stray '#' there would only shorten already-safe
// output, never expose a secret.
const hashIndex = redacted.indexOf('#')
return hashIndex === -1 ? redacted : redacted.slice(0, hashIndex)
}
/**
* Redact a filesystem path for /status and other public-safe diagnostic
* surfaces. Replaces a leading $HOME segment with `~` so absolute paths
* (e.g. mTLS cert/key, CA bundle) stay useful without leaking usernames
* or home directory layout.
*/
export function redactPathForStatus(rawPath: string): string {
if (!rawPath) return rawPath
const stripTrailingSep = (path: string) => path.replace(/[\\/]+$/, '')
const isWindowsLike = (path: string) =>
/^[a-zA-Z]:[\\/]/.test(path) || path.includes('\\')
const normalizeForCompare = (path: string) =>
isWindowsLike(path) ? path.toLowerCase() : path
const normalizedRawPath = stripTrailingSep(rawPath)
const rawPathForCompare = normalizeForCompare(normalizedRawPath)
// Cover POSIX (`HOME`), Windows (`USERPROFILE`), and containers where
// neither is set (`os.homedir()` reads the OS passwd db). Check each
// candidate; redact on the first prefix match. Filter out root-like
// candidates so a misconfigured homedir never causes mass over-redaction.
const candidates = [
process.env.HOME,
process.env.USERPROFILE,
homedir(),
]
.filter((h): h is string => Boolean(h))
.map(stripTrailingSep)
.filter(home => home !== '' && home !== '/' && !/^[a-zA-Z]:$/.test(home))
for (const home of candidates) {
const homeForCompare = normalizeForCompare(home)
if (rawPathForCompare === homeForCompare) return '~'
// Match either `/home/user/...` or `C:\Users\user\...` style prefixes.
if (
rawPathForCompare.startsWith(homeForCompare + '/') ||
rawPathForCompare.startsWith(homeForCompare + '\\')
) {
return '~' + rawPath.slice(home.length)
}
}
return rawPath
}