Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions src/commands/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
runGet,
runList,
runUpdate,
MAX_SECRET_FILE_BYTES,
} from './project.js';

const PROJECT_FIXTURE: CliProject = {
Expand Down Expand Up @@ -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 });
});
});
77 changes: 70 additions & 7 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()}`;
Expand Down Expand Up @@ -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()}`;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const enabled = opts.disable !== true;
Expand Down Expand Up @@ -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();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function secretFileError(
flagName: string,
reason: string,
details: Record<string, unknown>,
): ApiError {
return ApiError.fromEnvelope({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request.',
nextAction: `Flag \`--${flagName}\` is invalid: ${reason}.`,
requestId: 'local',
details: { field: flagName, reason, ...details },
},
});
}
Loading