Return 404 instead of looping on missing non-ASCII page paths (#4631)

This commit is contained in:
Peter White
2026-09-23 16:20:50 +02:00
committed by GitHub
parent 581abe0bb7
commit bdc2497771
5 changed files with 107 additions and 5 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Missing pages with accented or non-Latin characters in the URL now return 404 instead of an endless redirect.
@@ -11,7 +11,12 @@ import {
import { IconsProvider } from '@gitbook/icons';
import { PageContextProvider } from '../PageContext';
import { type PagePathParams, fetchPageData, getPathnameParam } from './fetch';
import {
type PagePathParams,
fetchPageData,
getLowercasePathnameRedirect,
getPathnameParam,
} from './fetch';
import { PageClientLayout } from './PageClientLayout';
import { UpdatesFilterProvider } from '@/components/DocumentView/UpdatesFilter';
import { UpdatesFilterScript } from '@/components/DocumentView/UpdatesFilterScript';
@@ -277,8 +282,8 @@ export async function getSitePageData(props: SitePageProps) {
const rawPathname = getPathnameParam(props.pageParams);
if (!pageTarget) {
const pathname = rawPathname.toLowerCase();
if (pathname !== rawPathname) {
const pathname = getLowercasePathnameRedirect(rawPathname);
if (pathname !== null) {
// If the pathname was not normalized, redirect to the normalized version
// before trying to resolve the page again
redirect(context.linker.toPathInSpace(pathname));
@@ -6,7 +6,8 @@ import type { GitBookSiteContext } from '@/lib/context';
mock.module('server-only', () => ({}));
const { fetchPageData } = await import('./fetch');
const { fetchPageData, getLowercasePathnameRedirect } = await import('./fetch');
const { normalizeURL } = await import('@/lib/data/urls');
const page = {
id: 'page-1',
@@ -92,3 +93,56 @@ describe('fetchPageData', () => {
expect(result.pageTarget?.page.git).toBeUndefined();
});
});
describe('getLowercasePathnameRedirect', () => {
it('redirects ASCII paths with uppercase letters', () => {
expect(getLowercasePathnameRedirect('Foo/Bar')).toBe('foo/bar');
});
it('does not redirect lowercase paths', () => {
expect(getLowercasePathnameRedirect('foo/cafe')).toBeNull();
});
it('does not redirect when only percent-encoded hex digits are uppercase', () => {
expect(getLowercasePathnameRedirect('foo/caf%C3%A9')).toBeNull();
expect(getLowercasePathnameRedirect('foo/caf%c3%a9')).toBeNull();
});
it('lowercases encoded non-ASCII letters', () => {
// É -> é
expect(getLowercasePathnameRedirect('foo/%C3%89')).toBe('foo/%C3%A9');
});
it('does not redirect paths that fail to decode', () => {
expect(getLowercasePathnameRedirect('Foo/%E0%A4%A')).toBeNull();
});
it('redirects to a pathname the middleware leaves unchanged', () => {
const normalize = (pathname: string) =>
normalizeURL(new URL(`https://example.com/${pathname}`)).pathname.slice(1);
const paths = [
'Foo/Bar',
'Foo/caf%C3%A9',
'foo/%C3%89',
'Video/porte%C3%91o-x.html',
'%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82', // Привет
'%CE%95%CE%BB%CE%BB%CE%AC%CE%B4%CE%B1', // Ελλάδα
'%C3%96sterreich/Stra%C3%9FE', // Österreich/StraßE
'%C4%B0stanbul', // İstanbul, lowercases to two code points
'Foo:Bar',
'Foo@Bar+Baz',
'Brack[et]',
'Q%3FX',
'Hash%23Y',
'Sp%20Ace',
];
for (const path of paths) {
const target = getLowercasePathnameRedirect(path);
expect(target).not.toBeNull();
expect(normalize(target!)).toBe(target!);
expect(getLowercasePathnameRedirect(target!)).toBeNull();
}
});
});
@@ -184,3 +184,41 @@ export function getPathnameParam(params: PagePathParams): string {
return pathname.map((part) => decodeURIComponent(part)).join('/');
}
/**
* Get the lowercased pathname to redirect a missing page to, or `null` if there is none.
* The pathname is percent-encoded, so lowercase its decoded form and re-encode it canonically:
* any other encoding would make the middleware redirect again, or loop.
*/
export function getLowercasePathnameRedirect(rawPathname: string): string | null {
let changed = false;
const segments: string[] = [];
for (const segment of rawPathname.split('/')) {
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
return null;
}
const lowercased = decoded.toLowerCase();
changed ||= lowercased !== decoded;
segments.push(lowercased);
}
if (!changed) {
return null;
}
return encodeURLPathname(segments.join('/')).slice(1);
}
/**
* Percent-encode a decoded pathname the way the URL parser does, the canonical form `normalizeURL` produces.
*/
function encodeURLPathname(pathname: string): string {
const url = new URL('https://gitbook.invalid');
url.pathname = pathname;
return url.pathname;
}
+1 -1
View File
@@ -233,7 +233,7 @@ export function normalizeRequestURL(url: URL): Response | null {
/**
* Normalize a URL to remove duplicate slashes and trailing slashes
* and transform the pathname to lowercase.
* and decode the pathname to its canonical encoding.
*/
export function normalizeURL(url: URL) {
const result = new URL(url);