From 7f5b909ef70d1b0880e6f59d3eab9fa81fd66268 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:35:33 -0400 Subject: [PATCH] fix(@angular/build): match proxy glob patterns against URL pathname only When normalizing proxy configurations for Vite, glob patterns are converted into regular expression strings using picomatch. Because picomatch builds regular expressions assuming the input is a file path and anchors them with `$`, whereas Vite tests regex proxy keys against `req.url` (which includes any query string), requests with query parameters could fail to match glob entries such as `/search/**` or `/api/*.json`, or inadvertently match wildcards against query string parameters. Adapt the generated regular expression so that wildcard tokens do not match across the `?` query delimiter and end-of-path anchors allow an optional query string after the matched pathname. Closes #34257 --- .../tests/options/proxy-config_spec.ts | 52 ++++++- .../build/src/utils/load-proxy-config.ts | 32 ++++- .../build/src/utils/load-proxy-config_spec.ts | 129 ++++++++++++++++++ 3 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 packages/angular/build/src/utils/load-proxy-config_spec.ts diff --git a/packages/angular/build/src/builders/dev-server/tests/options/proxy-config_spec.ts b/packages/angular/build/src/builders/dev-server/tests/options/proxy-config_spec.ts index 745da6eee969..31ea54fc4004 100644 --- a/packages/angular/build/src/builders/dev-server/tests/options/proxy-config_spec.ts +++ b/packages/angular/build/src/builders/dev-server/tests/options/proxy-config_spec.ts @@ -330,6 +330,56 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT await otherProxyServer.close(); } }); + + it('matches glob entries against the URL pathname when a query string is present', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + proxyConfig: 'proxy.config.json', + }); + + const proxyServer = await createProxyServer(); + try { + await harness.writeFiles({ + 'proxy.config.json': ` + { + "/api/test/**": { "target": "http://127.0.0.1:${proxyServer.address.port}" } + } + `, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/api/test?q=angular'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain('TEST_API_RETURN'); + } finally { + await proxyServer.close(); + } + }); + + it('does not match glob wildcards against query string parameters', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + proxyConfig: 'proxy.config.json', + }); + + const proxyServer = await createProxyServer(); + try { + await harness.writeFiles({ + 'proxy.config.json': ` + { + "/api/*.json": { "target": "http://127.0.0.1:${proxyServer.address.port}" } + } + `, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/api/test?file=data.json'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).not.toContain('TEST_API_RETURN'); + } finally { + await proxyServer.close(); + } + }); }); }); @@ -340,7 +390,7 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT */ async function createProxyServer(body = 'TEST_API_RETURN') { const proxyServer = createServer((request, response) => { - if (request.url?.endsWith('/test')) { + if (request.url?.split('?')[0].endsWith('/test')) { response.writeHead(200); response.end(body); } else { diff --git a/packages/angular/build/src/utils/load-proxy-config.ts b/packages/angular/build/src/utils/load-proxy-config.ts index e40b1981d764..f32db992ac92 100644 --- a/packages/angular/build/src/utils/load-proxy-config.ts +++ b/packages/angular/build/src/utils/load-proxy-config.ts @@ -118,7 +118,7 @@ function normalizeProxyConfiguration( // since Vite proxies a request with the first entry that matches it. const orderedProxy: Record = {}; for (const [key, value] of Object.entries(normalizedProxy)) { - const context = key[0] !== '^' && isDynamicPattern(key) ? makeRegExpFromGlob(key).source : key; + const context = key[0] !== '^' && isDynamicPattern(key) ? makeUrlRegExpFromGlob(key) : key; orderedProxy[context] = value; } normalizedProxy = orderedProxy; @@ -151,6 +151,36 @@ function normalizeProxyConfiguration( return normalizedProxy; } +/** + * Converts a glob pattern to a regular expression source string that matches + * the pathname portion of a request URL while allowing an optional query string. + * + * Vite tests regex proxy keys against `req.url`, which includes the query string, + * whereas `picomatch` generates regexes assuming the input is only a pathname. + */ +function makeUrlRegExpFromGlob(glob: string): string { + const { source } = makeRegExpFromGlob(glob); + + return ( + source + // Ensure picomatch's "at least one character" lookahead does not + // treat the `?` query delimiter as a pathname character (e.g., `/api/*` on `/api/?a=1`). + .replaceAll('(?=.)', '(?=[^?])') + // Prevent single-segment wildcards (`*` and `?`) from matching + // across `?` into the query string (e.g., `/api/*.json` on `/api/users?file=data.json`). + .replaceAll('[^/]', '[^/?]') + // Prevent leading-dot-aware `?` wildcards from matching the `?` delimiter. + .replaceAll('[^.\\/]', '[^.\\/?]') + // Prevent multi-segment globstars (`**`) from matching across `?` into the query string + // (e.g., `/api/**/users` on `/api/other?redirect=/users`). + .replaceAll('\\.).)*?', '\\.)[^?])*?') + // Replace unescaped end-of-input anchors (`$`, both at the end of the pattern and inside + // internal alternation groups for trailing `/**` or negated globs) so an optional query + // string is permitted after the matched pathname. + .replace(/(? { + let tempDir: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempDir = await mkdtemp(join(baseTmpDir, 'angular-proxy-config-spec-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + async function createGlobMatcher(glob: string): Promise<(url: string) => boolean> { + const configPath = join(tempDir, 'proxy.conf.json'); + await writeFile(configPath, JSON.stringify({ [glob]: { target: 'http://localhost:3000' } })); + const proxy = await loadProxyConfiguration(tempDir, 'proxy.conf.json'); + assert(proxy, 'Expected proxy configuration to be loaded'); + const keys = Object.keys(proxy); + expect(keys.length).toBe(1); + const regex = new RegExp(keys[0]); + + return (url: string) => regex.test(url); + } + + describe('glob to RegExp conversion with query strings', () => { + it('handles trailing globstar (/**) with and without query strings', async () => { + const matches = await createGlobMatcher('/search/**'); + + expect(matches('/search')).toBeTrue(); + expect(matches('/search?q=angular')).toBeTrue(); + expect(matches('/search/')).toBeTrue(); + expect(matches('/search/?q=angular')).toBeTrue(); + expect(matches('/search/items')).toBeTrue(); + expect(matches('/search/items?q=angular')).toBeTrue(); + expect(matches('/search/items?callback=/.hidden')).toBeTrue(); + + expect(matches('/searching')).toBeFalse(); + expect(matches('/searching?q=angular')).toBeFalse(); + expect(matches('/other?redirect=/search')).toBeFalse(); + }); + + it('handles single-segment wildcard (*) without matching across slashes or query strings', async () => { + const matches = await createGlobMatcher('/api/*'); + + expect(matches('/api/users')).toBeTrue(); + expect(matches('/api/users?id=1')).toBeTrue(); + expect(matches('/api/users?redirect=/home/page')).toBeTrue(); + + expect(matches('/api')).toBeFalse(); + expect(matches('/api/')).toBeFalse(); + expect(matches('/api/?a=1')).toBeFalse(); + expect(matches('/api/users/profile')).toBeFalse(); + expect(matches('/api/users/profile?a=1')).toBeFalse(); + }); + + it('handles suffix globs (*.json) without matching query parameters', async () => { + const matches = await createGlobMatcher('/api/*.json'); + + expect(matches('/api/data.json')).toBeTrue(); + expect(matches('/api/data.json?v=1')).toBeTrue(); + + expect(matches('/api/users')).toBeFalse(); + expect(matches('/api/users?file=data.json')).toBeFalse(); + expect(matches('/api/.json?v=1')).toBeFalse(); + }); + + it('handles middle globstar (/**/) without matching paths in query parameters', async () => { + const matches = await createGlobMatcher('/api/**/users'); + + expect(matches('/api/users')).toBeTrue(); + expect(matches('/api/users?page=1')).toBeTrue(); + expect(matches('/api/v1/users?page=1')).toBeTrue(); + expect(matches('/api/v1/v2/users?page=1')).toBeTrue(); + + expect(matches('/api/other?redirect=/users')).toBeFalse(); + expect(matches('/api/users/profile?page=1')).toBeFalse(); + }); + + it('handles single-character wildcard (?) without matching the query delimiter', async () => { + const matches = await createGlobMatcher('/api/v?'); + + expect(matches('/api/v1')).toBeTrue(); + expect(matches('/api/v1?a=1')).toBeTrue(); + + expect(matches('/api/v')).toBeFalse(); + expect(matches('/api/v?')).toBeFalse(); + expect(matches('/api/v?a=1')).toBeFalse(); + expect(matches('/api/v12?a=1')).toBeFalse(); + }); + + it('handles negated globs (!) with and without query strings', async () => { + const matches = await createGlobMatcher('!/api/public/**'); + + expect(matches('/api/public')).toBeFalse(); + expect(matches('/api/public?foo=1')).toBeFalse(); + expect(matches('/api/public/assets?foo=1')).toBeFalse(); + + expect(matches('/api/private')).toBeTrue(); + expect(matches('/api/private?foo=1')).toBeTrue(); + }); + + it('handles brace expansion and extglobs with query strings', async () => { + const matchesBraces = await createGlobMatcher('/api/{auth,users}/**'); + expect(matchesBraces('/api/auth?q=1')).toBeTrue(); + expect(matchesBraces('/api/users/list?q=1')).toBeTrue(); + expect(matchesBraces('/api/other?q=1')).toBeFalse(); + + const matchesExtglob = await createGlobMatcher('/**/!(*.d).ts'); + expect(matchesExtglob('/app/main.ts')).toBeTrue(); + expect(matchesExtglob('/app/main.ts?extra=.d.ts')).toBeTrue(); + expect(matchesExtglob('/app/main.d.ts')).toBeFalse(); + expect(matchesExtglob('/app/main.d.ts?extra=.ts')).toBeFalse(); + }); + }); +});