Skip to content

Commit 763800f

Browse files
Mossakaclaude
andcommitted
fix: prevent Squid config injection via domain inputs
Malicious input to --allow-domains or --allow-urls containing whitespace, newlines, or null bytes could inject arbitrary directives into the generated squid.conf. This adds: 1. Character validation in validateDomainOrPattern() rejects whitespace, null bytes, quotes, semicolons, backslashes 2. assertSafeForSquidConfig() in squid-config.ts validates every value before interpolation into squid.conf 3. URL pattern validation in cli.ts for --allow-urls Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 284e5f9 commit 763800f

5 files changed

Lines changed: 147 additions & 5 deletions

File tree

src/cli.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1639,6 +1639,13 @@ program
16391639
}
16401640
}
16411641

1642+
// Reject whitespace and null bytes to prevent Squid config injection
1643+
if (/[\s\0]/.test(url)) {
1644+
logger.error(`URL pattern contains illegal whitespace or control characters: ${JSON.stringify(url)}`);
1645+
logger.error('URL patterns must not contain spaces, tabs, newlines, or null bytes.');
1646+
process.exit(1);
1647+
}
1648+
16421649
// Ensure pattern has a path component (not just domain)
16431650
const urlWithoutScheme = url.replace(/^https:\/\//, '');
16441651
if (!urlWithoutScheme.includes('/')) {

src/domain-patterns.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,70 @@ describe('validateDomainOrPattern', () => {
232232
});
233233
});
234234

