Skip to content

Commit 452dcd1

Browse files
committed
fix(cli): stop a config value forging a section it was never meant to write
The config file is written by joining names and values into INI lines, and nothing checked what was in them. A profile name carrying a newline and a section header wrote a section that merged into a different profile and took over its endpoint - and the next command sent that profile's stored API key there. A workspace value could do the same from the other side, since only the endpoint flag validated its input. The refusal now lives at the writer, the single place untrusted text enters the document, with the flag-level checks kept for the better message. Either alone blocks the forgery; the pair is deliberate. Rejecting rather than escaping, because the format has no escape syntax and these files are hand-edited and read by other tools that would not decode one we invented. The forbidden set covers control characters and the two Unicode line separators, which the previous guard missed - those parse as an unreadable line, so the key silently vanished on read and the next write appended a duplicate while the command reported success. Login also wrote the key before the settings, so a malformed response from the deployment could leave a key on disk with no endpoint beside it, and the next command would send it to the default host. Settings are written first, and the response is checked before anything touches disk. Name validation applies only when creating a profile, so a hand-written one that predates the rule keeps working.
1 parent 7133845 commit 452dcd1

9 files changed

Lines changed: 651 additions & 49 deletions

File tree

packages/sim-cli/src/commands/auth.test.ts

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,17 @@ vi.mock('../auth/device-flow', () => ({
4141
createAuthRequest: mocks.createAuthRequest,
4242
pollForKey: mocks.pollForKey,
4343
}))
44-
vi.mock('../config/index', () => ({
44+
/**
45+
* The two validators come from the real module rather than a copy: a duplicated
46+
* pattern here would keep passing if the shipped one were deleted, which is
47+
* exactly the regression these tests exist to catch. `../config/profile` is not
48+
* itself mocked, so this is the shipped implementation.
49+
*/
50+
vi.mock('../config/index', async () => ({
51+
...(await import('../config/profile').then(({ normalizeWorkspaceId, validateProfileName }) => ({
52+
normalizeWorkspaceId,
53+
validateProfileName,
54+
}))),
4555
configPath: () => '/tmp/sim-config',
4656
credentialsPath: () => '/tmp/sim-credentials',
4757
DEFAULT_PROFILE: 'default',
@@ -232,6 +242,53 @@ describe('login command', () => {
232242
expect(mocks.createAuthRequest).toHaveBeenCalledOnce()
233243
})
234244

245+
it('writes the endpoint before the key, so a failed write cannot strand one', async () => {
246+
// A key on disk with no endpoint beside it falls back to the default host
247+
// on the next command, which would send a self-hosted key elsewhere.
248+
setInteractive(false)
249+
const order: string[] = []
250+
mocks.writeConfigProfile.mockImplementation(() => {
251+
order.push('config')
252+
})
253+
mocks.writeCredentialsProfile.mockImplementation(() => {
254+
order.push('credentials')
255+
})
256+
257+
await login()
258+
259+
expect(order).toEqual(['config', 'credentials'])
260+
})
261+
262+
it('stores nothing when the server answers with an unstorable workspace id', async () => {
263+
setInteractive(false)
264+
mocks.pollForKey.mockResolvedValue({
265+
apiKey: 'sim-key',
266+
scope: 'platform',
267+
workspaceBound: false,
268+
workspaceId: 'ws_1\nendpoint = http://elsewhere.invalid',
269+
})
270+
271+
await expect(login()).rejects.toThrow('Invalid workspace id')
272+
273+
expect(mocks.writeConfigProfile).not.toHaveBeenCalled()
274+
expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled()
275+
})
276+
277+
it('stores nothing when the server answers with a malformed key', async () => {
278+
setInteractive(false)
279+
mocks.pollForKey.mockResolvedValue({
280+
apiKey: ' ',
281+
scope: 'platform',
282+
workspaceBound: false,
283+
workspaceId: 'ws_1',
284+
})
285+
286+
await expect(login()).rejects.toThrow('malformed API key')
287+
288+
expect(mocks.writeConfigProfile).not.toHaveBeenCalled()
289+
expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled()
290+
})
291+
235292
it('clears a stale workspace default when none is selected during login', async () => {
236293
setInteractive(false)
237294
mocks.profileFrom.mockReturnValue({
@@ -449,6 +506,13 @@ describe('profiles command', () => {
449506
expect(mocks.writeConfigProfile).not.toHaveBeenCalled()
450507
})
451508

509+
it('refuses a new profile name that would forge a config section', async () => {
510+
await expect(profiles('add', 'evil]\n[default', '--workspace', 'ws_acme')).rejects.toThrow(
511+
'Invalid profile name'
512+
)
513+
expect(mocks.writeConfigProfile).not.toHaveBeenCalled()
514+
})
515+
452516
it('does not overwrite an existing profile', async () => {
453517
mocks.listProfiles.mockReturnValue(['acme'])
454518

@@ -467,8 +531,65 @@ describe('profiles command', () => {
467531
await profiles('list')
468532

469533
const output = vi.mocked(console.log).mock.calls.flat().join('\n')
470-
expect(output).toContain('acme (auth: default)')
471-
expect(output).not.toContain('acme (no key)')
534+
expect(output).toMatch(/acme\s+yes\s+default/)
535+
})
536+
537+
it('refuses an unknown profile like every other command', async () => {
538+
// `profiles list --profile typo` used to exit 0 on a name that resolves to
539+
// nothing, alone among the commands, because it never resolved at all.
540+
mocks.listProfiles.mockReturnValue(['default'])
541+
mocks.profileFrom.mockImplementation(() => {
542+
throw new mocks.ProfileConfigError('Unknown profile "bogus".')
543+
})
544+
545+
await expect(profiles('list', '--profile', 'bogus')).rejects.toThrow('Unknown profile "bogus".')
546+
expect(console.log).not.toHaveBeenCalled()
547+
})
548+
549+
it('renders the listing in the resolved output format', async () => {
550+
mocks.listProfiles.mockReturnValue(['acme', 'default'])
551+
mocks.profileFrom.mockReturnValue({
552+
name: 'default',
553+
endpoint: 'https://sim.ai',
554+
apiKey: 'stored-key',
555+
workspaceId: 'ws_default',
556+
output: 'json',
557+
sources: {
558+
endpoint: 'config',
559+
apiKey: 'credentials',
560+
workspaceId: 'config',
561+
output: 'config',
562+
},
563+
})
564+
565+
await profiles('list')
566+
567+
const output = vi.mocked(console.log).mock.calls.flat().join('\n')
568+
expect(JSON.parse(output)).toEqual([
569+
{ name: 'acme', active: false, hasKey: true, authProfile: 'acme', error: null },
570+
{ name: 'default', active: true, hasKey: true, authProfile: 'default', error: null },
571+
])
572+
})
573+
574+
it('answers a machine format with an empty list rather than prose', async () => {
575+
mocks.listProfiles.mockReturnValue([])
576+
mocks.profileFrom.mockReturnValue({
577+
name: 'default',
578+
endpoint: 'https://sim.ai',
579+
apiKey: null,
580+
workspaceId: null,
581+
output: 'json',
582+
sources: {
583+
endpoint: 'default',
584+
apiKey: 'unset',
585+
workspaceId: 'unset',
586+
output: 'config',
587+
},
588+
})
589+
590+
await profiles('list')
591+
592+
expect(JSON.parse(vi.mocked(console.log).mock.calls.flat().join('\n'))).toEqual([])
472593
})
473594

474595
it('marks a broken profile and still lists the rest', async () => {

packages/sim-cli/src/commands/auth.ts

Lines changed: 84 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ import {
1515
deleteProfile,
1616
listAuthenticationDependents,
1717
listProfiles,
18+
normalizeWorkspaceId,
1819
ProfileConfigError,
1920
type ResolvedProfile,
2021
readCredentialsProfile,
2122
resolveAuthenticationProfileName,
2223
type SettingSource,
24+
validateProfileName,
2325
writeConfigProfile,
2426
writeCredentialsProfile,
2527
} from '../config/index'
@@ -31,11 +33,10 @@ import {
3133
V2_OPERATIONS,
3234
} from '../generated/v2-api'
3335
import { requestAllPages, resolvePath, SimApiError, type SimClient } from '../http/client'
34-
import { printRecord, safeOneLine } from '../output/render'
36+
import { type Column, printList, printRecord, safeOneLine, text } from '../output/render'
3537

3638
type SelectableWorkspace = ListWorkspacesResponse['data'][number]
3739

38-
const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
3940
const MAX_INTERACTIVE_WORKSPACES = 1000
4041

4142
/**
@@ -108,15 +109,29 @@ function selectedProfileName(command: Command): string {
108109
}
109110

110111
function validateNewProfileName(profileName: string): void {
111-
if (!PROFILE_NAME_PATTERN.test(profileName)) {
112+
// The shape rule lives with the config writer, so `profiles add`, `login
113+
// --profile`, and `configure --profile` cannot drift into three answers.
114+
validateProfileName(profileName)
115+
if (listProfiles().includes(profileName)) {
112116
throw new SimApiError(
113-
`Invalid profile name "${profileName}". Use letters, numbers, dots, underscores, or hyphens, starting with a letter or number.`,
117+
`Profile "${profileName}" already exists. Remove it first with: sim logout --all --profile ${profileName}`,
114118
0
115119
)
116120
}
117-
if (listProfiles().includes(profileName)) {
121+
}
122+
123+
/**
124+
* Refuses a minted key the credentials file could not represent.
125+
*
126+
* The poll response is remote input, and the deployment answering it is
127+
* whatever the endpoint names. A key carrying a line break would be written
128+
* verbatim into an escape-less format, so the writer refuses it — this refuses
129+
* it one step earlier, before anything is on disk, and says which side is wrong.
130+
*/
131+
function requireStorableKey(apiKey: unknown): void {
132+
if (typeof apiKey !== 'string' || !apiKey.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(apiKey)) {
118133
throw new SimApiError(
119-
`Profile "${profileName}" already exists. Remove it first with: sim logout --all --profile ${profileName}`,
134+
'The server returned a malformed API key. Nothing was stored; check the endpoint.',
120135
0
121136
)
122137
}
@@ -290,17 +305,25 @@ export function loginCommand(): Command {
290305
)
291306
}
292307

293-
writeCredentialsProfile(profile.name, key.apiKey)
294-
295308
// The workspace picked in the browser becomes the profile's default,
296309
// whether or not the key is scoped to it. The user chose it by name —
297310
// making them look up its id afterwards would waste the one moment the
298-
// answer was already on screen.
311+
// answer was already on screen. It arrives off the wire, so it is
312+
// checked before either file is touched.
299313
const settings: Record<string, string | null> = {
300314
endpoint: profile.endpoint,
301-
workspace: key.workspaceId ?? null,
315+
workspace: key.workspaceId
316+
? normalizeWorkspaceId(key.workspaceId, 'the login response')
317+
: null,
302318
}
319+
requireStorableKey(key.apiKey)
320+
321+
// Config before credentials: the endpoint decides where the key is sent
322+
// later. Storing the key first and then failing on the settings left a
323+
// key on disk with no endpoint beside it, so the next command fell back
324+
// to the default host — sending a self-hosted key somewhere else.
303325
writeConfigProfile(profile.name, settings)
326+
writeCredentialsProfile(profile.name, key.apiKey)
304327

305328
console.log(chalk.green(`\n✓ Logged in. Key stored in ${credentialsPath()}`))
306329
if (key.workspaceBound && key.workspaceId) {
@@ -597,38 +620,66 @@ export function whoamiCommand(): Command {
597620
})
598621
}
599622

623+
interface ProfileRow {
624+
name: string
625+
active: boolean
626+
hasKey: boolean
627+
/** The profile whose stored login this one uses; itself unless it is an alias. */
628+
authProfile: string | null
629+
/** Why the row could not be resolved, for a profile with a broken `auth_profile`. */
630+
error: string | null
631+
}
632+
633+
const PROFILE_COLUMNS: Column<ProfileRow>[] = [
634+
{ header: '', value: (row) => (row.active ? chalk.green('*') : ' ') },
635+
{ header: 'profile', value: (row) => text(row.name) },
636+
{ header: 'key', value: (row) => (row.error ? text(null) : row.hasKey ? 'yes' : 'no') },
637+
{ header: 'auth', value: (row) => text(row.authProfile) },
638+
{ header: 'error', value: (row) => (row.error ? chalk.red(safeOneLine(row.error)) : text(null)) },
639+
]
640+
641+
/**
642+
* `profiles` is the command someone runs *because* a profile is broken, so a bad
643+
* `auth_profile` marks its own row rather than aborting the listing and leaving
644+
* them with nothing shown at all.
645+
*/
646+
function buildProfileRow(name: string, active: boolean): ProfileRow {
647+
try {
648+
const authProfile = resolveAuthenticationProfileName(name)
649+
return {
650+
name,
651+
active,
652+
hasKey: Boolean(readCredentialsProfile(authProfile).api_key),
653+
authProfile,
654+
error: null,
655+
}
656+
} catch (error) {
657+
if (!(error instanceof ProfileConfigError)) throw error
658+
return { name, active, hasKey: false, authProfile: null, error: error.message }
659+
}
660+
}
661+
600662
export function profilesCommand(): Command {
601663
const command = new Command('profiles')
602664
.alias('profile')
603665
.description('List profiles or add a workspace profile that shares a stored login')
604666

605667
const printProfiles = (_options: unknown, actionCommand: Command): void => {
606-
const profiles = listProfiles()
607-
if (profiles.length === 0) {
608-
console.log(chalk.dim('No profiles yet. Run: sim login'))
668+
// Resolving is what makes `profiles --profile typo` fail like every other
669+
// command instead of listing happily under a name that resolves to nothing,
670+
// and it is also what supplies the output format the listing renders in.
671+
const profile = profileFrom(actionCommand)
672+
const rows = listProfiles().map((name) => buildProfileRow(name, name === profile.name))
673+
674+
if (rows.length === 0) {
675+
// The prose belongs to the human formats; a script asking for json must
676+
// get an empty list, not a sentence it cannot parse.
677+
if (profile.output === 'table') console.log(chalk.dim('No profiles yet. Run: sim login'))
678+
else printList(profile.output, rows, PROFILE_COLUMNS)
609679
return
610680
}
611681

612-
const active = selectedProfileName(actionCommand)
613-
for (const name of profiles) {
614-
const marker = name === active ? chalk.green('*') : ' '
615-
616-
// `profiles` is the command someone runs *because* a profile is broken,
617-
// so one bad `auth_profile` must mark its own row rather than abort the
618-
// listing and leave them with no profiles shown at all.
619-
let authProfile: string
620-
try {
621-
authProfile = resolveAuthenticationProfileName(name)
622-
} catch (error) {
623-
if (!(error instanceof ProfileConfigError)) throw error
624-
console.log(`${marker} ${name}${chalk.red(` (${safeOneLine(error.message)})`)}`)
625-
continue
626-
}
627-
628-
const hasKey = Boolean(readCredentialsProfile(authProfile).api_key)
629-
const authentication = authProfile === name ? '' : chalk.dim(` (auth: ${authProfile})`)
630-
console.log(`${marker} ${name}${hasKey ? '' : chalk.dim(' (no key)')}${authentication}`)
631-
}
682+
printList(profile.output, rows, PROFILE_COLUMNS)
632683
}
633684

634685
command.action(printProfiles)

0 commit comments

Comments
 (0)