Sitelet https://github.com/nuxt/nuxt/commit/619963309
Skip to content

Commit 6199633

Browse files
committed
fix(nuxt): case-fold route rule keys to match folded lookups
1 parent 0704254 commit 6199633

6 files changed

Lines changed: 87 additions & 15 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"test:fixtures": "pnpm test:prepare && vitest run --project 'fixtures:*'",
2828
"test:fixtures:dev": "vitest run --project 'fixtures:vite-dev-*'",
2929
"test:fixtures:webpack": "vitest run --project 'fixtures:webpack-*'",
30-
"test:runtime": "vitest run --project nuxt --project nuxt-universal --project nuxt-legacy",
30+
"test:runtime": "vitest run --project nuxt --project nuxt-universal --project nuxt-legacy --project nuxt-routerules-case",
3131
"test:types": "pnpm --filter './test/fixtures/**' test:types",
3232
"test:bundle": "pnpm build && vitest run bundle",
3333
"test:unit": "vitest run --project unit",

‎packages/nitro-server/src/index.ts‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -348,13 +348,30 @@ export async function bundle (nuxt: Nuxt & { _nitro?: Nitro }): Promise<void> {
348348

349349
const validManifestKeys = ['prerender', 'redirect', 'appMiddleware', 'appLayout', 'cache', 'isr', 'swr', 'ssr']
350350

351-
function getRouteRulesRouter () {
351+
// rou3 matches keys case-sensitively, but vue-router matches routes case-insensitively
352+
// unless `sensitive`, so an insensitive-routing rule keyed `/Admin` would never match a
353+
// folded lookup and silently lose its protections. `sensitive` can also come from
354+
// `app/router.options.ts` (runtime-only), so emit both a verbatim and a folded matcher
355+
// and pick at runtime.
356+
const caseSensitiveRouteRules = !!nuxt.options.router.options.sensitive
357+
const foldRouteRuleKey = (route: string) => caseSensitiveRouteRules || typeof route !== 'string' ? route : route.toLowerCase()
358+
359+
function getRouteRulesRouter (fold: boolean) {
352360
const routeRulesRouter = createRou3Router<NitroRouteRules>()
353361
if (nuxt._nitro) {
362+
const foldedKeys = new Map<string, string>()
354363
for (const [route, rules] of Object.entries(nuxt._nitro.options.routeRules)) {
355364
if (route === '/__nuxt_error') { continue }
356365
if (validManifestKeys.every(key => !(key in rules))) { continue }
357-
addRoute(routeRulesRouter, undefined, route, rules)
366+
const key = fold && typeof route === 'string' ? route.toLowerCase() : route
367+
if (fold) {
368+
const existing = foldedKeys.get(key)
369+
if (existing !== undefined && existing !== route && !caseSensitiveRouteRules) {
370+
logger.warn(`Route rules for \`${existing}\` and \`${route}\` resolve to the same path when matched case-insensitively; \`${route}\` takes precedence. Disambiguate the keys or set \`router.options.sensitive: true\`.`)
371+
}
372+
foldedKeys.set(key, route)
373+
}
374+
addRoute(routeRulesRouter, undefined, key, rules)
358375
}
359376
}
360377
return routeRulesRouter
@@ -368,7 +385,7 @@ export async function bundle (nuxt: Nuxt & { _nitro?: Nitro }): Promise<void> {
368385
if (cachedMatchers[key]) {
369386
return cachedMatchers[key]
370387
}
371-
const matcher = compileRouterToString(getRouteRulesRouter(), '', {
388+
const compile = (fold: boolean) => compileRouterToString(getRouteRulesRouter(fold), '', {
372389
matchAll: true,
373390
serialize (routeRules) {
374391
return `{${Object.entries(routeRules)
@@ -399,11 +416,17 @@ export async function bundle (nuxt: Nuxt & { _nitro?: Nitro }): Promise<void> {
399416
}}`
400417
},
401418
})
402-
return cachedMatchers[key] = `
403-
import { defu } from 'defu'
404-
const matcher = ${matcher}
405-
export default (path) => defu({}, ...matcher('', typeof path === 'string' ? path.toLowerCase() : path).map(r => r.data).reverse())
406-
`
419+
const sensitiveMatcher = compile(false)
420+
const foldedMatcher = compile(true)
421+
return cachedMatchers[key] = [
422+
`import { defu } from 'defu'`,
423+
`import routerOptions from '#build/router.options.mjs'`,
424+
`const sensitiveMatcher = ${sensitiveMatcher}`,
425+
foldedMatcher === sensitiveMatcher ? `const foldedMatcher = sensitiveMatcher` : `const foldedMatcher = ${foldedMatcher}`,
426+
`export default (path) => routerOptions.sensitive`,
427+
` ? defu({}, ...sensitiveMatcher('', path).map(r => r.data).reverse())`,
428+
` : defu({}, ...foldedMatcher('', typeof path === 'string' ? path.toLowerCase() : path).map(r => r.data).reverse())`,
429+
].join('\n')
407430
},
408431
})
409432

@@ -500,13 +523,13 @@ export async function bundle (nuxt: Nuxt & { _nitro?: Nitro }): Promise<void> {
500523
nitro.hooks.hook('rollup:before', async (nitro) => {
501524
// Add pages prerendered but not covered by route rules
502525
const prerenderedRoutes = new Set<string>()
503-
const routeRulesMatcher = getRouteRulesRouter()
526+
const routeRulesMatcher = getRouteRulesRouter(!caseSensitiveRouteRules)
504527
if (nitro._prerenderedRoutes?.length) {
505528
const payloadSuffix = nuxt.options.experimental.renderJsonPayloads ? '/_payload.json' : '/_payload.js'
506529
for (const route of nitro._prerenderedRoutes) {
507530
if (!route.error && route.route.endsWith(payloadSuffix)) {
508531
const url = route.route.slice(0, -payloadSuffix.length) || '/'
509-
const rules = defu({}, ...findAllRoutes(routeRulesMatcher, undefined, url).reverse()) as Record<string, any>
532+
const rules = defu({}, ...findAllRoutes(routeRulesMatcher, undefined, foldRouteRuleKey(url)).reverse()) as Record<string, any>
510533
if (!rules.prerender) {
511534
prerenderedRoutes.add(url)
512535
}

‎packages/nuxt/src/app/composables/manifest.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ export function getRouteRules (url: string): Record<string, any>
7171
export function getRouteRules (arg: string | H3Event | { path: string }) {
7272
const path = typeof arg === 'string' ? arg : arg.path
7373
try {
74-
return routeRulesMatcher(path.toLowerCase())
74+
// The compiled matcher case-folds the lookup path itself (unless routing is
75+
// `sensitive`), so callers pass the path verbatim; folding here as well would
76+
// force case-insensitive matching even when `sensitive: true` is configured.
77+
return routeRulesMatcher(path)
7578
} catch (e) {
7679
console.error('[nuxt] Error matching route rules.', e)
7780
return {}

‎packages/nuxt/src/pages/module.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,12 +440,16 @@ export default defineNuxtModule({
440440

441441
// Inject page patterns that explicitly match `prerender: true` route rule
442442
if (!nitro.options.static) {
443+
// Fold keys (unless `sensitive`) to mirror the compiled `#build/route-rules.mjs`
444+
// matcher, so a `prerender: true` rule keyed `/Admin` is honoured at `/Admin`.
445+
const caseSensitiveRouteRules = !!nuxt.options.router.options.sensitive
446+
const foldRouteRuleKey = (route: string) => caseSensitiveRouteRules ? route : route.toLowerCase()
443447
const routeRulesRouter = createRou3Router<NitroRouteRules>()
444448
for (const [route, rules] of Object.entries(nitro.options.routeRules)) {
445-
addRoute(routeRulesRouter, undefined, route, rules)
449+
addRoute(routeRulesRouter, undefined, foldRouteRuleKey(route), rules)
446450
}
447451
for (const route of prerenderRoutes) {
448-
const rules = defu({} as Record<string, any>, ...findAllRoutes(routeRulesRouter, undefined, route).reverse())
452+
const rules = defu({} as Record<string, any>, ...findAllRoutes(routeRulesRouter, undefined, foldRouteRuleKey(route)).reverse())
449453
if (rules.prerender) {
450454
nitro.options.prerender.routes.push(route)
451455
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { getRouteRules } from '#app/composables/manifest'
4+
5+
// Routing is case-insensitive by default, so mixed-case route-rule keys must still match any
6+
// request casing. `ssr` and `redirect` exercise the same key-folding path `appMiddleware` uses.
7+
describe('case-insensitive route rules fold mixed-case keys', () => {
8+
it('applies an uppercase-keyed ssr rule for any request casing', () => {
9+
for (const path of ['/Secret/Docs/index', '/secret/docs/index', '/SECRET/DOCS/index']) {
10+
expect(getRouteRules({ path }), path).toMatchObject({ ssr: false })
11+
}
12+
})
13+
14+
it('applies an uppercase-keyed redirect rule for any request casing', () => {
15+
for (const path of ['/Legacy/Home', '/legacy/home', '/LEGACY/HOME']) {
16+
expect(getRouteRules({ path }), path).toMatchObject({ redirect: '/target' })
17+
}
18+
})
19+
})

‎vitest.config.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export default defineConfig({
166166
test: {
167167
name: project,
168168
dir: './test/nuxt',
169-
exclude: [...defaultExclude, '**/universal/**'],
169+
exclude: [...defaultExclude, '**/universal/**', '**/routerules-case/**'],
170170
environment: 'nuxt',
171171
setupFiles: ['./test/setup-runtime.ts'],
172172
env: {
@@ -179,6 +179,29 @@ export default defineConfig({
179179
},
180180
},
181181
}))),
182+
await defineVitestProject({
183+
define: {
184+
'import.meta.dev': 'globalThis.__TEST_DEV__',
185+
},
186+
test: {
187+
name: 'nuxt-routerules-case',
188+
dir: './test/nuxt/routerules-case',
189+
environment: 'nuxt',
190+
setupFiles: ['./test/setup-runtime.ts'],
191+
environmentOptions: {
192+
nuxt: {
193+
// Case-insensitive routing (3.x default) must still match mixed-case rule keys.
194+
overrides: defu({
195+
router: { options: { sensitive: false } },
196+
routeRules: {
197+
'/Secret/Docs/**': { ssr: false },
198+
'/Legacy/Home': { redirect: '/target' },
199+
},
200+
} satisfies NuxtConfig, commonSettings),
201+
},
202+
},
203+
},
204+
}),
182205
],
183206
},
184207
})

0 commit comments

Comments
 (0)