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(); + }); + }); +});