From ca214ccc761a13fedd4a5f2903a752060b96a045 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 5 Apr 2026 01:03:53 -0500 Subject: [PATCH] fix(mosaic): address code review findings for gateway token recovery (CU-03-08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.ts: write session.json with mode 0o600 (was world-readable; cookie is a credential) - login.ts: add promptSecret() using TTY raw mode so password is not echoed to terminal - login.ts: export promptLine() so token-ops.ts can use it (keeps prompts mockable in tests) - login.ts: fix password trimming — do not trim() passwords (may have intentional whitespace) - token-ops.ts: use promptLine/promptSecret from login.ts (replaces inline readline) - token-ops.ts: persistToken() warns when --gateway targets a different host than meta.json - gateway.ts: mark --password flag [UNSAFE] in help; emit console.warn when it is used - recover-token.spec.ts: update mock to include promptLine/promptSecret from ./login.js Co-Authored-By: Claude Sonnet 4.6 --- packages/mosaic/src/auth.ts | 3 +- packages/mosaic/src/commands/gateway.ts | 10 ++- packages/mosaic/src/commands/gateway/login.ts | 70 ++++++++++++++++--- .../commands/gateway/recover-token.spec.ts | 11 +-- .../mosaic/src/commands/gateway/token-ops.ts | 26 ++++--- 5 files changed, 90 insertions(+), 30 deletions(-) diff --git a/packages/mosaic/src/auth.ts b/packages/mosaic/src/auth.ts index e9fe792..1996859 100644 --- a/packages/mosaic/src/auth.ts +++ b/packages/mosaic/src/auth.ts @@ -74,7 +74,8 @@ export function saveSession(gatewayUrl: string, auth: AuthResult): void { expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days }; - writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8'); + // 0o600: owner read/write only — the session cookie is a credential + writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), { encoding: 'utf-8', mode: 0o600 }); } /** diff --git a/packages/mosaic/src/commands/gateway.ts b/packages/mosaic/src/commands/gateway.ts index 0bfb9ea..92fc9ef 100644 --- a/packages/mosaic/src/commands/gateway.ts +++ b/packages/mosaic/src/commands/gateway.ts @@ -126,10 +126,18 @@ export function registerGatewayCommand(program: Command): void { .description('Sign in to the gateway (defaults to URL from meta.json)') .option('-g, --gateway ', 'Gateway URL (overrides meta.json)') .option('-e, --email ', 'Email address') - .option('-p, --password ', 'Password') + .option( + '-p, --password ', + '[UNSAFE] Avoid — exposes credentials in shell history and process listings', + ) .action(async (cmdOpts: { gateway?: string; email?: string; password?: string }) => { const { runLogin } = await import('./gateway/login.js'); const url = getGatewayUrl(cmdOpts.gateway); + if (cmdOpts.password) { + console.warn( + 'Warning: --password flag exposes credentials in shell history and process listings.', + ); + } try { await runLogin({ gatewayUrl: url, email: cmdOpts.email, password: cmdOpts.password }); } catch (err) { diff --git a/packages/mosaic/src/commands/gateway/login.ts b/packages/mosaic/src/commands/gateway/login.ts index fd3730a..109a1ae 100644 --- a/packages/mosaic/src/commands/gateway/login.ts +++ b/packages/mosaic/src/commands/gateway/login.ts @@ -2,6 +2,62 @@ import { createInterface } from 'node:readline'; import { signIn, saveSession } from '../../auth.js'; import { readMeta } from './daemon.js'; +/** + * Prompt for a single line of input (with echo). + */ +export function promptLine(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +/** + * Prompt for a secret value without echoing the typed characters to the terminal. + * Uses TTY raw mode when available so that passwords do not appear in terminal + * recordings, scrollback, or shared screen sessions. + */ +export function promptSecret(question: string): Promise { + return new Promise((resolve) => { + process.stdout.write(question); + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdin.setEncoding('utf-8'); + + let secret = ''; + const onData = (char: string): void => { + if (char === '\n' || char === '\r' || char === '\u0004') { + process.stdout.write('\n'); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.pause(); + process.stdin.removeListener('data', onData); + resolve(secret); + } else if (char === '\u0003') { + // ^C + process.stdout.write('\n'); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.pause(); + process.stdin.removeListener('data', onData); + process.exit(130); + } else if (char === '\u007f' || char === '\b') { + secret = secret.slice(0, -1); + } else { + secret += char; + } + }; + process.stdin.on('data', onData); + }); +} + /** * Shared login helper used by both `mosaic login` and `mosaic gateway login`. * Prompts for email/password if not supplied, signs in, and persists the session. @@ -11,17 +67,9 @@ export async function runLogin(opts: { email?: string; password?: string; }): Promise { - let email = opts.email; - let password = opts.password; - - if (!email || !password) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); - - if (!email) email = await ask('Email: '); - if (!password) password = await ask('Password: '); - rl.close(); - } + const email = opts.email ?? (await promptLine('Email: ')); + // Do not trim password — it may intentionally contain leading/trailing whitespace + const password = opts.password ?? (await promptSecret('Password: ')); const auth = await signIn(opts.gatewayUrl, email, password); saveSession(opts.gatewayUrl, auth); diff --git a/packages/mosaic/src/commands/gateway/recover-token.spec.ts b/packages/mosaic/src/commands/gateway/recover-token.spec.ts index aeb45d4..d00bc0d 100644 --- a/packages/mosaic/src/commands/gateway/recover-token.spec.ts +++ b/packages/mosaic/src/commands/gateway/recover-token.spec.ts @@ -16,14 +16,9 @@ vi.mock('./daemon.js', () => ({ vi.mock('./login.js', () => ({ getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'), -})); - -// Mock readline so tests don't block on stdin -vi.mock('node:readline', () => ({ - createInterface: vi.fn().mockReturnValue({ - question: vi.fn((_q: string, cb: (a: string) => void) => cb('test-input')), - close: vi.fn(), - }), + // promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin + promptLine: vi.fn().mockResolvedValue('test@example.com'), + promptSecret: vi.fn().mockResolvedValue('test-password'), })); const mockFetch = vi.fn(); diff --git a/packages/mosaic/src/commands/gateway/token-ops.ts b/packages/mosaic/src/commands/gateway/token-ops.ts index 2fd6005..412dd31 100644 --- a/packages/mosaic/src/commands/gateway/token-ops.ts +++ b/packages/mosaic/src/commands/gateway/token-ops.ts @@ -1,7 +1,6 @@ -import { createInterface } from 'node:readline'; import { loadSession, validateSession, signIn, saveSession } from '../../auth.js'; import { readMeta, writeMeta } from './daemon.js'; -import { getGatewayUrl } from './login.js'; +import { getGatewayUrl, promptLine, promptSecret } from './login.js'; interface MintedToken { id: string; @@ -58,6 +57,9 @@ export async function mintAdminToken( /** * Persist the new token into meta.json and print the confirmation banner. + * + * Emits a warning when the target gateway differs from the locally installed one, + * so operators are aware that meta.json may not reflect the intended gateway. */ export function persistToken(gatewayUrl: string, minted: MintedToken): void { const meta = readMeta() ?? { @@ -68,6 +70,15 @@ export function persistToken(gatewayUrl: string, minted: MintedToken): void { port: parseInt(new URL(gatewayUrl).port || '14242', 10), }; + // Warn when the target gateway does not match the locally installed one + const targetHost = new URL(gatewayUrl).hostname; + if (targetHost !== meta.host) { + console.warn( + `Warning: token was minted against ${gatewayUrl} but is being saved to the local` + + ` meta.json (host: ${meta.host}). Copy the token manually if targeting a remote gateway.`, + ); + } + writeMeta({ ...meta, adminToken: minted.plaintext }); const preview = `${minted.plaintext.slice(0, 8)}...`; @@ -108,13 +119,10 @@ export async function ensureSession(gatewayUrl: string): Promise { console.log(`No session found for ${gatewayUrl}. Please sign in.`); } - // Prompt for credentials - const rl = createInterface({ input: process.stdin, output: process.stdout }); - const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); - - const email = (await ask('Email: ')).trim(); - const password = (await ask('Password: ')).trim(); - rl.close(); + // Prompt for credentials — password must not be echoed to the terminal + const email = await promptLine('Email: '); + // Do not trim password — it may contain intentional leading/trailing whitespace + const password = await promptSecret('Password: '); const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => { console.error(err instanceof Error ? err.message : String(err)); -- 2.49.1