Skip to content

Commit f1efc2d

Browse files
committed
fix(cli): address James's review notes on stash env (#682)
- Note 2: treat an ABSENT access-key role as member (server default) rather than skipping the check — `(role ?? 'member')` — so the documented 'verified on the response' guarantee doesn't depend on the field always being present. - Note 3: only append the 'name already taken, use --name' hint on 400/409 (where duplicates land), not every non-403 — a 500 no longer carries a misleading suggestion. - Note 4 (defense-in-depth): reject any server-provided emitted value (CRN/region, client id, access key) that contains a control character, closing the dotenv line-injection surface the name guard already covered. clientKey is hex by construction. Tests: absent-role → success; 500 omits the --name hint (keeps the leftover-client note); a newline in the server region → refused, no injected line on stdout. 27 env unit tests, suite 540/62 green. Note 1 (CRN round-trip) needs no code change — the pre-merge live smoke already inits a wasm-inline client with the emitted workspaceCrn (see PR comment); replying there. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
1 parent 5dbe58a commit f1efc2d

2 files changed

Lines changed: 75 additions & 6 deletions

File tree

packages/cli/src/commands/env/__tests__/env.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,19 @@ describe('envCommand — failure modes', () => {
338338
expect(message).toContain('--name')
339339
})
340340

341+
it('does NOT append the --name hint to a 500 (only 400/409 are duplicates)', async () => {
342+
stubSession()
343+
stubFetch({
344+
accessKey: new Response('internal error', { status: 500 }),
345+
})
346+
347+
await expectExit(envCommand({ name: 'x' }), 1)
348+
const message = lastError()
349+
expect(message).not.toContain('already taken')
350+
// The leftover-client note still appears — a client was minted.
351+
expect(message).toContain("ZeroKMS client 'x'")
352+
})
353+
341354
it('maps a request timeout to a clear request_timeout error', async () => {
342355
stubSession()
343356
vi.stubGlobal(
@@ -433,6 +446,39 @@ describe('envCommand — response validation (nothing minted may print undefined
433446
// The over-privileged secret itself must not be printed anywhere.
434447
expect(stdout()).not.toContain('CSAKTid.secret')
435448
})
449+
450+
it('treats an absent role as member (server default) and succeeds', async () => {
451+
stubSession()
452+
stubFetch({
453+
// No `role` field — the check must assume member, not skip.
454+
accessKey: Response.json(
455+
{ accessKey: 'CSAKTid.secret' },
456+
{ status: 201 },
457+
),
458+
})
459+
460+
await envCommand({ name: 'x' })
461+
expect(stdout()).toContain('CS_CLIENT_ACCESS_KEY=CSAKTid.secret')
462+
})
463+
464+
it('refuses to emit a server value containing a control character', async () => {
465+
stubSession()
466+
stubFetch({
467+
// A newline in the region would break out of the CRN's dotenv line.
468+
workspaces: Response.json([
469+
{ id: 'WS123', region: 'us-east-1.aws\nCS_INJECTED=evil' },
470+
]),
471+
})
472+
473+
await expectExit(envCommand({ name: 'x', json: true }), 1)
474+
const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string)
475+
expect(event).toMatchObject({
476+
status: 'error',
477+
code: 'unexpected_response',
478+
})
479+
expect(String(event.message)).toContain('workspaceCrn')
480+
expect(stdout()).not.toContain('CS_INJECTED')
481+
})
436482
})
437483

438484
describe('envCommand — --write', () => {

packages/cli/src/commands/env/index.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -446,30 +446,53 @@ async function mintCredentials(keyName: string): Promise<MintedCredentials> {
446446
'create the access key',
447447
keyResponse,
448448
)
449-
throw new MintError(
450-
base.code,
451-
`${base.message} If the name is already taken, rerun with a different --name.${leftover}`,
452-
)
449+
// A duplicate name comes back as 400 (BadRequest) or 409 (Conflict); only
450+
// then is "pick a different name" the right suggestion — a 500 shouldn't
451+
// carry it.
452+
const dupHint =
453+
keyResponse.status === 400 || keyResponse.status === 409
454+
? ' If the name is already taken, rerun with a different --name.'
455+
: ''
456+
throw new MintError(base.code, `${base.message}${dupHint}${leftover}`)
453457
}
454458
const accessKeyBody = parsed(
455459
accessKeySchema,
456460
await keyResponse.json(),
457461
'access key',
458462
)
459-
if (accessKeyBody.role && accessKeyBody.role.toLowerCase() !== 'member') {
463+
// The request pinned `member` and the server owns the default, so an ABSENT
464+
// role means "server default (member)" — treat it as member rather than
465+
// skipping the check, so the documented "verified on the response" guarantee
466+
// doesn't quietly depend on the field always being present.
467+
const returnedRole = (accessKeyBody.role ?? 'member').toLowerCase()
468+
if (returnedRole !== 'member') {
460469
throw new MintError(
461470
'unexpected_role',
462471
`CTS returned a '${accessKeyBody.role}' access key where member was requested — refusing to emit it. Revoke '${keyName}' in the dashboard.`,
463472
)
464473
}
465474

466-
return {
475+
const creds: MintedCredentials = {
467476
keyName,
468477
workspaceCrn,
469478
clientId: client.id,
470479
clientKey,
471480
accessKey: accessKeyBody.accessKey,
472481
}
482+
// Defense-in-depth: the name is already control-char-guarded, but the
483+
// server-provided values (CRN region, client id, access key) land on their
484+
// own dotenv lines too. A control char — especially a newline — could break
485+
// out of its line and inject another. Very low risk (trusted server), fully
486+
// closed here. clientKey is hex by construction, so it can't carry one.
487+
for (const [field, value] of Object.entries(creds)) {
488+
if (CONTROL_CHARS.test(value)) {
489+
throw new MintError(
490+
'unexpected_response',
491+
`The service returned a ${field} containing a control character — refusing to emit it. Check for a newer CLI release.`,
492+
)
493+
}
494+
}
495+
return creds
473496
}
474497

475498
function trimTrailingSlash(url: string): string {

0 commit comments

Comments
 (0)