Skip to content

Commit 1235c2a

Browse files
CopilotMossakaclaude
authored
feat: add protocol-specific domain allowlisting (http/https) (#115)
* Initial plan * feat: add protocol-specific domain allowlisting (http/https) Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> * docs: improve code comments based on review feedback Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> Co-authored-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 75e9e67 commit 1235c2a

5 files changed

Lines changed: 678 additions & 132 deletions

File tree

src/cli.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,10 +300,12 @@ program
300300
.version(version)
301301
.option(
302302
'--allow-domains <domains>',
303-
'Comma-separated list of allowed domains. Supports wildcards:\n' +
304-
' github.com - exact domain + subdomains\n' +
305-
' *.github.com - any subdomain of github.com\n' +
306-
' api-*.example.com - api-* subdomains'
303+
'Comma-separated list of allowed domains. Supports wildcards and protocol prefixes:\n' +
304+
' github.com - exact domain + subdomains (HTTP & HTTPS)\n' +
305+
' *.github.com - any subdomain of github.com\n' +
306+
' api-*.example.com - api-* subdomains\n' +
307+
' https://secure.com - HTTPS only\n' +
308+
' http://legacy.com - HTTP only'
307309
)
308310
.option(
309311
'--allow-domains-file <path>',

src/domain-patterns.test.ts

Lines changed: 201 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,80 @@ import {
44
validateDomainOrPattern,
55
parseDomainList,
66
isDomainMatchedByPattern,
7+
parseDomainWithProtocol,
78
} from './domain-patterns';
89

10+
describe('parseDomainWithProtocol', () => {
11+
it('should parse domain without protocol as "both"', () => {
12+
expect(parseDomainWithProtocol('github.com')).toEqual({
13+
domain: 'github.com',
14+
protocol: 'both',
15+
});
16+
});
17+
18+
it('should parse http:// prefix as "http"', () => {
19+
expect(parseDomainWithProtocol('http://github.com')).toEqual({
20+
domain: 'github.com',
21+
protocol: 'http',
22+
});
23+
});
24+
25+
it('should parse https:// prefix as "https"', () => {
26+
expect(parseDomainWithProtocol('https://github.com')).toEqual({
27+
domain: 'github.com',
28+
protocol: 'https',
29+
});
30+
});
31+
32+
it('should strip trailing slash', () => {
33+
expect(parseDomainWithProtocol('github.com/')).toEqual({
34+
domain: 'github.com',
35+
protocol: 'both',
36+
});
37+
expect(parseDomainWithProtocol('http://github.com/')).toEqual({
38+
domain: 'github.com',
39+
protocol: 'http',
40+
});
41+
expect(parseDomainWithProtocol('https://github.com/')).toEqual({
42+
domain: 'github.com',
43+
protocol: 'https',
44+
});
45+
});
46+
47+
it('should trim whitespace', () => {
48+
expect(parseDomainWithProtocol(' github.com ')).toEqual({
49+
domain: 'github.com',
50+
protocol: 'both',
51+
});
52+
expect(parseDomainWithProtocol(' http://github.com ')).toEqual({
53+
domain: 'github.com',
54+
protocol: 'http',
55+
});
56+
});
57+
58+
it('should handle wildcard patterns with protocol', () => {
59+
expect(parseDomainWithProtocol('http://*.example.com')).toEqual({
60+
domain: '*.example.com',
61+
protocol: 'http',
62+
});
63+
expect(parseDomainWithProtocol('https://*.secure.com')).toEqual({
64+
domain: '*.secure.com',
65+
protocol: 'https',
66+
});
67+
});
68+
69+
it('should handle subdomains with protocol', () => {
70+
expect(parseDomainWithProtocol('http://api.github.com')).toEqual({
71+
domain: 'api.github.com',
72+
protocol: 'http',
73+
});
74+
expect(parseDomainWithProtocol('https://secure.api.github.com')).toEqual({
75+
domain: 'secure.api.github.com',
76+
protocol: 'https',
77+
});
78+
});
79+
});
80+
981
describe('isWildcardPattern', () => {
1082
it('should detect asterisk wildcard', () => {
1183
expect(isWildcardPattern('*.github.com')).toBe(true);
@@ -156,14 +228,45 @@ describe('validateDomainOrPattern', () => {
156228
expect(() => validateDomainOrPattern('*.*.com')).toThrow("too many wildcard segments");
157229
});
158230
});
231+
232+
describe('protocol-prefixed domains', () => {
233+
it('should accept valid http:// prefixed domains', () => {
234+
expect(() => validateDomainOrPattern('http://github.com')).not.toThrow();
235+
expect(() => validateDomainOrPattern('http://api.github.com')).not.toThrow();
236+
});
237+
238+
it('should accept valid https:// prefixed domains', () => {
239+
expect(() => validateDomainOrPattern('https://github.com')).not.toThrow();
240+
expect(() => validateDomainOrPattern('https://secure.example.com')).not.toThrow();
241+
});
242+
243+
it('should accept protocol-prefixed wildcard patterns', () => {
244+
expect(() => validateDomainOrPattern('http://*.example.com')).not.toThrow();
245+
expect(() => validateDomainOrPattern('https://*.secure.com')).not.toThrow();
246+
});
247+
248+
it('should reject protocol prefix with empty domain', () => {
249+
expect(() => validateDomainOrPattern('http://')).toThrow('cannot be empty');
250+
expect(() => validateDomainOrPattern('https://')).toThrow('cannot be empty');
251+
});
252+
253+
it('should reject overly broad patterns even with protocol prefix', () => {
254+
expect(() => validateDomainOrPattern('http://*')).toThrow("matches all domains");
255+
expect(() => validateDomainOrPattern('https://*.*')).toThrow("too broad");
256+
});
257+
});
159258
});
160259

161260
describe('parseDomainList', () => {
162261
it('should separate plain domains from patterns', () => {
163262
const result = parseDomainList(['github.com', '*.gitlab.com', 'example.com']);
164-
expect(result.plainDomains).toEqual(['github.com', 'example.com']);
263+
expect(result.plainDomains).toEqual([
264+
{ domain: 'github.com', protocol: 'both' },
265+
{ domain: 'example.com', protocol: 'both' },
266+
]);
165267
expect(result.patterns).toHaveLength(1);
166268
expect(result.patterns[0].original).toBe('*.gitlab.com');
269+
expect(result.patterns[0].protocol).toBe('both');
167270
});
168271

169272
it('should convert patterns to regex', () => {
@@ -173,7 +276,11 @@ describe('parseDomainList', () => {
173276

174277
it('should handle all plain domains', () => {
175278
const result = parseDomainList(['github.com', 'gitlab.com', 'example.com']);
176-
expect(result.plainDomains).toEqual(['github.com', 'gitlab.com', 'example.com']);
279+
expect(result.plainDomains).toEqual([
280+
{ domain: 'github.com', protocol: 'both' },
281+
{ domain: 'gitlab.com', protocol: 'both' },
282+
{ domain: 'example.com', protocol: 'both' },
283+
]);
177284
expect(result.patterns).toHaveLength(0);
178285
});
179286

@@ -193,46 +300,118 @@ describe('parseDomainList', () => {
193300
expect(result.plainDomains).toHaveLength(0);
194301
expect(result.patterns).toHaveLength(0);
195302
});
303+
304+
describe('protocol parsing', () => {
305+
it('should parse http:// prefix as http protocol', () => {
306+
const result = parseDomainList(['http://github.com']);
307+
expect(result.plainDomains).toEqual([
308+
{ domain: 'github.com', protocol: 'http' },
309+
]);
310+
});
311+
312+
it('should parse https:// prefix as https protocol', () => {
313+
const result = parseDomainList(['https://github.com']);
314+
expect(result.plainDomains).toEqual([
315+
{ domain: 'github.com', protocol: 'https' },
316+
]);
317+
});
318+
319+
it('should handle mixed protocols', () => {
320+
const result = parseDomainList(['http://api.example.com', 'https://secure.example.com', 'example.com']);
321+
expect(result.plainDomains).toEqual([
322+
{ domain: 'api.example.com', protocol: 'http' },
323+
{ domain: 'secure.example.com', protocol: 'https' },
324+
{ domain: 'example.com', protocol: 'both' },
325+
]);
326+
});
327+
328+
it('should handle protocol-prefixed wildcard patterns', () => {
329+
const result = parseDomainList(['http://*.example.com', 'https://*.secure.com']);
330+
expect(result.patterns).toEqual([
331+
{ original: '*.example.com', regex: '^.*\\.example\\.com$', protocol: 'http' },
332+
{ original: '*.secure.com', regex: '^.*\\.secure\\.com$', protocol: 'https' },
333+
]);
334+
});
335+
336+
it('should strip trailing slash after protocol', () => {
337+
const result = parseDomainList(['http://github.com/', 'https://example.com/']);
338+
expect(result.plainDomains).toEqual([
339+
{ domain: 'github.com', protocol: 'http' },
340+
{ domain: 'example.com', protocol: 'https' },
341+
]);
342+
});
343+
});
196344
});
197345

198346
describe('isDomainMatchedByPattern', () => {
199347
it('should match domain against leading wildcard', () => {
200-
const patterns = [{ original: '*.github.com', regex: '^.*\\.github\\.com$' }];
201-
expect(isDomainMatchedByPattern('api.github.com', patterns)).toBe(true);
202-
expect(isDomainMatchedByPattern('raw.github.com', patterns)).toBe(true);
348+
const patterns = [{ original: '*.github.com', regex: '^.*\\.github\\.com$', protocol: 'both' as const }];
349+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'both' }, patterns)).toBe(true);
350+
expect(isDomainMatchedByPattern({ domain: 'raw.github.com', protocol: 'both' }, patterns)).toBe(true);
203351
});
204352

205353
it('should not match domain that does not fit pattern', () => {
206-
const patterns = [{ original: '*.github.com', regex: '^.*\\.github\\.com$' }];
207-
expect(isDomainMatchedByPattern('github.com', patterns)).toBe(false);
208-
expect(isDomainMatchedByPattern('gitlab.com', patterns)).toBe(false);
209-
expect(isDomainMatchedByPattern('notgithub.com', patterns)).toBe(false);
354+
const patterns = [{ original: '*.github.com', regex: '^.*\\.github\\.com$', protocol: 'both' as const }];
355+
expect(isDomainMatchedByPattern({ domain: 'github.com', protocol: 'both' }, patterns)).toBe(false);
356+
expect(isDomainMatchedByPattern({ domain: 'gitlab.com', protocol: 'both' }, patterns)).toBe(false);
357+
expect(isDomainMatchedByPattern({ domain: 'notgithub.com', protocol: 'both' }, patterns)).toBe(false);
210358
});
211359

212360
it('should match against middle wildcard', () => {
213-
const patterns = [{ original: 'api-*.example.com', regex: '^api-.*\\.example\\.com$' }];
214-
expect(isDomainMatchedByPattern('api-v1.example.com', patterns)).toBe(true);
215-
expect(isDomainMatchedByPattern('api-test.example.com', patterns)).toBe(true);
216-
expect(isDomainMatchedByPattern('api.example.com', patterns)).toBe(false);
361+
const patterns = [{ original: 'api-*.example.com', regex: '^api-.*\\.example\\.com$', protocol: 'both' as const }];
362+
expect(isDomainMatchedByPattern({ domain: 'api-v1.example.com', protocol: 'both' }, patterns)).toBe(true);
363+
expect(isDomainMatchedByPattern({ domain: 'api-test.example.com', protocol: 'both' }, patterns)).toBe(true);
364+
expect(isDomainMatchedByPattern({ domain: 'api.example.com', protocol: 'both' }, patterns)).toBe(false);
217365
});
218366

219367
it('should match against any pattern in list', () => {
220368
const patterns = [
221-
{ original: '*.github.com', regex: '^.*\\.github\\.com$' },
222-
{ original: '*.gitlab.com', regex: '^.*\\.gitlab\\.com$' },
369+
{ original: '*.github.com', regex: '^.*\\.github\\.com$', protocol: 'both' as const },
370+
{ original: '*.gitlab.com', regex: '^.*\\.gitlab\\.com$', protocol: 'both' as const },
223371
];
224-
expect(isDomainMatchedByPattern('api.github.com', patterns)).toBe(true);
225-
expect(isDomainMatchedByPattern('api.gitlab.com', patterns)).toBe(true);
226-
expect(isDomainMatchedByPattern('api.bitbucket.com', patterns)).toBe(false);
372+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'both' }, patterns)).toBe(true);
373+
expect(isDomainMatchedByPattern({ domain: 'api.gitlab.com', protocol: 'both' }, patterns)).toBe(true);
374+
expect(isDomainMatchedByPattern({ domain: 'api.bitbucket.com', protocol: 'both' }, patterns)).toBe(false);
227375
});
228376

229377
it('should be case-insensitive', () => {
230-
const patterns = [{ original: '*.GitHub.com', regex: '^.*\\.GitHub\\.com$' }];
231-
expect(isDomainMatchedByPattern('API.GITHUB.COM', patterns)).toBe(true);
232-
expect(isDomainMatchedByPattern('api.github.com', patterns)).toBe(true);
378+
const patterns = [{ original: '*.GitHub.com', regex: '^.*\\.GitHub\\.com$', protocol: 'both' as const }];
379+
expect(isDomainMatchedByPattern({ domain: 'API.GITHUB.COM', protocol: 'both' }, patterns)).toBe(true);
380+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'both' }, patterns)).toBe(true);
233381
});
234382

