Add token verification on OpenAPI proxy (#4085)

This commit is contained in:
Nolann B.
2026-03-08 16:50:43 +01:00
committed by GitHub
parent d666212674
commit 887c3f8eac
10 changed files with 490 additions and 221 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Add token verification on OpenAPI proxy
@@ -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: <Icon icon="chevron-down" />,
chevronRight: <Icon icon="chevron-right" />,
@@ -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);
});
});
@@ -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();
}
+138 -206
View File
@@ -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<string, string>; body?: string }
@@ -27,50 +33,40 @@ function createRequest(
function getForwardedHeaders(): Headers {
const calls = (globalThis.fetch as ReturnType<typeof mock>).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<typeof mock>).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');
});
});
+34 -9
View File
@@ -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<R
);
}
// Host allowlist: verify the signed token and check the target URL is allowed.
// This prevents the proxy from being used as an open proxy for arbitrary URLs.
const verification = verifyProxyRequest(request.nextUrl.searchParams, targetUrl);
if (!verification.allowed) {
return NextResponse.json({ error: verification.reason }, { status: 403 });
}
const { allowedOrigins } = verification;
let parsedUrl: URL;
try {
parsedUrl = new URL(targetUrl);
@@ -180,14 +190,18 @@ export async function handleOpenAPIProxyRequest(request: NextRequest): Promise<R
const timeout = setTimeout(() => 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<R
async function fetchWithRedirectValidation(
url: string,
options: RequestInit & { duplex?: string },
allowedOrigins: string[],
remaining = MAX_REDIRECTS
): Promise<Response> {
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() {
@@ -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 && (
<ScalarApiButton
context={getOpenAPIClientContext(context)}
context={resolveScalarClientContext(context, servers, specUrl)}
withProxy={Boolean(data.operation['x-enable-proxy'] ?? data['x-enable-proxy'])}
method={method}
path={path}
@@ -255,6 +260,33 @@ function OpenAPICodeSampleFooter(props: {
);
}
/**
* Build the client context for ScalarApiButton, resolving the signed proxy URL
* with the allowed server hosts for SSRF protection.
*/
function resolveScalarClientContext(
context: OpenAPIContext,
servers: OpenAPIOperationData['servers'],
specUrl: string
) {
const clientContext = getOpenAPIClientContext(context);
if (context.resolveProxyUrl) {
// Collect all possible host+path entries from spec servers
const origins = getAllServerOrigins(servers);
// Add the spec URL so the proxy can resolve it
const specOrigin = extractOrigin(specUrl);
if (specOrigin) {
origins.push(specOrigin);
}
clientContext.proxyUrl = context.resolveProxyUrl(origins) ?? undefined;
}
return clientContext;
}
/**
* Get custom code samples for the operation.
*/
+9 -2
View File
@@ -45,7 +45,8 @@ export interface OpenAPIClientContext {
$$isClientContext$$: true;
}
export interface OpenAPIContext extends Omit<OpenAPIClientContext, '$$isClientContext$$'> {
export interface OpenAPIContext
extends Omit<OpenAPIClientContext, '$$isClientContext$$' | 'proxyUrl'> {
/**
* Render a code block.
*/
@@ -69,6 +70,12 @@ export interface OpenAPIContext extends Omit<OpenAPIClientContext, '$$isClientCo
* Specification URL.
*/
specUrl: string;
/**
* Build a signed proxy URL that restricts the proxy to specific origins.
* Called at render time (server-side) with the server origins for an operation.
*/
resolveProxyUrl?: (allowedOrigins: string[]) => 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,
};
}
+1
View File
@@ -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';
+31
View File
@@ -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<string>();
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).