Skip to content

Commit 9e595eb

Browse files
fix(web): bound route label cardinality with an allowlist
Truncating path depth bounded depth, not breadth. The first path segment is client-supplied and /api/[...slug] is a catch-all, so /wp-admin, /.env, and /api/<anything> each minted a new time series. Scanner traffic could grow the series count without limit, which is exactly what the normalization was supposed to prevent. Match the truncated path against a known-route set and report anything else as `other`, bounding distinct route labels to that set plus one regardless of what is requested. A route missing from the set loses granularity rather than breaking, so it fails closed. Also strengthens the metrics-port exclusion assertion. It checked for the absence of a `/metrics` label, which became vacuous once unknown paths collapse to `other` — it now asserts the total observation count, and fails if the port filter is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a0e55b2 commit 9e595eb

3 files changed

Lines changed: 125 additions & 28 deletions

File tree

packages/web/src/httpMetrics.integration.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ describe('httpMetrics', () => {
5959
expect(browse).toHaveLength(1);
6060
expect(browse[0].trim().endsWith('2')).toBe(true);
6161

62-
// The scrape of the metrics port must not appear at all.
63-
expect(counts.some(line => line.includes('route="/metrics"'))).toBe(false);
62+
// The scrape of the metrics port must not be recorded. Asserted on the
63+
// total observation count rather than on the absence of a `/metrics`
64+
// label: `/metrics` is not a known route, so it would land in `other`
65+
// and an absent-label check would pass even with the filter removed.
66+
const total = counts.reduce((sum, line) => sum + Number(line.trim().split(' ').pop()), 0);
67+
expect(total).toBe(3);
68+
expect(counts.some(line => line.includes('route="other"'))).toBe(false);
6469
});
6570
});
Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,79 @@
11
import { describe, expect, it } from 'vitest';
2-
import { normalizeRoute } from './httpMetrics';
2+
import { MAX_ROUTE_LABELS, normalizeRoute } from './httpMetrics';
33

