Repository: vercel/next.js
Pull request: Fix bad decoding for x-matched-path header

This is a follow-up to https://github.com/vercel/next.js/pull/78326 which reverts the change that interferes with the added middleware rewrite test while also ensuring the encoding test from the `vercel/vercel` repo is still passing as expected. The issue with the encoding seems to be from the `x-matched-path` value being incorrectly decoded when using non-utf8 characters so this fixes the encoding. 

Fixes: https://github.com/vercel/next.js/actions/runs/14717733032/attempts/2

<details>

<summary>test runs with patch</summary>


![CleanShot 2025-04-29 at 09 21 44@2x](https://github.com/user-attachments/assets/de5d66c5-5eb2-4e87-877e-875a7c937f35)
![CleanShot 2025-04-29 at 09 21 51@2x](https://github.com/user-attachments/assets/f1743f6c-d06c-4999-869a-9afaf1b69ab5)

</details>

Changed files:
filename: packages/next/src/server/base-server.ts
patch: @@ -177,6 +177,7 @@ import {
 import { InvariantError } from '../shared/lib/invariant-error'
 import { decodeQueryPathParameter } from './lib/decode-query-path-parameter'
 import { getCacheHandlers } from './use-cache/handlers'
+import { fixMojibake } from './lib/fix-mojibake'
 
 export type FindComponentsResult = {
   components: LoadComponentsReturnType
@@ -1096,7 +1097,7 @@ export default abstract class Server<
           // x-matched-path is the source of truth, it tells what page
           // should be rendered because we don't process rewrites in minimalMode
           let { pathname: matchedPath } = new URL(
-            req.headers[MATCHED_PATH_HEADER] as string,
+            fixMojibake(req.headers[MATCHED_PATH_HEADER] as string),
             'http://localhost'
           )
 
@@ -1249,9 +1250,14 @@ export default abstract class Server<
           if (pageIsDynamic) {
             let params: ParsedUrlQuery | false = {}
 
-            // ensure we normalize the dynamic route params for encoding/
-            // default values
-            paramsResult = utils.normalizeDynamicRouteParams(queryParams, false)
+            // If we don't already have valid params, try to parse them from
+            // the query params.
+            if (!paramsResult.hasValidParams) {
+              paramsResult = utils.normalizeDynamicRouteParams(
+                queryParams,
+                false
+              )
+            }
 
             // for prerendered ISR paths we attempt parsing the route
             // params from the URL directly as route-matches may not

filename: packages/next/src/server/lib/fix-mojibake.test.ts
patch: @@ -0,0 +1,21 @@
+import { fixMojibake } from './fix-mojibake'
+
+describe('Mojibake handling', () => {
+  const validValues = [
+    'hello-world',
+    'hello world',
+    encodeURIComponent('こんにちは'),
+  ]
+  it.each(validValues)(
+    'should maintain value when encoding is correct $1',
+    (testValue) => {
+      expect(fixMojibake(testValue)).toBe(testValue)
+    }
+  )
+
+  it('should fix invalid encoding', () => {
+    expect(fixMojibake('/blog/ã\x81\x93ã\x82\x93ã\x81«ã\x81¡ã\x81¯')).toBe(
+      '/blog/こんにちは'
+    )
+  })
+})

filename: packages/next/src/server/lib/fix-mojibake.ts
patch: @@ -0,0 +1,14 @@
+// x-matched-path header can be decoded incorrectly
+// and should only be utf8 characters so this fixes
+// incorrectly encoded values
+export function fixMojibake(input: string): string {
+  // Convert each character's char code to a byte
+  const bytes = new Uint8Array(input.length)
+  for (let i = 0; i < input.length; i++) {
+    bytes[i] = input.charCodeAt(i)
+  }
+
+  // Decode the bytes as proper UTF-8
+  const decoder = new TextDecoder('utf-8')
+  return decoder.decode(bytes)
+}