diff --git a/src/commands/project.test.ts b/src/commands/project.test.ts index ae98070..1625773 100644 --- a/src/commands/project.test.ts +++ b/src/commands/project.test.ts @@ -16,6 +16,7 @@ import { runGet, runList, runUpdate, + MAX_SECRET_FILE_BYTES, } from './project.js'; const PROJECT_FIXTURE: CliProject = { @@ -1423,3 +1424,185 @@ describe('dogfood 2026-06-30 — whitespace-only --name is rejected (parity with ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); }); }); + +describe('#282 — secret --*-file flags are guarded (structured error, exit 5, no raw ENOENT)', () => { + const noNetwork = () => { + throw new Error('network should not be hit'); + }; + const deps = (credentialsPath: string) => ({ + credentialsPath, + fetchImpl: makeFetch(noNetwork), + stdout: () => {}, + stderr: () => {}, + }); + const missingPath = () => join(mkdtempSync(join(tmpdir(), 'cli-missing-')), 'no-such-secret.txt'); + + it('runCredential --credential-file missing → VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runCredential( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + authType: 'API key', + credentialFile: missingPath(), + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runCredential --credential-file pointing at a directory → VALIDATION_ERROR (exit 5)', async () => { + const { credentialsPath } = makeCreds(); + const dir = mkdtempSync(join(tmpdir(), 'cli-cred-dir-')); + await expect( + runCredential( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + authType: 'API key', + credentialFile: dir, + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runCredential --credential-file over the size cap → PAYLOAD_TOO_LARGE (exit 5)', async () => { + const { credentialsPath } = makeCreds(); + const dir = mkdtempSync(join(tmpdir(), 'cli-cred-big-')); + const bigFile = join(dir, 'big.txt'); + writeFileSync(bigFile, 'a'.repeat(MAX_SECRET_FILE_BYTES + 1)); + await expect( + runCredential( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + authType: 'API key', + credentialFile: bigFile, + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'PAYLOAD_TOO_LARGE', exitCode: 5 }); + }); + + it('runCredential reads a valid --credential-file (trimmed) and sends it', async () => { + const { credentialsPath } = makeCreds(); + const dir = mkdtempSync(join(tmpdir(), 'cli-cred-ok-')); + const credFile = join(dir, 'cred.txt'); + writeFileSync(credFile, ' tok-from-file\n'); + let sentBody: { credential?: string } | undefined; + const fetchImpl = makeFetch((_url, init) => { + sentBody = init.body ? JSON.parse(init.body as string) : undefined; + return { status: 200, body: { projectId: 'p1', authType: 'API key', rewroteCount: 1 } }; + }); + await runCredential( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + authType: 'API key', + credentialFile: credFile, + }, + { credentialsPath, fetchImpl, stdout: () => {}, stderr: () => {} }, + ); + // blindfold: manual — the on-disk fixture written above is " tok-from-file\n"; the guard trims it + expect(sentBody?.credential).toBe('tok-from-file'); + }); + + it('runAutoAuth --password-file missing → VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runAutoAuth( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + method: 'password', + inject: 'bearer', + passwordFile: missingPath(), + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runAutoAuth --client-secret-file missing → VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runAutoAuth( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + method: 'refresh_token', + inject: 'bearer', + clientSecretFile: missingPath(), + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runAutoAuth --refresh-token-file missing → VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runAutoAuth( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + method: 'refresh_token', + inject: 'bearer', + refreshTokenFile: missingPath(), + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runCreate --password-file missing → VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runCreate( + { + profile: 'default', + output: 'json', + debug: false, + type: 'frontend', + name: 'FE', + targetUrl: 'https://example.com', + username: 'u', + passwordFile: missingPath(), + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runUpdate --password-file missing → VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runUpdate( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + passwordFile: missingPath(), + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); +}); diff --git a/src/commands/project.ts b/src/commands/project.ts index 6a0b332..4082b90 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { readFileSync } from 'node:fs'; +import { readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; import { Command } from 'commander'; import { emitDryRunBanner, @@ -210,7 +211,7 @@ export async function runCreate( // Resolve password: flag > file > none let password = opts.password; if (password === undefined && opts.passwordFile !== undefined) { - password = readFileSync(opts.passwordFile, 'utf8').trim(); + password = readSecretFileGuarded(opts.passwordFile, 'password-file'); } const idempotencyKey = opts.idempotencyKey ?? `cli-proj-create-${randomUUID()}`; @@ -326,7 +327,7 @@ export async function runUpdate( // filesystem, even when --password-file is present. let password = opts.password; if (password === undefined && opts.passwordFile !== undefined) { - password = readFileSync(opts.passwordFile, 'utf8').trim(); + password = readSecretFileGuarded(opts.passwordFile, 'password-file'); } const idempotencyKey = opts.idempotencyKey ?? `cli-proj-update-${randomUUID()}`; @@ -467,7 +468,7 @@ export async function runCredential( // except `public` (which clears it). let credential = opts.credential; if (credential === undefined && opts.credentialFile !== undefined) { - credential = readFileSync(opts.credentialFile, 'utf8').trim(); + credential = readSecretFileGuarded(opts.credentialFile, 'credential-file'); } if (opts.authType !== 'public' && (credential === undefined || credential === '')) { throw localValidationError( @@ -576,16 +577,18 @@ export async function runAutoAuth( // Resolve secrets from --*-file variants so they stay out of shell history. const password = opts.password ?? - (opts.passwordFile !== undefined ? readFileSync(opts.passwordFile, 'utf8').trim() : undefined); + (opts.passwordFile !== undefined + ? readSecretFileGuarded(opts.passwordFile, 'password-file') + : undefined); const clientSecret = opts.clientSecret ?? (opts.clientSecretFile !== undefined - ? readFileSync(opts.clientSecretFile, 'utf8').trim() + ? readSecretFileGuarded(opts.clientSecretFile, 'client-secret-file') : undefined); const refreshToken = opts.refreshToken ?? (opts.refreshTokenFile !== undefined - ? readFileSync(opts.refreshTokenFile, 'utf8').trim() + ? readSecretFileGuarded(opts.refreshTokenFile, 'refresh-token-file') : undefined); const enabled = opts.disable !== true; @@ -1082,3 +1085,63 @@ function localValidationError(message: string): ApiError { }, }); } + +/** Upper bound for any secret read from a `--*-file` flag. */ +export const MAX_SECRET_FILE_BYTES = 64 * 1024; + +/** + * Read a secret (password / credential / client-secret / refresh-token) from a + * `--*-file` flag, failing with a structured VALIDATION_ERROR (exit 5) instead + * of an unwrapped `readFileSync` error. A missing path, a non-regular file, or + * an oversized file all surface a typed envelope carrying the flag name — the + * same contract the `--code-file` guard in `test.ts` already provides. Without + * this, a missing file escaped as a raw `ENOENT` (exit 1) and broke the + * `--output json` envelope. `flagName` is the bare flag (e.g. `credential-file`). + */ +function readSecretFileGuarded(path: string, flagName: string): string { + const absolute = resolve(path); + let stat; + try { + stat = statSync(absolute); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw secretFileError(flagName, 'must point to a readable file', { + path: absolute, + error: message, + }); + } + + if (!stat.isFile()) { + throw secretFileError(flagName, 'must point to a regular file', { path: absolute }); + } + + if (stat.size > MAX_SECRET_FILE_BYTES) { + throw ApiError.fromEnvelope({ + error: { + code: 'PAYLOAD_TOO_LARGE', + message: 'Secret file is too large.', + nextAction: `Flag \`--${flagName}\` is invalid: file must be at most ${MAX_SECRET_FILE_BYTES} bytes.`, + requestId: 'local', + details: { field: flagName, sizeBytes: stat.size, maxBytes: MAX_SECRET_FILE_BYTES }, + }, + }); + } + + return readFileSync(absolute, 'utf8').trim(); +} + +function secretFileError( + flagName: string, + reason: string, + details: Record, +): ApiError { + return ApiError.fromEnvelope({ + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid request.', + nextAction: `Flag \`--${flagName}\` is invalid: ${reason}.`, + requestId: 'local', + details: { field: flagName, reason, ...details }, + }, + }); +}