235383
it('should return false for empty pattern list', () => {
236-
expect(isDomainMatchedByPattern('api.github.com', [])).toBe(false);
384+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'both' }, [])).toBe(false);
385+
});
386+
387+
describe('protocol compatibility', () => {
388+
it('should match when pattern has "both" protocol', () => {
389+
const patterns = [{ original: '*.github.com', regex: '^.*\\.github\\.com$', protocol: 'both' as const }];
390+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'http' }, patterns)).toBe(true);
391+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'https' }, patterns)).toBe(true);
392+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'both' }, patterns)).toBe(true);
393+
});
394+
395+
it('should not fully cover "both" domain with single protocol pattern', () => {
396+
const httpPatterns = [{ original: '*.github.com', regex: '^.*\\.github\\.com$', protocol: 'http' as const }];
397+
const httpsPatterns = [{ original: '*.github.com', regex: '^.*\\.github\\.com$', protocol: 'https' as const }];
398+
// A domain that needs "both" cannot be fully covered by a single-protocol pattern
399+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'both' }, httpPatterns)).toBe(false);
400+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'both' }, httpsPatterns)).toBe(false);
401+
});
402+
403+
it('should match when protocols match exactly', () => {
404+
const httpPatterns = [{ original: '*.github.com', regex: '^.*\\.github\\.com$', protocol: 'http' as const }];
405+
const httpsPatterns = [{ original: '*.github.com', regex: '^.*\\.github\\.com$', protocol: 'https' as const }];
406+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'http' }, httpPatterns)).toBe(true);
407+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'https' }, httpsPatterns)).toBe(true);
408+
});
409+
410+
it('should not match when protocols do not match', () => {
411+
const httpPatterns = [{ original: '*.github.com', regex: '^.*\\.github\\.com$', protocol: 'http' as const }];
412+
const httpsPatterns = [{ original: '*.github.com', regex: '^.*\\.github\\.com$', protocol: 'https' as const }];
413+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'https' }, httpPatterns)).toBe(false);
414+
expect(isDomainMatchedByPattern({ domain: 'api.github.com', protocol: 'http' }, httpsPatterns)).toBe(false);
415+
});
237416
});
238417
});

0 commit comments

Comments
 (0)