Allow CORS for the same root domain (#4262)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Greg Bergé
2026-05-19 14:41:07 +02:00
committed by GitHub
parent 8b6dc6dbbd
commit 0c577a00de
5 changed files with 233 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Allow CORS for the same root domain
+5
View File
@@ -188,6 +188,7 @@
"shiki": "^3.21.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"tldts": "^7.0.30",
"unified": "^11.0.5",
"unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0",
@@ -3316,6 +3317,10 @@
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
+1
View File
@@ -80,6 +80,7 @@
"shiki": "^3.21.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"tldts": "^7.0.30",
"unified": "^11.0.5",
"unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0",
+61
View File
@@ -47,6 +47,7 @@ import {
import { waitUntil } from '@/lib/waitUntil';
import { serveResizedImage } from '@/routes/image';
import Negotiator from 'negotiator';
import { getDomain } from 'tldts';
import {
type ServerInsightsEventInput,
serveProxyAnalyticsEvent,
@@ -501,6 +502,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
response.headers.set('x-gitbook-route-type', routeType);
response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol);
// Allow cross-origin requests from the same parent domain as the site.
const allowedOrigin = getAllowedCORSOrigin(request, siteCanonicalURL);
if (allowedOrigin) {
response.headers.set('access-control-allow-origin', allowedOrigin);
response.headers.set('access-control-allow-credentials', 'true');
}
// AI related headers
// This one is technically useless, but is used by a bunch of scoring systems
response.headers.set(
@@ -868,6 +876,59 @@ async function writeResponseCookies<R extends NextResponse>(
return response;
}
/**
* Registrable domains where customer sites are hosted side-by-side on different
* subdomains. For these, parent/sibling-subdomain CORS would let one customer site
* read another, so only an exact hostname match is allowed.
*/
const SHARED_REGISTRABLE_DOMAINS = new Set(['gitbook.io']);
/**
* Get the allowed CORS origin for a request to a site.
*
* For a site on a customer domain like `foo.example.com`, requests from
* `example.com` or any `*.example.com` subdomain are allowed. Public-suffix-aware
* (via `tldts`) so multi-label suffixes like `co.uk` are handled correctly. For
* sites on a shared GitBook hosting domain (see {@link SHARED_REGISTRABLE_DOMAINS}),
* only the exact hostname is allowed.
*/
function getAllowedCORSOrigin(request: NextRequest, siteCanonicalURL: URL): string | null {
const origin = request.headers.get('origin');
if (!origin) {
return null;
}
let originURL: URL;
try {
originURL = new URL(origin);
} catch {
return null;
}
const siteHostname = siteCanonicalURL.hostname.toLowerCase();
const originHostname = originURL.hostname.toLowerCase();
// Exact match is always allowed.
if (originHostname === siteHostname) {
return origin;
}
// Compare on the registrable domain (eTLD+1) so multi-label public suffixes
// like `co.uk` don't allow unrelated registrants to claim each other.
const siteRegistrable = getDomain(siteHostname);
const originRegistrable = getDomain(originHostname);
if (!siteRegistrable || siteRegistrable !== originRegistrable) {
return null;
}
// On shared hosting registrable domains, only exact-match (handled above) is allowed.
if (SHARED_REGISTRABLE_DOMAINS.has(siteRegistrable)) {
return null;
}
return origin;
}
function acceptsMarkdown(request: Request): boolean {
const acceptHeader = request.headers.get('accept') || '';
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'bun:test';
import { getContentTestURL } from './utils';
describe('CORS', () => {
// On the shared GitBook hosting domain `gitbook.io`, customer sites live on
// different subdomains side-by-side, so CORS must be locked to exact-hostname
// matches only. A sibling subdomain or the bare parent must NOT be allowed.
describe('on a shared gitbook.io hosting domain', () => {
const TEST_URL = getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open');
it('should allow a request from the exact same hostname', async () => {
const origin = 'https://gitbook.gitbook.io';
const response = await fetch(TEST_URL, {
headers: { Origin: origin },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBe(origin);
expect(response.headers.get('access-control-allow-credentials')).toBe('true');
});
it('should NOT allow a sibling subdomain on the shared domain', async () => {
const response = await fetch(TEST_URL, {
headers: { Origin: 'https://docs.gitbook.io' },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
expect(response.headers.get('access-control-allow-credentials')).toBeNull();
});
it('should NOT allow the bare shared parent domain (gitbook.io)', async () => {
const response = await fetch(TEST_URL, {
headers: { Origin: 'https://gitbook.io' },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
expect(response.headers.get('access-control-allow-credentials')).toBeNull();
});
it('should NOT allow a deeper subdomain on the shared domain', async () => {
const response = await fetch(TEST_URL, {
headers: { Origin: 'https://foo.bar.gitbook.io' },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
});
it('should reject an unrelated origin', async () => {
const response = await fetch(TEST_URL, {
headers: { Origin: 'https://evil.example.com' },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
});
it('should not set CORS headers when no Origin is provided', async () => {
const response = await fetch(TEST_URL);
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
expect(response.headers.get('access-control-allow-credentials')).toBeNull();
});
});
// On a customer custom domain with 3+ labels, requests from the parent
// domain, sibling subdomains and deeper subdomains are all allowed.
describe('on a customer custom domain (docs.snyk.io)', () => {
const TEST_URL = getContentTestURL('https://docs.snyk.io');
it('should allow a request from the exact same hostname', async () => {
const origin = 'https://docs.snyk.io';
const response = await fetch(TEST_URL, {
headers: { Origin: origin },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBe(origin);
expect(response.headers.get('access-control-allow-credentials')).toBe('true');
});
it('should allow a request from a sibling subdomain', async () => {
const origin = 'https://learn.snyk.io';
const response = await fetch(TEST_URL, {
headers: { Origin: origin },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBe(origin);
});
it('should allow a request from the bare parent domain', async () => {
const origin = 'https://snyk.io';
const response = await fetch(TEST_URL, {
headers: { Origin: origin },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBe(origin);
});
it('should reject an unrelated origin', async () => {
const response = await fetch(TEST_URL, {
headers: { Origin: 'https://evil.example.com' },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
});
it('should reject a hostname that only suffix-matches the parent', async () => {
// `notsnyk.io` ends with `snyk.io` as a string but is not a subdomain of `snyk.io`.
const response = await fetch(TEST_URL, {
headers: { Origin: 'https://notsnyk.io' },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
});
});
// On a 2-label hostname like `gitbook.com`, the registrable domain is the
// hostname itself, so subdomains under it share the same registrable domain
// and are allowed.
describe('on a 2-label hostname (gitbook.com)', () => {
const TEST_URL = getContentTestURL('https://gitbook.com/docs');
it('should allow a request from the exact same hostname', async () => {
const origin = 'https://gitbook.com';
const response = await fetch(TEST_URL, {
headers: { Origin: origin },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBe(origin);
expect(response.headers.get('access-control-allow-credentials')).toBe('true');
});
it('should allow a subdomain (same registrable domain)', async () => {
const origin = 'https://docs.gitbook.com';
const response = await fetch(TEST_URL, {
headers: { Origin: origin },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBe(origin);
});
it('should reject a different registrable domain', async () => {
const response = await fetch(TEST_URL, {
headers: { Origin: 'https://evil.example.com' },
});
expect(response.status).toBe(200);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
});
});
});