Skip to content

Commit b5879de

Browse files
fix: reject malformed api keys (#141)
1 parent 87a6dff commit b5879de

2 files changed

Lines changed: 71 additions & 1 deletion

File tree

src/lib/client-factory.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { afterEach, describe, expect, it } from 'vitest';
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
22
import {
33
DRY_RUN_API_KEY,
44
DRY_RUN_BANNER,
5+
assertValidApiKeyHeaderValue,
56
assertValidEndpointUrl,
67
emitDryRunBanner,
78
makeHttpClient,
@@ -331,6 +332,58 @@ describe('makeHttpClient — real path (regression)', () => {
331332
// assertValidEndpointUrl — endpoint syntax guard (NOT an SSRF guard)
332333
// ---------------------------------------------------------------------------
333334

335+
describe('makeHttpClient - API key validation', () => {
336+
it.each([
337+
['newline', 'sk-user-abc\ndef'],
338+
['carriage return', 'sk-user-abc\rdef'],
339+
['smart dash', 'sk-user-abc\u2013def'],
340+
['smart quote', 'sk-user-\u201cabc\u201d'],
341+
['emoji', 'sk-user-abc\u{1f600}'],
342+
['whitespace-only', ' '],
343+
])('rejects a malformed configured API key with %s before fetch/retry', (_label, apiKey) => {
344+
const fetchImpl = vi.fn();
345+
let caught: unknown;
346+
try {
347+
makeHttpClient(
348+
{ profile: 'default', output: 'json', debug: false, dryRun: false },
349+
{
350+
env: { TESTSPRITE_API_KEY: apiKey } as NodeJS.ProcessEnv,
351+
credentialsPath: NO_CREDS_PATH,
352+
fetchImpl,
353+
},
354+
);
355+
} catch (err) {
356+
caught = err;
357+
}
358+
expect(fetchImpl).not.toHaveBeenCalled();
359+
expect(caught).toBeInstanceOf(ApiError);
360+
const apiErr = caught as ApiError;
361+
expect(apiErr.code).toBe('VALIDATION_ERROR');
362+
expect(apiErr.exitCode).toBe(5);
363+
expect(apiErr.nextAction).toContain('api-key');
364+
});
365+
});
366+
367+
describe('assertValidApiKeyHeaderValue', () => {
368+
it('accepts a normal ASCII API key value', () => {
369+
expect(() => assertValidApiKeyHeaderValue('sk-user-abc-def_123')).not.toThrow();
370+
});
371+
372+
it('throws a VALIDATION_ERROR (exit 5) for a key that cannot be sent as x-api-key', () => {
373+
let caught: unknown;
374+
try {
375+
assertValidApiKeyHeaderValue('sk-user-abc\u2013def');
376+
} catch (err) {
377+
caught = err;
378+
}
379+
expect(caught).toBeInstanceOf(ApiError);
380+
const apiErr = caught as ApiError;
381+
expect(apiErr.code).toBe('VALIDATION_ERROR');
382+
expect(apiErr.exitCode).toBe(5);
383+
expect(apiErr.nextAction).toContain('api-key');
384+
});
385+
});
386+
334387
describe('assertValidEndpointUrl', () => {
335388
it('accepts http(s) URLs, including private / localhost hosts (self-hosted, dev, mock)', () => {
336389
for (const url of [

src/lib/client-factory.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,22 @@ export function assertValidEndpointUrl(rawUrl: string): void {
180180
}
181181
}
182182

183+
export function assertValidApiKeyHeaderValue(apiKey: string): void {
184+
const reason =
185+
'must be a non-empty HTTP header value; paste the raw key without smart punctuation, emoji, or line breaks';
186+
187+
if (apiKey.trim().length === 0) {
188+
throw localValidationError('api-key', reason, undefined, 'field');
189+
}
190+
191+
for (let i = 0; i < apiKey.length; i += 1) {
192+
const code = apiKey.charCodeAt(i);
193+
if (code < 0x20 || code === 0x7f || code > 0xff) {
194+
throw localValidationError('api-key', reason, undefined, 'field');
195+
}
196+
}
197+
}
198+
183199
/**
184200
* Parse the `--request-timeout <seconds>` flag value into milliseconds.
185201
*
@@ -251,6 +267,7 @@ export function makeHttpClient(opts: CommonOptions, deps: ClientFactoryDeps = {}
251267
// VALIDATION_ERROR rather than an opaque URL throw or a retried "fetch failed".
252268
assertValidEndpointUrl(config.apiUrl);
253269
if (!config.apiKey) throw ApiError.authRequired();
270+
assertValidApiKeyHeaderValue(config.apiKey);
254271
return new HttpClient({
255272
baseUrl: facadeBaseUrl(config.apiUrl),
256273
apiKey: config.apiKey,

0 commit comments

Comments
 (0)