From 887c3f8eacfe6f06c7491abe2b76f36ab976fbc2 Mon Sep 17 00:00:00 2001 From: "Nolann B." <100787331+nolannbiron@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:50:43 +0100 Subject: [PATCH] Add token verification on OpenAPI proxy (#4085) --- .changeset/tame-grapes-wash.md | 6 + .../DocumentView/OpenAPI/context.tsx | 9 +- .../src/lib/openapi/proxy-token.test.ts | 130 +++++++ .../gitbook/src/lib/openapi/proxy-token.ts | 100 +++++ .../gitbook/src/routes/openapi-proxy.test.ts | 344 +++++++----------- packages/gitbook/src/routes/openapi-proxy.ts | 43 ++- .../react-openapi/src/OpenAPICodeSample.tsx | 36 +- packages/react-openapi/src/context.ts | 11 +- packages/react-openapi/src/index.ts | 1 + packages/react-openapi/src/util/server.ts | 31 ++ 10 files changed, 490 insertions(+), 221 deletions(-) create mode 100644 .changeset/tame-grapes-wash.md create mode 100644 packages/gitbook/src/lib/openapi/proxy-token.test.ts create mode 100644 packages/gitbook/src/lib/openapi/proxy-token.ts diff --git a/.changeset/tame-grapes-wash.md b/.changeset/tame-grapes-wash.md new file mode 100644 index 000000000..c8e9df0a0 --- /dev/null +++ b/.changeset/tame-grapes-wash.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-openapi": patch +"gitbook": patch +--- + +Add token verification on OpenAPI proxy diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx index 6e888d9c2..4a96be843 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx @@ -12,6 +12,7 @@ import { Heading } from '../Heading'; import './style.css'; import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server'; import type { GitBookAnyContext } from '@/lib/context'; +import { buildSignedProxyUrl } from '@/lib/openapi/proxy-token'; import type { AnyOpenAPIOperationsBlock, OpenAPISchemasBlock, @@ -32,11 +33,15 @@ export function getOpenAPIContext(args: { const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE; const locale = checkIsValidLocale(customizationLocale) ? customizationLocale : DEFAULT_LOCALE; - const proxyUrl = context ? context.linker.toPathInSite('~scalar/proxy') : undefined; + const proxyUrl = context + ? context.linker.toAbsoluteURL(context.linker.toPathInSite('~scalar/proxy')) + : undefined; return { specUrl, - proxyUrl, + resolveProxyUrl: proxyUrl + ? (allowedOrigins: string[]) => buildSignedProxyUrl(proxyUrl, allowedOrigins) + : undefined, icons: { chevronDown: , chevronRight: , diff --git a/packages/gitbook/src/lib/openapi/proxy-token.test.ts b/packages/gitbook/src/lib/openapi/proxy-token.test.ts new file mode 100644 index 000000000..a42f3befc --- /dev/null +++ b/packages/gitbook/src/lib/openapi/proxy-token.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, mock } from 'bun:test'; + +mock.module('@/lib/env/globals', () => ({ GITBOOK_SECRET: 'test-secret-key' })); + +const { buildSignedProxyUrl, verifyProxyRequest } = await import('./proxy-token'); + +describe('buildSignedProxyUrl', () => { + it('returns null for empty hosts', () => { + expect(buildSignedProxyUrl('http://localhost/proxy', [])).toBeNull(); + }); + + it('builds a URL with allowed_origin and token params', () => { + const result = buildSignedProxyUrl('http://localhost/proxy', ['api.example.com']); + expect(result).not.toBeNull(); + + // biome-ignore lint/style/noNonNullAssertion: test assertion + const url = new URL(result!); + expect(url.searchParams.getAll('allowed_origin')).toEqual(['api.example.com']); + expect(url.searchParams.get('token')).toBeTruthy(); + }); + + it('appends params with & when base URL already has query params', () => { + const result = buildSignedProxyUrl('http://localhost/proxy?existing=1', [ + 'api.example.com', + ]); + expect(result).toContain('?existing=1&'); + }); + + it('deduplicates and sorts hosts', () => { + const result = buildSignedProxyUrl('http://localhost/proxy', [ + 'b.example.com', + 'a.example.com', + 'b.example.com', + ]); + // biome-ignore lint/style/noNonNullAssertion: test assertion + const url = new URL(result!); + expect(url.searchParams.getAll('allowed_origin')).toEqual([ + 'a.example.com', + 'b.example.com', + ]); + }); +}); + +describe('verifyProxyRequest', () => { + it('rejects when no token is provided', () => { + const params = new URLSearchParams(); + const result = verifyProxyRequest(params, 'https://api.example.com'); + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toBe('Missing proxy authorization token'); + } + }); + + it('rejects when token is invalid', () => { + const params = new URLSearchParams(); + params.set('allowed_origin', 'api.example.com'); + params.set('token', 'invalid-token'); + const result = verifyProxyRequest(params, 'https://api.example.com/v1/users'); + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toBe('Invalid proxy authorization token'); + } + }); + + it('rejects when target is not in the allowed origins', () => { + // biome-ignore lint/style/noNonNullAssertion: test assertion + const signed = buildSignedProxyUrl('http://localhost/proxy', ['api.example.com'])!; + const params = new URL(signed).searchParams; + const result = verifyProxyRequest(params, 'https://evil.com/hack'); + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toBe('Target URL is not in the allowed origins'); + } + }); + + it('allows when token is valid and host matches', () => { + // biome-ignore lint/style/noNonNullAssertion: test assertion + const signed = buildSignedProxyUrl('http://localhost/proxy', ['api.example.com'])!; + const params = new URL(signed).searchParams; + const result = verifyProxyRequest(params, 'https://api.example.com/v1/users'); + expect(result.allowed).toBe(true); + if (result.allowed) { + expect(result.allowedOrigins).toEqual(['api.example.com']); + } + }); + + it('allows any protocol on an allowed host', () => { + // biome-ignore lint/style/noNonNullAssertion: test assertion + const signed = buildSignedProxyUrl('http://localhost/proxy', ['api.example.com'])!; + const params = new URL(signed).searchParams; + expect(verifyProxyRequest(params, 'https://api.example.com/path').allowed).toBe(true); + expect(verifyProxyRequest(params, 'http://api.example.com/path').allowed).toBe(true); + }); + + it('supports multiple allowed hosts', () => { + const hosts = ['api.example.com', 'cdn.example.com']; + // biome-ignore lint/style/noNonNullAssertion: test assertion + const signed = buildSignedProxyUrl('http://localhost/proxy', hosts)!; + const params = new URL(signed).searchParams; + + expect(verifyProxyRequest(params, 'https://api.example.com/v1').allowed).toBe(true); + expect(verifyProxyRequest(params, 'https://cdn.example.com/spec.json').allowed).toBe(true); + expect(verifyProxyRequest(params, 'https://other.com').allowed).toBe(false); + }); + + it('rejects a forged token with tampered hosts', () => { + // biome-ignore lint/style/noNonNullAssertion: test assertion + const signed = buildSignedProxyUrl('http://localhost/proxy', ['api.example.com'])!; + const url = new URL(signed); + + // Tamper with the allowed origins but keep the original token + url.searchParams.delete('allowed_origin'); + url.searchParams.append('allowed_origin', 'evil.com'); + + const result = verifyProxyRequest(url.searchParams, 'https://evil.com/hack'); + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toBe('Invalid proxy authorization token'); + } + }); + + it('checks path prefix when origin includes a path', () => { + // biome-ignore lint/style/noNonNullAssertion: test assertion + const signed = buildSignedProxyUrl('http://localhost/proxy', ['api.example.com/v1'])!; + const params = new URL(signed).searchParams; + + expect(verifyProxyRequest(params, 'https://api.example.com/v1/users').allowed).toBe(true); + expect(verifyProxyRequest(params, 'https://api.example.com/v2/users').allowed).toBe(false); + }); +}); diff --git a/packages/gitbook/src/lib/openapi/proxy-token.ts b/packages/gitbook/src/lib/openapi/proxy-token.ts new file mode 100644 index 000000000..065cad717 --- /dev/null +++ b/packages/gitbook/src/lib/openapi/proxy-token.ts @@ -0,0 +1,100 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { GITBOOK_SECRET } from '@/lib/env/globals'; +import { extractOrigin } from '@gitbook/react-openapi'; + +/** + * Sign a list of allowed origins for the OpenAPI proxy. + * Returns null if no signing key is available. + */ +function signOrigins(origins: string[]): string | null { + if (!GITBOOK_SECRET) { + return null; + } + const payload = origins.sort().join('\n'); + return createHmac('sha256', GITBOOK_SECRET).update(payload).digest('hex'); +} + +/** + * Verify a proxy token signature against the allowed origins. + */ +function verifySignature(origins: string[], signature: string): boolean { + const expected = signOrigins(origins); + if (!expected || expected.length !== signature.length) { + return false; + } + return timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); +} + +/** + * Build a signed proxy URL that restricts which origins can be proxied. + * Returns null if no signing key is configured (proxy should be disabled). + */ +export function buildSignedProxyUrl(baseProxyUrl: string, allowedOrigins: string[]): string | null { + const origins = deduplicateAndSort(allowedOrigins); + if (origins.length === 0) { + return null; + } + + const signature = signOrigins(origins); + if (!signature) { + return null; + } + + const url = new URL(baseProxyUrl); + for (const origin of origins) { + url.searchParams.append('allowed_origin', origin); + } + url.searchParams.set('token', signature); + + return url.toString(); +} + +/** + * Verify the proxy request's signed token and check that the target URL's + * origin is allowed by the signed origins. + */ +export function verifyProxyRequest( + searchParams: URLSearchParams, + targetUrl: string +): { allowed: true; allowedOrigins: string[] } | { allowed: false; reason: string } { + if (!GITBOOK_SECRET) { + return { allowed: false, reason: 'Proxy is disabled: no signing key configured' }; + } + + const allowedOrigins = searchParams.getAll('allowed_origin'); + const token = searchParams.get('token'); + + if (allowedOrigins.length === 0 || !token) { + return { allowed: false, reason: 'Missing proxy authorization token' }; + } + + const sorted = deduplicateAndSort(allowedOrigins); + if (!verifySignature(sorted, token)) { + return { allowed: false, reason: 'Invalid proxy authorization token' }; + } + + // Check that the target URL's host+path matches one of the allowed entries + if (!isAllowedByOrigins(targetUrl, sorted)) { + return { + allowed: false, + reason: 'Target URL is not in the allowed origins', + }; + } + + return { allowed: true, allowedOrigins: sorted }; +} + +/** + * Check if a URL's host+path matches one of the allowed origin entries. + */ +export function isAllowedByOrigins(url: string, allowedOrigins: string[]): boolean { + const hostAndPath = extractOrigin(url); + if (!hostAndPath) { + return false; + } + return allowedOrigins.some((allowed) => hostAndPath.startsWith(allowed)); +} + +function deduplicateAndSort(values: string[]): string[] { + return [...new Set(values)].sort(); +} diff --git a/packages/gitbook/src/routes/openapi-proxy.test.ts b/packages/gitbook/src/routes/openapi-proxy.test.ts index 40f0b7bdc..05afb43d0 100644 --- a/packages/gitbook/src/routes/openapi-proxy.test.ts +++ b/packages/gitbook/src/routes/openapi-proxy.test.ts @@ -1,19 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; -// Mock DNS resolution before importing the proxy module const mockDnsLookup = mock(() => Promise.resolve([{ address: '93.184.215.14', family: 4 }])); mock.module('node:dns/promises', () => ({ lookup: mockDnsLookup })); +mock.module('@/lib/env/globals', () => ({ GITBOOK_SECRET: 'test-secret-key' })); import { NextRequest } from 'next/server'; -import { - handleOpenAPIProxyOptions, - handleOpenAPIProxyRequest, - isBlockedHost, -} from './openapi-proxy'; +const { buildSignedProxyUrl } = await import('@/lib/openapi/proxy-token'); +const { handleOpenAPIProxyOptions, handleOpenAPIProxyRequest, isBlockedHost } = await import( + './openapi-proxy' +); const originalFetch = globalThis.fetch; +function signedProxyUrl(targetUrl: string, extraHosts?: string[]): string { + const hostname = new URL(targetUrl).hostname; + const hosts = [hostname, ...(extraHosts ?? [])]; + const signed = buildSignedProxyUrl('http://localhost/~scalar/proxy', hosts); + return `${signed}&scalar_url=${encodeURIComponent(targetUrl)}`; +} + function createRequest( url: string, options?: { method?: string; headers?: Record; body?: string } @@ -27,50 +33,40 @@ function createRequest( function getForwardedHeaders(): Headers { const calls = (globalThis.fetch as ReturnType).mock.calls; - // biome-ignore lint/style/noNonNullAssertion: test helper, call is guaranteed + // biome-ignore lint/style/noNonNullAssertion: test helper return calls[0]![1].headers as Headers; } +async function expectJsonError(res: Response, status: number, error: string) { + expect(res.status).toBe(status); + expect(((await res.json()) as { error: string }).error).toBe(error); +} + describe('isBlockedHost', () => { - it('blocks localhost IP', async () => { - expect(await isBlockedHost('127.0.0.1')).toBe(true); + it('blocks private and reserved IPs', async () => { + for (const ip of ['127.0.0.1', '10.0.0.1', '172.16.0.1', '192.168.1.1', '::1']) { + expect(await isBlockedHost(ip)).toBe(true); + } }); - it('blocks private 10.x range', async () => { - expect(await isBlockedHost('10.0.0.1')).toBe(true); + it('blocks cloud metadata and multicast/reserved ranges', async () => { + for (const ip of ['169.254.169.254', '224.0.0.1', '240.0.0.1', '255.255.255.255']) { + expect(await isBlockedHost(ip)).toBe(true); + } }); - it('blocks private 172.16.x range', async () => { - expect(await isBlockedHost('172.16.0.1')).toBe(true); - }); - - it('blocks private 192.168.x range', async () => { - expect(await isBlockedHost('192.168.1.1')).toBe(true); - }); - - it('blocks link-local 169.254.x (cloud metadata)', async () => { - expect(await isBlockedHost('169.254.169.254')).toBe(true); - }); - - it('blocks IPv6 loopback', async () => { - expect(await isBlockedHost('::1')).toBe(true); - }); - - it('blocks multicast range (224.0.0.0/4)', async () => { - expect(await isBlockedHost('224.0.0.1')).toBe(true); - expect(await isBlockedHost('239.255.255.255')).toBe(true); - }); - - it('blocks reserved range (240.0.0.0/4) and broadcast', async () => { - expect(await isBlockedHost('240.0.0.1')).toBe(true); - expect(await isBlockedHost('255.255.255.255')).toBe(true); + it('blocks IPv4-mapped IPv6 with private IPs', async () => { + for (const ip of ['::ffff:127.0.0.1', '::ffff:10.0.0.1', '::ffff:169.254.169.254']) { + expect(await isBlockedHost(ip)).toBe(true); + } }); it('allows public IPs', async () => { expect(await isBlockedHost('93.184.215.14')).toBe(false); + expect(await isBlockedHost('::ffff:93.184.215.14')).toBe(false); }); - it('resolves hostnames via DNS and checks the result', async () => { + it('blocks when DNS resolves to a private IP', async () => { mockDnsLookup.mockResolvedValueOnce([{ address: '10.0.0.1', family: 4 }]); expect(await isBlockedHost('evil.example.com')).toBe(true); }); @@ -79,17 +75,6 @@ describe('isBlockedHost', () => { mockDnsLookup.mockRejectedValueOnce(new Error('ENOTFOUND')); expect(await isBlockedHost('nonexistent.invalid')).toBe(true); }); - - it('blocks IPv4-mapped IPv6 addresses with private IPv4', async () => { - expect(await isBlockedHost('::ffff:127.0.0.1')).toBe(true); - expect(await isBlockedHost('::ffff:10.0.0.1')).toBe(true); - expect(await isBlockedHost('::ffff:192.168.1.1')).toBe(true); - expect(await isBlockedHost('::ffff:169.254.169.254')).toBe(true); - }); - - it('allows IPv4-mapped IPv6 addresses with public IPv4', async () => { - expect(await isBlockedHost('::ffff:93.184.215.14')).toBe(false); - }); }); describe('handleOpenAPIProxyRequest', () => { @@ -112,63 +97,60 @@ describe('handleOpenAPIProxyRequest', () => { }); it('returns 400 when scalar_url is missing', async () => { - const req = createRequest('http://localhost/~scalar/proxy'); - const res = await handleOpenAPIProxyRequest(req); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('Missing required query parameter: scalar_url'); - }); - - it('returns 400 for an invalid URL', async () => { - const req = createRequest('http://localhost/~scalar/proxy?scalar_url=not-a-url'); - const res = await handleOpenAPIProxyRequest(req); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('Invalid URL provided in scalar_url parameter'); - }); - - it('returns 400 for non-HTTP protocols', async () => { - const req = createRequest('http://localhost/~scalar/proxy?scalar_url=ftp://example.com'); - const res = await handleOpenAPIProxyRequest(req); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('Only HTTP and HTTPS URLs are supported'); - }); - - it('returns 403 for private IPs', async () => { - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=http://169.254.169.254/latest/meta-data' + const res = await handleOpenAPIProxyRequest( + createRequest('http://localhost/~scalar/proxy') ); - const res = await handleOpenAPIProxyRequest(req); + await expectJsonError(res, 400, 'Missing required query parameter: scalar_url'); + }); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('Forbidden: access to private addresses is not allowed'); + it('returns 403 when no signed token is provided', async () => { + const res = await handleOpenAPIProxyRequest( + createRequest('http://localhost/~scalar/proxy?scalar_url=https://api.example.com') + ); + await expectJsonError(res, 403, 'Missing proxy authorization token'); + }); + + it('returns 403 when token is invalid', async () => { + const res = await handleOpenAPIProxyRequest( + createRequest( + 'http://localhost/~scalar/proxy?scalar_url=https://api.example.com&allowed_origin=api.example.com&token=bad-token' + ) + ); + await expectJsonError(res, 403, 'Invalid proxy authorization token'); + }); + + it('returns 403 when target host is not in the allowed list', async () => { + const signed = buildSignedProxyUrl('http://localhost/~scalar/proxy', ['api.example.com']); + const res = await handleOpenAPIProxyRequest( + createRequest(`${signed}&scalar_url=${encodeURIComponent('https://evil.com/hack')}`) + ); + await expectJsonError(res, 403, 'Target URL is not in the allowed origins'); + }); + + it('returns 403 for private IPs even with valid token', async () => { + const res = await handleOpenAPIProxyRequest( + createRequest(signedProxyUrl('http://169.254.169.254/latest/meta-data')) + ); + await expectJsonError(res, 403, 'Forbidden: access to private addresses is not allowed'); }); it('returns 403 when hostname resolves to a private IP', async () => { mockDnsLookup.mockResolvedValueOnce([{ address: '10.0.0.1', family: 4 }]); - - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=https://internal.example.com' + const res = await handleOpenAPIProxyRequest( + createRequest(signedProxyUrl('https://internal.example.com')) ); - const res = await handleOpenAPIProxyRequest(req); - expect(res.status).toBe(403); }); it('forwards the request to the target URL', async () => { const target = 'https://api.example.com/v1/users'; - const req = createRequest(`http://localhost/~scalar/proxy?scalar_url=${target}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: '{"name":"test"}', - }); - - await handleOpenAPIProxyRequest(req); + await handleOpenAPIProxyRequest( + createRequest(signedProxyUrl(target), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"name":"test"}', + }) + ); expect(globalThis.fetch).toHaveBeenCalledTimes(1); const calls = (globalThis.fetch as ReturnType).mock.calls; @@ -178,97 +160,40 @@ describe('handleOpenAPIProxyRequest', () => { expect(calledOptions.method).toBe('POST'); }); - it('strips request headers that should not be forwarded', async () => { - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=https://api.example.com', - { + it('strips forbidden request headers and remaps scalar headers', async () => { + await handleOpenAPIProxyRequest( + createRequest(signedProxyUrl('https://api.example.com/v1'), { headers: { origin: 'http://localhost:3000', referer: 'http://localhost:3000/docs', 'x-forwarded-for': '127.0.0.1', accept: 'application/json', + 'x-scalar-cookie': 'session=abc123', + 'x-scalar-user-agent': 'ScalarClient/1.0', }, - } + }) ); - await handleOpenAPIProxyRequest(req); - const headers = getForwardedHeaders(); + // Stripped expect(headers.get('origin')).toBeNull(); expect(headers.get('referer')).toBeNull(); expect(headers.get('x-forwarded-for')).toBeNull(); + // Kept expect(headers.get('accept')).toBe('application/json'); + // Remapped + expect(headers.get('cookie')).toBe('session=abc123'); + expect(headers.get('user-agent')).toBe('ScalarClient/1.0'); + // Host set to target + expect(headers.get('host')).toBe('api.example.com'); }); - it('converts X-Scalar-Cookie to cookie header', async () => { - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=https://api.example.com', - { headers: { 'x-scalar-cookie': 'session=abc123' } } - ); - - await handleOpenAPIProxyRequest(req); - expect(getForwardedHeaders().get('cookie')).toBe('session=abc123'); - }); - - it('converts X-Scalar-User-Agent to user-agent header', async () => { - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=https://api.example.com', - { headers: { 'x-scalar-user-agent': 'ScalarClient/1.0' } } - ); - - await handleOpenAPIProxyRequest(req); - expect(getForwardedHeaders().get('user-agent')).toBe('ScalarClient/1.0'); - }); - - it('sets the host header to the target host', async () => { - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=https://api.example.com/v1' - ); - - await handleOpenAPIProxyRequest(req); - expect(getForwardedHeaders().get('host')).toBe('api.example.com'); - }); - - it('adds CORS headers to the response', async () => { - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=https://api.example.com' - ); - const res = await handleOpenAPIProxyRequest(req); - - expect(res.headers.get('access-control-allow-origin')).toBe('*'); - expect(res.headers.get('access-control-allow-methods')).toBe('*'); - expect(res.headers.get('access-control-allow-headers')).toBe('*'); - }); - - it('strips upstream CORS headers and replaces with our own', async () => { + it('adds CORS headers and strips upstream CORS/transport headers', async () => { globalThis.fetch = mock(() => Promise.resolve( new Response('ok', { headers: { 'access-control-allow-origin': 'https://specific.example.com', - 'access-control-allow-methods': 'GET', - 'content-type': 'application/json', - }, - }) - ) - ); - - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=https://api.example.com' - ); - const res = await handleOpenAPIProxyRequest(req); - - // Upstream CORS headers replaced with permissive ones - expect(res.headers.get('access-control-allow-origin')).toBe('*'); - expect(res.headers.get('access-control-allow-methods')).toBe('*'); - expect(res.headers.get('content-type')).toBe('application/json'); - }); - - it('strips problematic response headers', async () => { - globalThis.fetch = mock(() => - Promise.resolve( - new Response('ok', { - headers: { 'content-encoding': 'gzip', 'transfer-encoding': 'chunked', 'content-type': 'application/json', @@ -277,31 +202,27 @@ describe('handleOpenAPIProxyRequest', () => { ) ); - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=https://api.example.com' + const res = await handleOpenAPIProxyRequest( + createRequest(signedProxyUrl('https://api.example.com')) ); - const res = await handleOpenAPIProxyRequest(req); + expect(res.headers.get('access-control-allow-origin')).toBe('*'); + expect(res.headers.get('access-control-allow-methods')).toBe('*'); + expect(res.headers.get('content-type')).toBe('application/json'); expect(res.headers.get('content-encoding')).toBeNull(); expect(res.headers.get('transfer-encoding')).toBeNull(); - expect(res.headers.get('content-type')).toBe('application/json'); }); - it('returns 502 when the upstream fetch fails', async () => { + it('returns 502 when upstream fetch fails', async () => { globalThis.fetch = mock(() => Promise.reject(new Error('Connection refused'))); - - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=https://api.example.com' + const res = await handleOpenAPIProxyRequest( + createRequest(signedProxyUrl('https://api.example.com')) ); - const res = await handleOpenAPIProxyRequest(req); - - expect(res.status).toBe(502); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('Failed to fetch from target URL'); + await expectJsonError(res, 502, 'Failed to fetch from target URL'); }); it('forwards upstream error responses transparently', async () => { - const errorBody = JSON.stringify({ message: 'Unauthorized', code: 'AUTH_REQUIRED' }); + const errorBody = JSON.stringify({ message: 'Unauthorized' }); globalThis.fetch = mock(() => Promise.resolve( new Response(errorBody, { @@ -311,18 +232,15 @@ describe('handleOpenAPIProxyRequest', () => { ) ); - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=https://api.example.com' + const res = await handleOpenAPIProxyRequest( + createRequest(signedProxyUrl('https://api.example.com')) ); - const res = await handleOpenAPIProxyRequest(req); - expect(res.status).toBe(401); expect(await res.text()).toBe(errorBody); - expect(res.headers.get('content-type')).toBe('application/json'); expect(res.headers.get('x-request-id')).toBe('abc-123'); }); - it('follows redirects and validates each target', async () => { + it('follows redirects within allowed hosts', async () => { let callCount = 0; globalThis.fetch = mock(() => { callCount++; @@ -330,51 +248,65 @@ describe('handleOpenAPIProxyRequest', () => { return Promise.resolve( new Response(null, { status: 302, - headers: { location: 'https://final.example.com/result' }, + headers: { location: 'https://api.example.com/redirected' }, }) ); } return Promise.resolve(new Response('final', { status: 200 })); }); - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=https://api.example.com' + const res = await handleOpenAPIProxyRequest( + createRequest(signedProxyUrl('https://api.example.com')) ); - const res = await handleOpenAPIProxyRequest(req); - expect(res.status).toBe(200); expect(await res.text()).toBe('final'); expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); - it('blocks redirects to private IPs', async () => { - globalThis.fetch = mock(() => - Promise.resolve( - new Response(null, { - status: 302, - headers: { location: 'http://169.254.169.254/latest/meta-data' }, - }) - ) - ); + it('blocks redirects to non-allowed hosts or private IPs', async () => { + for (const location of [ + 'https://evil.com/steal-data', + 'http://169.254.169.254/latest/meta-data', + ]) { + globalThis.fetch = mock(() => + Promise.resolve(new Response(null, { status: 302, headers: { location } })) + ); - const req = createRequest( - 'http://localhost/~scalar/proxy?scalar_url=https://api.example.com' - ); - const res = await handleOpenAPIProxyRequest(req); + const res = await handleOpenAPIProxyRequest( + createRequest(signedProxyUrl('https://api.example.com')) + ); + await expectJsonError(res, 502, 'Failed to fetch from target URL'); + } + }); - expect(res.status).toBe(502); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('Failed to fetch from target URL'); + it('allows redirects to a second allowed host', async () => { + let callCount = 0; + globalThis.fetch = mock(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve( + new Response(null, { + status: 302, + headers: { location: 'https://cdn.example.com/spec.json' }, + }) + ); + } + return Promise.resolve(new Response('from cdn', { status: 200 })); + }); + + const res = await handleOpenAPIProxyRequest( + createRequest(signedProxyUrl('https://api.example.com', ['cdn.example.com'])) + ); + expect(res.status).toBe(200); + expect(await res.text()).toBe('from cdn'); }); }); describe('handleOpenAPIProxyOptions', () => { it('returns 204 with CORS preflight headers', () => { const res = handleOpenAPIProxyOptions(); - expect(res.status).toBe(204); expect(res.headers.get('access-control-allow-origin')).toBe('*'); - expect(res.headers.get('access-control-allow-methods')).toBe('*'); expect(res.headers.get('access-control-max-age')).toBe('86400'); }); }); diff --git a/packages/gitbook/src/routes/openapi-proxy.ts b/packages/gitbook/src/routes/openapi-proxy.ts index 15c33674e..124157231 100644 --- a/packages/gitbook/src/routes/openapi-proxy.ts +++ b/packages/gitbook/src/routes/openapi-proxy.ts @@ -1,6 +1,8 @@ import { lookup } from 'node:dns/promises'; import { isIP } from 'node:net'; +import { isAllowedByOrigins, verifyProxyRequest } from '@/lib/openapi/proxy-token'; + import { type NextRequest, NextResponse } from 'next/server'; const MAX_REDIRECTS = 10; @@ -126,6 +128,14 @@ export async function handleOpenAPIProxyRequest(request: NextRequest): Promise controller.abort(), FETCH_TIMEOUT_MS); try { - const response = await fetchWithRedirectValidation(targetUrl, { - method: request.method, - headers: forwardedHeaders, - body: request.body, - signal: controller.signal, - // @ts-ignore - duplex is required for streaming request bodies - duplex: 'half', - }); + const response = await fetchWithRedirectValidation( + targetUrl, + { + method: request.method, + headers: forwardedHeaders, + body: request.body, + signal: controller.signal, + // @ts-ignore - duplex is required for streaming request bodies + duplex: 'half', + }, + allowedOrigins + ); // Build response headers, stripping transport headers and upstream CORS headers const responseHeaders = new Headers(); @@ -227,6 +241,7 @@ export async function handleOpenAPIProxyRequest(request: NextRequest): Promise { const response = await fetch(url, { ...options, redirect: 'manual' }); @@ -250,6 +265,11 @@ async function fetchWithRedirectValidation( throw new Error('Redirect to private address is not allowed'); } + // Check redirect target is within the allowed hosts (host + path prefix) + if (!isAllowedByOrigins(redirectUrl.toString(), allowedOrigins)) { + throw new Error('Redirect to a non-allowed host is not allowed'); + } + // 307/308 preserve method and body; others convert to GET const preserveMethod = response.status === 307 || response.status === 308; const redirectHeaders = new Headers(options.headers); @@ -265,7 +285,12 @@ async function fetchWithRedirectValidation( redirectOptions = { ...options, method: 'GET', body: undefined, headers: redirectHeaders }; } - return fetchWithRedirectValidation(redirectUrl.toString(), redirectOptions, remaining - 1); + return fetchWithRedirectValidation( + redirectUrl.toString(), + redirectOptions, + allowedOrigins, + remaining - 1 + ); } export function handleOpenAPIProxyOptions() { diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 7df0a6840..2eb0f1e50 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -11,7 +11,12 @@ import { generateMediaTypeExamples, generateSchemaExample } from './generateSche import { stringifyOpenAPI } from './stringifyOpenAPI'; import type { OpenAPIOperationData } from './types'; import { mergeHeaders } from './util/headers'; -import { getDefaultServerURL, hasValidServerHost } from './util/server'; +import { + extractOrigin, + getAllServerOrigins, + getDefaultServerURL, + hasValidServerHost, +} from './util/server'; import { resolvePrefillCodePlaceholderFromSecurityScheme, resolveURLWithPrefillCodePlaceholdersFromServer, @@ -242,7 +247,7 @@ function OpenAPICodeSampleFooter(props: { )} {!hideTryItPanel && hasValidHost && ( { +export interface OpenAPIContext + extends Omit { /** * Render a code block. */ @@ -69,6 +70,12 @@ export interface OpenAPIContext extends Omit string | null; } export type OpenAPIUniversalContext = OpenAPIClientContext | OpenAPIContext; @@ -102,7 +109,7 @@ export function getOpenAPIClientContext(context: OpenAPIUniversalContext): OpenA defaultInteractiveOpened: context.defaultInteractiveOpened, blockKey: context.blockKey, id: context.id, - proxyUrl: context.proxyUrl, + proxyUrl: '$$isClientContext$$' in context ? context.proxyUrl : undefined, $$isClientContext$$: true, }; } diff --git a/packages/react-openapi/src/index.ts b/packages/react-openapi/src/index.ts index f04fca524..bc7a1648c 100644 --- a/packages/react-openapi/src/index.ts +++ b/packages/react-openapi/src/index.ts @@ -8,3 +8,4 @@ export * from './resolveOpenAPIWebhook'; export type { OpenAPIOperationData, OpenAPIWebhookData } from './types'; export type { OpenAPIContextInput } from './context'; export { checkIsValidLocale } from './translations'; +export { extractOrigin } from './util/server'; diff --git a/packages/react-openapi/src/util/server.ts b/packages/react-openapi/src/util/server.ts index 0ebe175d5..d498259e2 100644 --- a/packages/react-openapi/src/util/server.ts +++ b/packages/react-openapi/src/util/server.ts @@ -64,6 +64,37 @@ export function hasValidServerHost(servers: OpenAPIV3.ServerObject[]): boolean { }); } +/** + * Get the unique host+path entries from a list of servers (using default variable values). + * Used to build the allowlist for the OpenAPI proxy. + * Returns entries like "api.example.com/v1" (without protocol or trailing slash). + */ +export function getAllServerOrigins(servers: OpenAPIV3.ServerObject[]): string[] { + const origins = new Set(); + + for (const server of servers) { + const url = interpolateServerURL(server); + const origin = extractOrigin(url); + if (origin) { + origins.add(origin); + } + } + + return Array.from(origins); +} + +/** + * Extract the host and path from a URL string by stripping the protocol and trailing slash. + * e.g. "https://api.example.com/v1/" → "api.example.com/v1" + */ +export function extractOrigin(url: string): string | null { + const stripped = url.replace(/^https?:\/\//, '').replace(/\/+$/, ''); + if (!stripped) { + return null; + } + return stripped; +} + /** * Check if the server host/URL is valid for making direct HTTP requests. * Accepts both full URLs (with protocol) and hostnames (without protocol).