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).