mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Add token verification on OpenAPI proxy (#4085)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user