44
describe('normalizeRoute', () => {
55
it('maps the root path', () => {
66
expect(normalizeRoute('/')).toBe('/');
77
expect(normalizeRoute('')).toBe('/');
88
});
99

10-
it('keeps two segments for API routes', () => {
10+
it('keeps two segments for known API routes', () => {
1111
expect(normalizeRoute('/api/health')).toBe('/api/health');
1212
expect(normalizeRoute('/api/commits')).toBe('/api/commits');
1313
expect(normalizeRoute('/api/auth/callback/github')).toBe('/api/auth');
1414
});
1515

16-
it('keeps one segment for page routes', () => {
16+
it('keeps one segment for known page routes', () => {
1717
expect(normalizeRoute('/search')).toBe('/search');
1818
expect(normalizeRoute('/settings/connections/42')).toBe('/settings');
1919
});
2020

21-
it('bounds unbounded repository and file paths', () => {
21+
it('collapses unbounded repository and file paths', () => {
2222
const a = normalizeRoute('/browse/github.com/org/repo/-/blob/src/index.ts');
2323
const b = normalizeRoute('/browse/github.com/other/repo/-/blob/lib/other.ts');
2424

2525
expect(a).toBe('/browse');
26-
expect(b).toBe('/browse');
27-
// The point of normalizing: distinct files must not mint distinct labels.
28-
expect(a).toBe(b);
26+
expect(b).toBe(a);
2927
});
3028

3129
it('is unaffected by trailing or duplicate slashes', () => {
3230
expect(normalizeRoute('/search/')).toBe('/search');
3331
expect(normalizeRoute('//search//')).toBe('/search');
3432
});
3533

36-
it('produces a bounded label set for a realistic path mix', () => {
37-
const paths = [
38-
'/', '/search', '/search?q=foo'.split('?')[0], '/repos', '/settings/general',
39-
'/browse/github.com/a/b/-/blob/x.ts', '/browse/github.com/c/d/-/blob/y.ts',
40-
'/api/health', '/api/health', '/api/commits', '/api/auth/session',
41-
'/_next/static/chunks/main.js', '/_next/static/css/app.css',
42-
];
34+
describe('cardinality bounding', () => {
35+
it('reports unknown top-level paths as other', () => {
36+
expect(normalizeRoute('/wp-admin')).toBe('other');
37+
expect(normalizeRoute('/.env')).toBe('other');
38+
expect(normalizeRoute('/phpmyadmin/index.php')).toBe('other');
39+
});
4340

44-
const labels = new Set(paths.map(normalizeRoute));
41+
it('reports unknown API paths as other, despite the [...slug] catch-all', () => {
42+
expect(normalizeRoute('/api/not-a-real-route')).toBe('other');
43+
expect(normalizeRoute('/api/12345')).toBe('other');
44+
expect(normalizeRoute('/api/health-check')).toBe('other');
45+
});
4546

46-
expect(labels).toEqual(new Set([
47-
'/', '/search', '/repos', '/settings', '/browse',
48-
'/api/health', '/api/commits', '/api/auth', '/_next',
49-
]));
47+
it('stays bounded under scanner traffic', () => {
48+
const hostile: string[] = [];
49+
for (let i = 0; i < 1000; i++) {
50+
hostile.push(`/scan-${i}`);
51+
hostile.push(`/api/scan-${i}`);
52+
hostile.push(`/${i}/${i}/${i}`);
53+
}
54+
55+
const labels = new Set(hostile.map(normalizeRoute));
56+
57+
// 3000 distinct hostile paths must produce exactly one label.
58+
expect(labels).toEqual(new Set(['other']));
59+
});
60+
61+
it('never exceeds the documented label bound for any input', () => {
62+
const paths = [
63+
'/', '/search', '/repos', '/settings/general', '/browse/a/b/c',
64+
'/api/health', '/api/commits', '/api/auth/session', '/_next/static/x.js',
65+
'/wp-admin', '/api/bogus', '/random', '/api/9', '/..%2f', '/a/b/c/d/e',
66+
];
67+
for (let i = 0; i < 500; i++) {
68+
paths.push(`/junk${i}`, `/api/junk${i}`);
69+
}
70+
71+
const labels = new Set(paths.map(normalizeRoute));
72+
73+
expect(labels.size).toBeLessThanOrEqual(MAX_ROUTE_LABELS);
74+
// Known routes still resolve; only the unknown ones collapse.
75+
expect(labels).toContain('/api/health');
76+
expect(labels).toContain('other');
77+
});
5078
});
5179
});

packages/web/src/httpMetrics.ts

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,74 @@ import { httpRequestDuration } from './promClient';
55

66
const logger = createLogger('web-http-metrics');
77

8+
/**
9+
* Every path that isn't in this set is reported as `other`.
10+
*
11+
* Truncating path depth alone does not bound cardinality: the first segment is
12+
* client-supplied, and `/api/[...slug]` is a catch-all, so `/wp-admin`,
13+
* `/.env`, and `/api/<anything>` would each mint a new time series. Scanner or
14+
* bot traffic would then grow the series count without limit. Matching against
15+
* a known set instead bounds it to this size plus one, whatever gets requested.
16+
*
17+
* Adding a route here is deliberate. A missing one is reported as `other`, so
18+
* new routes lose granularity rather than breaking, and cardinality holds.
19+
*/
20+
const KNOWN_ROUTES = new Set([
21+
'/',
22+
'/_next',
23+
'/askgh',
24+
'/browse',
25+
'/chat',
26+
'/chats',
27+
'/invite',
28+
'/login',
29+
'/oauth',
30+
'/onboard',
31+
'/redeem',
32+
'/repos',
33+
'/search',
34+
'/settings',
35+
'/signup',
36+
'/slow',
37+
'/api/auth',
38+
'/api/avatar',
39+
'/api/blame',
40+
'/api/changelog',
41+
'/api/chat',
42+
'/api/commit',
43+
'/api/commits',
44+
'/api/diff',
45+
'/api/ee',
46+
'/api/files',
47+
'/api/find_definitions',
48+
'/api/find_references',
49+
'/api/folder_contents',
50+
'/api/health',
51+
'/api/minidenticon',
52+
'/api/models',
53+
'/api/offers',
54+
'/api/openapi.json',
55+
'/api/repo-status',
56+
'/api/repos',
57+
'/api/search',
58+
'/api/source',
59+
'/api/stream_search',
60+
'/api/symbols',
61+
'/api/tree',
62+
'/api/version',
63+
'/api/webhook',
64+
]);
65+
66+
const OTHER_ROUTE = 'other';
67+
868
/**
969
* Collapses a request path into a bounded label.
1070
*
11-
* The full path can't be used: repository and file paths are unbounded, so
12-
* `/browse/github.com/org/repo/-/blob/src/index.ts` would mint a new time
13-
* series for every file anyone views. API paths keep two segments so
14-
* `/api/health` stays distinct from `/api/commits`; everything else keeps one.
15-
* That bounds the label to roughly the number of API routes plus top-level
16-
* pages.
71+
* Depth is truncated first, because repository and file paths are unbounded and
72+
* `/browse/github.com/org/repo/-/blob/src/index.ts` must not mint a series per
73+
* file viewed. API paths keep two segments so `/api/health` stays distinct from
74+
* `/api/commits`; everything else keeps one. The result is then matched against
75+
* `KNOWN_ROUTES`, which is what actually bounds the label set.
1776
*/
1877
export const normalizeRoute = (pathname: string): string => {
1978
const segments = pathname.split('/').filter(segment => segment.length > 0);
@@ -22,9 +81,14 @@ export const normalizeRoute = (pathname: string): string => {
2281
}
2382

2483
const depth = segments[0] === 'api' ? 2 : 1;
25-
return `/${segments.slice(0, depth).join('/')}`;
84+
const candidate = `/${segments.slice(0, depth).join('/')}`;
85+
86+
return KNOWN_ROUTES.has(candidate) ? candidate : OTHER_ROUTE;
2687
};
2788

89+
/** Upper bound on distinct `route` label values, for tests and review. */
90+
export const MAX_ROUTE_LABELS = KNOWN_ROUTES.size + 1;
91+
2892
interface RequestStartMessage {
2993
response?: ServerResponse;
3094
socket?: { localPort?: number };

0 commit comments

Comments
 (0)