235+
describe('rejects injection characters', () => {
236+
it('should reject LF in domain', () => {
237+
expect(() => validateDomainOrPattern('evil.com\nhttp_access allow all')).toThrow('contains invalid character');
238+
});
239+
240+
it('should reject CR in domain', () => {
241+
expect(() => validateDomainOrPattern('evil.com\rhttp_access allow all')).toThrow('contains invalid character');
242+
});
243+
244+
it('should reject CRLF in domain', () => {
245+
expect(() => validateDomainOrPattern('evil.com\r\nhttp_access allow all')).toThrow('contains invalid character');
246+
});
247+
248+
it('should reject null bytes', () => {
249+
expect(() => validateDomainOrPattern('evil.com\0')).toThrow('contains invalid character');
250+
});
251+
252+
it('should reject tabs', () => {
253+
expect(() => validateDomainOrPattern('evil.com\tallowed')).toThrow('contains invalid character');
254+
});
255+
256+
it('should reject interior spaces', () => {
257+
expect(() => validateDomainOrPattern('evil.com allowed')).toThrow('contains invalid character');
258+
});
259+
260+
it('should reject space-separated domains (ACL token injection)', () => {
261+
expect(() => validateDomainOrPattern('.evil.com .attacker.com')).toThrow('contains invalid character');
262+
});
263+
264+
it('should reject semicolons', () => {
265+
expect(() => validateDomainOrPattern('evil.com;rm -rf')).toThrow('contains invalid character');
266+
});
267+
268+
it('should reject hash characters', () => {
269+
expect(() => validateDomainOrPattern('evil.com#comment')).toThrow('contains invalid character');
270+
});
271+
272+
it('should reject backslashes', () => {
273+
expect(() => validateDomainOrPattern('evil.com\\n')).toThrow('contains invalid character');
274+
});
275+
276+
it('should reject single quotes', () => {
277+
expect(() => validateDomainOrPattern("evil.com'")).toThrow('contains invalid character');
278+
});
279+
280+
it('should reject double quotes', () => {
281+
expect(() => validateDomainOrPattern('evil.com"')).toThrow('contains invalid character');
282+
});
283+
});
284+
285+
describe('accepts valid DNS names with underscores', () => {
286+
it('should accept _dmarc.example.com', () => {
287+
expect(() => validateDomainOrPattern('_dmarc.example.com')).not.toThrow();
288+
});
289+
290+
it('should accept _acme-challenge.example.com', () => {
291+
expect(() => validateDomainOrPattern('_acme-challenge.example.com')).not.toThrow();
292+
});
293+
294+
it('should accept _srv._tcp.example.com', () => {
295+
expect(() => validateDomainOrPattern('_srv._tcp.example.com')).not.toThrow();
296+
});
297+
});
298+
235299
describe('protocol-prefixed domains', () => {
236300
it('should accept valid http:// prefixed domains', () => {
237301
expect(() => validateDomainOrPattern('http://github.com')).not.toThrow();

src/domain-patterns.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,23 @@ export function validateDomainOrPattern(input: string): void {
151151
throw new Error('Domain cannot be empty');
152152
}
153153

154+
// Reject characters that could inject Squid config directives or tokens.
155+
// Squid config is line-and-space delimited, so whitespace (space, tab, CR, LF),
156+
// null bytes, and comment/quote characters are dangerous.
157+
// This prevents Squid config injection via --allow-domains.
158+
const DANGEROUS_CHARS = /[\s\0"'`;#\\]/;
159+
const match = trimmed.match(DANGEROUS_CHARS);
160+
if (match) {
161+
const charCode = match[0].charCodeAt(0);
162+
const charDesc = charCode <= 0x20 || charCode === 0x7f
163+
? `U+${charCode.toString(16).padStart(4, '0')}`
164+
: `'${match[0]}'`;
165+
throw new Error(
166+
`Invalid domain '${trimmed}': contains invalid character ${charDesc}. ` +
167+
`Domain names must not contain whitespace, quotes, semicolons, backslashes, or control characters.`
168+
);
169+
}
170+
154171
// Check for overly broad patterns
155172
if (trimmed === '*') {
156173
throw new Error("Pattern '*' matches all domains and is not allowed");

src/squid-config.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,44 @@
11
import { generateSquidConfig, generatePolicyManifest } from './squid-config';
22
import { SquidConfig } from './types';
33

4+
describe('defense-in-depth: rejects injected values', () => {
5+
const defaultPort = 3128;
6+
7+
it('should reject newline in domain via validateDomainOrPattern', () => {
8+
expect(() => {
9+
generateSquidConfig({
10+
domains: ['evil.com\nhttp_access allow all'],
11+
port: defaultPort,
12+
});
13+
}).toThrow();
14+
});
15+
16+
it('should reject newline in URL pattern', () => {
17+
// URL patterns go through generateSslBumpSection, which interpolates into squid.conf.
18+
// The assertSafeForSquidConfig guard should catch this.
19+
const maliciousPattern = 'https://evil.com/path\nhttp_access allow all';
20+
expect(() => {
21+
generateSquidConfig({
22+
domains: ['evil.com'],
23+
port: defaultPort,
24+
sslBump: true,
25+
caFiles: { certPath: '/tmp/cert.pem', keyPath: '/tmp/key.pem' },
26+
sslDbPath: '/tmp/ssl_db',
27+
urlPatterns: [maliciousPattern],
28+
});
29+
}).toThrow(/SECURITY|whitespace/);
30+
});
31+
32+
it('should reject space in domain (ACL token injection)', () => {
33+
expect(() => {
34+
generateSquidConfig({
35+
domains: ['.evil.com .attacker.com'],
36+
port: defaultPort,
37+
});
38+
}).toThrow();
39+
});
40+
});
41+
442
// Pattern constant for the safer domain character class (matches the implementation)
543
const DOMAIN_CHAR_PATTERN = '[a-zA-Z0-9.-]*';
644

src/squid-config.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,26 @@ interface PatternsByProtocol {
5353
both: DomainPattern[];
5454
}
5555

56+
/**
57+
* Defense-in-depth: assert a domain/regex/URL-pattern string is safe for Squid config interpolation.
58+
* Rejects any whitespace (\s covers space, tab, CR, LF, Unicode whitespace) and null bytes.
59+
* Squid config is line-and-space delimited, so any whitespace could inject directives or tokens.
60+
*/
61+
function assertSafeForSquidConfig(value: string): string {
62+
if (/[\s\0]/.test(value)) {
63+
throw new Error(
64+
`SECURITY: Domain or pattern contains whitespace or null bytes and cannot be ` +
65+
`interpolated into squid.conf: ${JSON.stringify(value)}`
66+
);
67+
}
68+
return value;
69+
}
70+
5671
/**
5772
* Helper to add leading dot to domain for Squid subdomain matching
5873
*/
5974
function formatDomainForSquid(domain: string): string {
75+
assertSafeForSquidConfig(domain);
6076
return domain.startsWith('.') ? domain : `.${domain}`;
6177
}
6278

@@ -151,7 +167,7 @@ function generateSslBumpSection(
151167
let urlAccessRules = '';
152168
if (urlPatterns && urlPatterns.length > 0) {
153169
const urlAcls = urlPatterns
154-
.map((pattern, i) => `acl allowed_url_${i} url_regex ${pattern}`)
170+
.map((pattern, i) => `acl allowed_url_${i} url_regex ${assertSafeForSquidConfig(pattern)}`)
155171
.join('\n');
156172
urlAclSection = `\n# URL pattern ACLs for HTTPS content inspection\n${urlAcls}\n`;
157173

@@ -262,7 +278,7 @@ export function generateSquidConfig(config: SquidConfig): string {
262278
aclLines.push('');
263279
aclLines.push('# ACL definitions for allowed domain patterns (HTTP and HTTPS)');
264280
for (const p of patternsByProto.both) {
265-
aclLines.push(`acl allowed_domains_regex dstdom_regex -i ${p.regex}`);
281+
aclLines.push(`acl allowed_domains_regex dstdom_regex -i ${assertSafeForSquidConfig(p.regex)}`);
266282
}
267283
}
268284

@@ -280,7 +296,7 @@ export function generateSquidConfig(config: SquidConfig): string {
280296
aclLines.push('');
281297
aclLines.push('# ACL definitions for HTTP-only domain patterns');
282298
for (const p of patternsByProto.http) {
283-
aclLines.push(`acl allowed_http_only_regex dstdom_regex -i ${p.regex}`);
299+
aclLines.push(`acl allowed_http_only_regex dstdom_regex -i ${assertSafeForSquidConfig(p.regex)}`);
284300
}
285301
}
286302

@@ -298,7 +314,7 @@ export function generateSquidConfig(config: SquidConfig): string {
298314
aclLines.push('');
299315
aclLines.push('# ACL definitions for HTTPS-only domain patterns');
300316
for (const p of patternsByProto.https) {
301-
aclLines.push(`acl allowed_https_only_regex dstdom_regex -i ${p.regex}`);
317+
aclLines.push(`acl allowed_https_only_regex dstdom_regex -i ${assertSafeForSquidConfig(p.regex)}`);
302318
}
303319
}
304320

@@ -362,7 +378,7 @@ export function generateSquidConfig(config: SquidConfig): string {
362378
blockedAclLines.push('');
363379
blockedAclLines.push('# ACL definitions for blocked domain patterns (wildcard)');
364380
for (const p of blockedPatterns) {
365-
blockedAclLines.push(`acl blocked_domains_regex dstdom_regex -i ${p.regex}`);
381+
blockedAclLines.push(`acl blocked_domains_regex dstdom_regex -i ${assertSafeForSquidConfig(p.regex)}`);
366382
}
367383
blockedAccessRules.push('http_access deny blocked_domains_regex');
368384
}

0 commit comments

Comments
 (0)