Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/lib/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,38 @@ describe('serializeCredentials', () => {
expect(text).toContain('api_key = sk');
expect(text).not.toContain('api_url');
});

it('strips newline characters from values to prevent INI injection', () => {
// A malicious apiUrl with embedded newlines could inject new key-value
// pairs or section headers into the credentials file. The serializer
// must strip \n and \r so the written file has exactly one value per
// field and no injected content parsed as separate keys/sections.
const malicious = 'https://evil.com\napi_key = sk-HIJACKED\n[admin]\napi_key = sk-admin';
const text = serializeCredentials({ default: { apiKey: 'sk-real', apiUrl: malicious } });
// The output must NOT contain a standalone [admin] section header
// (it would be on its own line if injection succeeded)
const lines = text.split('\n');
// Only one section header exists: [default]
const sectionHeaders = lines.filter(l => /^\[.+\]$/.test(l.trim()));
expect(sectionHeaders).toEqual(['[default]']);
// Only one api_key line exists (the real one, not an injected duplicate)
const apiKeyLines = lines.filter(l => l.trim().startsWith('api_key'));
expect(apiKeyLines).toHaveLength(1);
expect(apiKeyLines[0]).toContain('sk-real');
// Round-trip: reading back must return only the real key, not the injected one
const parsed = parseCredentials(text);
expect(parsed['default']?.apiKey).toBe('sk-real');
expect(parsed['admin']).toBeUndefined();
});

it('strips \\r\\n (CRLF) injection from values', () => {
const text = serializeCredentials({ default: { apiUrl: 'https://x.com\r\napi_key = pwned' } });
const parsed = parseCredentials(text);
// The injected api_key must NOT be parsed as a real key
expect(parsed['default']?.apiKey).toBeUndefined();
// The api_url value is on one line (newlines stripped)
expect(parsed['default']?.apiUrl).toContain('https://x.com');
});
});

describe('readCredentialsFile / readProfile', () => {
Expand Down
10 changes: 9 additions & 1 deletion src/lib/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,15 @@ export function serializeCredentials(file: CredentialsFile): string {
for (const field of fields) {
const value = entry[field];
if (value === undefined || value === '') continue;
lines.push(`${FIELD_TO_FILE_KEY[field]} = ${value}`);
// Guard against INI injection: a value containing newline characters
// would be serialized across multiple lines, allowing an attacker to
// inject arbitrary key-value pairs (or new section headers) into the
// credentials file. A valid API key or URL never contains \n or \r.
// Strip them so a compromised env var or MITM'd backend response
// cannot override the stored api_key on subsequent reads.
const sanitized = value.replace(/[\r\n]/g, '');
if (sanitized === '') continue;
lines.push(`${FIELD_TO_FILE_KEY[field]} = ${sanitized}`);
}
lines.push('');
}
Expand Down
Loading