Skip to content

Commit 78418c3

Browse files
chargomeclaude
andcommitted
fix(core): Add filterUrlQuery util
Applies a `CollectBehavior` to the query string of a URL, leaving every other part of the URL untouched. The query is located by string offset rather than by parsing, so encoding, duplicate keys and param order are preserved byte-for-byte. All filtering logic is delegated to the existing `filterQueryParams`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5c4c3aa commit 78418c3

2 files changed

Lines changed: 115 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { CollectBehavior } from '../../types/datacollection';
2+
import { filterQueryParams } from './filterQueryParams';
3+
4+
/**
5+
* Applies a `CollectBehavior` to the query string of a full URL, leaving every other URL component
6+
* (scheme, host, path, fragment) untouched.
7+
*
8+
* The query is located by string offsets rather than by parsing, so the URL is returned byte-for-byte
9+
* apart from the query itself. This keeps relative URLs, non-HTTP schemes and unusual encodings intact,
10+
* none of which survive a `URL` round-trip.
11+
*
12+
* Returns the URL with its query filtered, or with the query removed entirely when collection is off.
13+
*/
14+
export function filterUrlQuery(url: string, behavior: CollectBehavior): string {
15+
// The fragment is delimited first: a `?` after a `#` belongs to the fragment, not the query.
16+
const fragmentStart = url.indexOf('#');
17+
const queryEnd = fragmentStart === -1 ? url.length : fragmentStart;
18+
19+
const queryStart = url.indexOf('?');
20+
if (queryStart === -1 || queryStart > queryEnd) {
21+
return url;
22+
}
23+
24+
const prefix = url.slice(0, queryStart);
25+
const query = url.slice(queryStart + 1, queryEnd);
26+
const suffix = url.slice(queryEnd);
27+
28+
const filtered = filterQueryParams(query, behavior);
29+
30+
return filtered ? `${prefix}?${filtered}${suffix}` : `${prefix}${suffix}`;
31+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { filterUrlQuery } from '../../../../src/utils/data-collection/filterUrlQuery';
3+
4+
describe('filterUrlQuery', () => {
5+
describe('no query string', () => {
6+
it('returns the URL unchanged', () => {
7+
expect(filterUrlQuery('https://example.com/api/users', true)).toBe('https://example.com/api/users');
8+
});
9+
10+
it('returns a URL with only a fragment unchanged', () => {
11+
expect(filterUrlQuery('https://example.com/docs#section', true)).toBe('https://example.com/docs#section');
12+
});
13+
14+
it('leaves a trailing `?` with no params alone', () => {
15+
expect(filterUrlQuery('https://example.com/api?', true)).toBe('https://example.com/api');
16+
});
17+
});
18+
19+
describe('denyList mode (true)', () => {
20+
it('filters sensitive params and preserves the rest', () => {
21+
const result = filterUrlQuery('https://example.com/api/users?token=abc123&q=a%20b%26c&page=5', true);
22+
23+
expect(result).toBe('https://example.com/api/users?token=[Filtered]&q=a%20b%26c&page=5');
24+
});
25+
26+
it('preserves the fragment', () => {
27+
const result = filterUrlQuery('https://example.com/api?token=abc&page=5#results', true);
28+
29+
expect(result).toBe('https://example.com/api?token=[Filtered]&page=5#results');
30+
});
31+
32+
it('preserves userinfo, port and path', () => {
33+
const result = filterUrlQuery('https://user:pw@example.com:8443/a/b?secret=x&ok=1', true);
34+
35+
expect(result).toBe('https://user:pw@example.com:8443/a/b?secret=[Filtered]&ok=1');
36+
});
37+
});
38+
39+
describe('off mode (false)', () => {
40+
it('removes the query entirely', () => {
41+
expect(filterUrlQuery('https://example.com/api/users?token=abc&page=5', false)).toBe(
42+
'https://example.com/api/users',
43+
);
44+
});
45+
46+
it('removes the query but keeps the fragment', () => {
47+
expect(filterUrlQuery('https://example.com/api?token=abc#results', false)).toBe(
48+
'https://example.com/api#results',
49+
);
50+
});
51+
});
52+
53+
describe('allow / deny behaviors', () => {
54+
it('supports allowList mode', () => {
55+
const result = filterUrlQuery('https://example.com/s?page=1&ref=x&sort=name', { allow: ['page', 'sort'] });
56+
57+
expect(result).toBe('https://example.com/s?page=1&ref=[Filtered]&sort=name');
58+
});
59+
60+
it('supports extra deny terms', () => {
61+
const result = filterUrlQuery('https://example.com/s?page=1&utm_source=email', { deny: ['utm'] });
62+
63+
expect(result).toBe('https://example.com/s?page=1&utm_source=[Filtered]');
64+
});
65+
});
66+
67+
describe('non-standard URLs', () => {
68+
it('handles relative URLs', () => {
69+
expect(filterUrlQuery('/api/users?token=abc&page=5', true)).toBe('/api/users?token=[Filtered]&page=5');
70+
});
71+
72+
it('preserves duplicate params and their order', () => {
73+
const result = filterUrlQuery('https://example.com/s?page=1&token=a&page=2', true);
74+
75+
expect(result).toBe('https://example.com/s?page=1&token=[Filtered]&page=2');
76+
});
77+
78+
it('does not treat a `?` inside a fragment as a query', () => {
79+
const result = filterUrlQuery('https://example.com/docs#/route?token=abc', true);
80+
81+
expect(result).toBe('https://example.com/docs#/route?token=abc');
82+
});
83+
});
84+
});

0 commit comments

Comments
 (0)