diff --git a/.changeset/fast-kiwis-rush.md b/.changeset/fast-kiwis-rush.md new file mode 100644 index 000000000..e8276eb63 --- /dev/null +++ b/.changeset/fast-kiwis-rush.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Allow CORS for the same root domain diff --git a/bun.lock b/bun.lock index 853d33ab0..5ce64721d 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index ca845e860..d192e4770 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -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", diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 63db52b93..b3fa5f5bb 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -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( 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') || ''; diff --git a/packages/gitbook/tests/cors.test.ts b/packages/gitbook/tests/cors.test.ts new file mode 100644 index 000000000..5d6f88f78 --- /dev/null +++ b/packages/gitbook/tests/cors.test.ts @@ -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(); + }); + }); +});