VYPR
Medium severityNVD Advisory· Published Apr 17, 2026· Updated Apr 29, 2026

CVE-2026-40299

CVE-2026-40299

Description

next-intl provides internationalization for Next.js. Applications using the next-intl middleware prior to version 4.9.1with localePrefix: 'as-needed' could construct URLs where path handling and the WHATWG URL parser resolved a relative redirect target to another host (e.g. scheme-relative // or control characters stripped by the URL parser), so the middleware could redirect the browser off-site while the user still started from a trusted app URL. The problem has been patchedin next-intl@4.9.1.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
next-intlnpm
< 4.9.14.9.1

Affected products

1

Patches

1
1c80b668aa6d

fix: Improve middleware pathname validation (#2304)

https://github.com/amannn/next-intlJan AmannApr 10, 2026via ghsa
4 files changed · +94 5
  • packages/next-intl/.size-limit.ts+1 1 modified
    @@ -42,7 +42,7 @@ const config: SizeLimitConfig = [
       {
         name: "import * from 'next-intl/middleware'",
         path: 'dist/esm/production/middleware.js',
    -    limit: '10.1 KB'
    +    limit: '10.12 KB'
       },
       {
         name: "import * from 'next-intl/routing'",
    
  • packages/next-intl/src/middleware/middleware.test.tsx+38 0 modified
    @@ -219,6 +219,44 @@ describe('prefix-based routing', () => {
           );
         });
     
    +    describe('open redirect prevention', () => {
    +      it('redirects to a same-origin URL when the path contains a TAB after decodeURI', () => {
    +        middleware(createMockRequest('/en/\t/example.org'));
    +        expect(MockedNextResponse.next).not.toHaveBeenCalled();
    +        expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
    +        expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
    +          'http://localhost:3000/example.org'
    +        );
    +      });
    +
    +      it('redirects to a same-origin URL when the path contains an encoded backslash', () => {
    +        middleware(createMockRequest('/en/%5Cexample.org'));
    +        expect(MockedNextResponse.next).not.toHaveBeenCalled();
    +        expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
    +        expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
    +          'http://localhost:3000/%5Cexample.org'
    +        );
    +      });
    +
    +      it('redirects to a same-origin URL when the path contains excess slashes before a segment', () => {
    +        middleware(createMockRequest('/en///example.org'));
    +        expect(MockedNextResponse.next).not.toHaveBeenCalled();
    +        expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
    +        expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
    +          'http://localhost:3000/example.org'
    +        );
    +      });
    +
    +      it('redirects to a same-origin URL when TAB is double-encoded as %2509', () => {
    +        middleware(createMockRequest('/en/%2509/some-page'));
    +        expect(MockedNextResponse.next).not.toHaveBeenCalled();
    +        expect(MockedNextResponse.rewrite).not.toHaveBeenCalled();
    +        expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe(
    +          'http://localhost:3000/%09/some-page'
    +        );
    +      });
    +    });
    +
         it('redirects requests for the default locale when prefixed at sub paths', () => {
           middleware(createMockRequest('/en/about'));
           expect(MockedNextResponse.next).not.toHaveBeenCalled();
    
  • packages/next-intl/src/middleware/utils.test.tsx+41 1 modified
    @@ -4,9 +4,49 @@ import {
       getInternalTemplate,
       getNormalizedPathname,
       getPathnameMatch,
    -  getRouteParams
    +  getRouteParams,
    +  sanitizePathname
     } from './utils.js';
     
    +describe('sanitizePathname', () => {
    +  it('leaves normal pathnames unchanged', () => {
    +    expect(sanitizePathname('/en/about')).toBe('/en/about');
    +    expect(sanitizePathname('/ja/%E7%B4%84')).toBe('/ja/%E7%B4%84');
    +    expect(sanitizePathname('/')).toBe('/');
    +  });
    +
    +  it('encodes backslashes to prevent scheme-relative redirect via \\host', () => {
    +    expect(sanitizePathname('/en/\\example.org')).toBe('/en/%5Cexample.org');
    +    expect(sanitizePathname('/en/%5Cexample.org')).toBe('/en/%5Cexample.org');
    +  });
    +
    +  it('collapses consecutive slashes to prevent scheme-relative redirect via //host', () => {
    +    expect(sanitizePathname('/en////example.org')).toBe('/en/example.org');
    +    expect(sanitizePathname('//example.org')).toBe('/example.org');
    +  });
    +
    +  it('strips TAB (U+0009) to prevent //host collapse', () => {
    +    expect(sanitizePathname('/en/\t/example.org')).toBe('/en/example.org');
    +    expect(sanitizePathname('\t//example.org')).toBe('/example.org');
    +  });
    +
    +  it('strips LF (U+000A)', () => {
    +    expect(sanitizePathname('/en/\n/example.org')).toBe('/en/example.org');
    +  });
    +
    +  it('strips CR (U+000D)', () => {
    +    expect(sanitizePathname('/en/\r/example.org')).toBe('/en/example.org');
    +  });
    +
    +  it('strips multiple whitespace characters in combination', () => {
    +    expect(sanitizePathname('/en/\t\r\n/example.org')).toBe('/en/example.org');
    +  });
    +
    +  it('applies replacements in the correct order: backslash before slash collapse', () => {
    +    expect(sanitizePathname('/en\\/example.org')).toBe('/en%5C/example.org');
    +  });
    +});
    +
     describe('getNormalizedPathname', () => {
       it('should return the normalized pathname', () => {
         function getResult(pathname: string) {
    
  • packages/next-intl/src/middleware/utils.tsx+14 3 modified
    @@ -322,7 +322,18 @@ export function getLocaleAsPrefix<AppLocales extends Locales>(
     
     export function sanitizePathname(pathname: string) {
       // Sanitize malicious URIs, e.g.:
    -  // '/en/\\example.org → /en/%5C%5Cexample.org'
    -  // '/en////example.org → /en/example.org'
    -  return pathname.replace(/\\/g, '%5C').replace(/\/+/g, '/');
    +  // '/en/\\example.org'  → '/en/%5Cexample.org'     (backslash → %5C)
    +  // '/en/\t/example.org' → '/en/example.org'        (WHATWG-stripped TAB)
    +  // '/en/\n/example.org' → '/en/example.org'        (WHATWG-stripped LF)
    +  // '/en/\r/example.org' → '/en/example.org'        (WHATWG-stripped CR)
    +  // '/en////example.org' → '/en/example.org'        (consecutive slashes)
    +  //
    +  // U+0009/000A/000D are silently stripped by the WHATWG URL parser
    +  // (https://url.spec.whatwg.org/#concept-url-parser). Without removing
    +  // them here, a decoded TAB in a segment separator position causes
    +  // new URL("/\t/host", base) to collapse to "//host" → open redirect.
    +  return pathname
    +    .replace(/\\/g, '%5C')
    +    .replace(/[\t\n\r]/g, '')
    +    .replace(/\/+/g, '/');
     }
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.