mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Add server-side proxy for Scalar API client to bypass CORS (#4050)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@gitbook/openapi-parser": patch
|
||||
"@gitbook/react-openapi": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add server-side proxy for Scalar API client to bypass CORS
|
||||
@@ -0,0 +1,32 @@
|
||||
import { handleOpenAPIProxyOptions, handleOpenAPIProxyRequest } from '@/routes/openapi-proxy';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function HEAD(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleOpenAPIProxyOptions();
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
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 }));
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import {
|
||||
handleOpenAPIProxyOptions,
|
||||
handleOpenAPIProxyRequest,
|
||||
isBlockedHost,
|
||||
} from './openapi-proxy';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function createRequest(
|
||||
url: string,
|
||||
options?: { method?: string; headers?: Record<string, string>; body?: string }
|
||||
) {
|
||||
return new NextRequest(new URL(url), {
|
||||
method: options?.method ?? 'GET',
|
||||
headers: options?.headers,
|
||||
body: options?.body,
|
||||
});
|
||||
}
|
||||
|
||||
function getForwardedHeaders(): Headers {
|
||||
const calls = (globalThis.fetch as ReturnType<typeof mock>).mock.calls;
|
||||
// biome-ignore lint/style/noNonNullAssertion: test helper, call is guaranteed
|
||||
return calls[0]![1].headers as Headers;
|
||||
}
|
||||
|
||||
describe('isBlockedHost', () => {
|
||||
it('blocks localhost IP', async () => {
|
||||
expect(await isBlockedHost('127.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks private 10.x range', async () => {
|
||||
expect(await isBlockedHost('10.0.0.1')).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('allows public IPs', async () => {
|
||||
expect(await isBlockedHost('93.184.215.14')).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves hostnames via DNS and checks the result', async () => {
|
||||
mockDnsLookup.mockResolvedValueOnce([{ address: '10.0.0.1', family: 4 }]);
|
||||
expect(await isBlockedHost('evil.example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks when DNS resolution fails', async () => {
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
mockDnsLookup.mockReset();
|
||||
mockDnsLookup.mockResolvedValue([{ address: '93.184.215.14', family: 4 }]);
|
||||
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response('ok', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
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(req);
|
||||
|
||||
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 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(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);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
const calls = (globalThis.fetch as ReturnType<typeof mock>).mock.calls;
|
||||
// biome-ignore lint/style/noNonNullAssertion: test assertion
|
||||
const [calledUrl, calledOptions] = calls[0]!;
|
||||
expect(calledUrl).toBe(target);
|
||||
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',
|
||||
{
|
||||
headers: {
|
||||
origin: 'http://localhost:3000',
|
||||
referer: 'http://localhost:3000/docs',
|
||||
'x-forwarded-for': '127.0.0.1',
|
||||
accept: 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await handleOpenAPIProxyRequest(req);
|
||||
|
||||
const headers = getForwardedHeaders();
|
||||
expect(headers.get('origin')).toBeNull();
|
||||
expect(headers.get('referer')).toBeNull();
|
||||
expect(headers.get('x-forwarded-for')).toBeNull();
|
||||
expect(headers.get('accept')).toBe('application/json');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const req = createRequest(
|
||||
'http://localhost/~scalar/proxy?scalar_url=https://api.example.com'
|
||||
);
|
||||
const res = await handleOpenAPIProxyRequest(req);
|
||||
|
||||
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 () => {
|
||||
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(req);
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe('Failed to fetch from target URL');
|
||||
});
|
||||
|
||||
it('forwards upstream error responses transparently', async () => {
|
||||
const errorBody = JSON.stringify({ message: 'Unauthorized', code: 'AUTH_REQUIRED' });
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(errorBody, {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json', 'x-request-id': 'abc-123' },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const req = createRequest(
|
||||
'http://localhost/~scalar/proxy?scalar_url=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 () => {
|
||||
let callCount = 0;
|
||||
globalThis.fetch = mock(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'https://final.example.com/result' },
|
||||
})
|
||||
);
|
||||
}
|
||||
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(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' },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const req = createRequest(
|
||||
'http://localhost/~scalar/proxy?scalar_url=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');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const MAX_REDIRECTS = 10;
|
||||
const FETCH_TIMEOUT_MS = 30_000;
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
|
||||
/** Headers that should not be forwarded from the incoming request to the target */
|
||||
const REQUEST_HEADERS_TO_STRIP = new Set([
|
||||
'host',
|
||||
'origin',
|
||||
'referer',
|
||||
'connection',
|
||||
'x-scalar-cookie',
|
||||
'x-scalar-user-agent',
|
||||
'x-forwarded-for',
|
||||
'x-forwarded-host',
|
||||
'x-forwarded-proto',
|
||||
'x-forwarded-port',
|
||||
'x-middleware-invoke',
|
||||
'x-middleware-next',
|
||||
'x-nextjs-data',
|
||||
]);
|
||||
|
||||
/** Response headers that should not be forwarded back to the client */
|
||||
const RESPONSE_HEADERS_TO_STRIP = new Set([
|
||||
'content-encoding',
|
||||
'content-length',
|
||||
'transfer-encoding',
|
||||
'connection',
|
||||
'keep-alive',
|
||||
]);
|
||||
|
||||
const CORS_HEADERS = {
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': '*',
|
||||
'access-control-allow-headers': '*',
|
||||
'access-control-expose-headers': '*',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Check if an IPv4 address is in a private/reserved range.
|
||||
*/
|
||||
function isPrivateIPv4(ip: string): boolean {
|
||||
const parts = ip.split('.').map(Number);
|
||||
const [a, b] = parts;
|
||||
if (a === undefined || b === undefined) return false;
|
||||
return (
|
||||
a === 0 || // 0.0.0.0/8
|
||||
a === 10 || // 10.0.0.0/8
|
||||
a === 127 || // 127.0.0.0/8
|
||||
(a === 169 && b === 254) || // 169.254.0.0/16
|
||||
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12
|
||||
(a === 192 && b === 168) || // 192.168.0.0/16
|
||||
(a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10
|
||||
a >= 224 // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved + 255.255.255.255 broadcast
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IPv6 address is in a private/reserved range.
|
||||
* Also handles IPv4-mapped IPv6 addresses (::ffff:x.x.x.x).
|
||||
*/
|
||||
function isPrivateIPv6(ip: string): boolean {
|
||||
const lower = ip.toLowerCase();
|
||||
|
||||
// IPv4-mapped IPv6 (::ffff:127.0.0.1) — extract and check the IPv4 part
|
||||
const v4MappedMatch = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (v4MappedMatch?.[1]) {
|
||||
return isPrivateIPv4(v4MappedMatch[1]);
|
||||
}
|
||||
|
||||
return (
|
||||
lower === '::1' ||
|
||||
lower === '::' ||
|
||||
lower.startsWith('fe80:') || // Link-local
|
||||
lower.startsWith('fc') || // Unique local (fc00::/7)
|
||||
lower.startsWith('fd') // Unique local (fc00::/7)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address (v4 or v6) is in a private/reserved range.
|
||||
*/
|
||||
function isPrivateIP(ip: string): boolean {
|
||||
const version = isIP(ip);
|
||||
if (version === 4) return isPrivateIPv4(ip);
|
||||
if (version === 6) return isPrivateIPv6(ip);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hostname resolves to a private/reserved IP address (SSRF protection).
|
||||
* Blocks private IPs, link-local, loopback, and cloud metadata addresses.
|
||||
*/
|
||||
export async function isBlockedHost(hostname: string): Promise<boolean> {
|
||||
// Strip brackets from IPv6 literals
|
||||
const host =
|
||||
hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
||||
|
||||
// Direct IP check
|
||||
if (isIP(host)) {
|
||||
return isPrivateIP(host);
|
||||
}
|
||||
|
||||
// Resolve hostname and check all resolved IPs
|
||||
try {
|
||||
const results = await lookup(host, { all: true });
|
||||
if (results.length === 0) return true;
|
||||
return results.some((result) => isPrivateIP(result.address));
|
||||
} catch {
|
||||
// DNS resolution failed — block to be safe
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleOpenAPIProxyRequest(request: NextRequest): Promise<Response> {
|
||||
const targetUrl = request.nextUrl.searchParams.get('scalar_url');
|
||||
|
||||
if (!targetUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required query parameter: scalar_url' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(targetUrl);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid URL provided in scalar_url parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only HTTP and HTTPS URLs are supported' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// SSRF protection: block requests to private/internal addresses.
|
||||
// Note: DNS is resolved here then again inside fetch(), so a DNS rebinding attack
|
||||
// (returning a public IP first, then a private IP) could theoretically bypass this.
|
||||
// Mitigating this fully would require controlling DNS at the socket level.
|
||||
if (await isBlockedHost(parsedUrl.hostname)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: access to private addresses is not allowed' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build forwarded headers
|
||||
const forwardedHeaders = new Headers();
|
||||
for (const [key, value] of request.headers.entries()) {
|
||||
if (!REQUEST_HEADERS_TO_STRIP.has(key.toLowerCase())) {
|
||||
forwardedHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Scalar sends cookies via X-Scalar-Cookie when using a proxy
|
||||
const scalarCookie = request.headers.get('x-scalar-cookie');
|
||||
if (scalarCookie) {
|
||||
forwardedHeaders.set('cookie', scalarCookie);
|
||||
}
|
||||
|
||||
// Scalar sends user-agent via X-Scalar-User-Agent in some environments
|
||||
const scalarUserAgent = request.headers.get('x-scalar-user-agent');
|
||||
if (scalarUserAgent) {
|
||||
forwardedHeaders.set('user-agent', scalarUserAgent);
|
||||
}
|
||||
|
||||
forwardedHeaders.set('host', parsedUrl.host);
|
||||
|
||||
const controller = new AbortController();
|
||||
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',
|
||||
});
|
||||
|
||||
// Build response headers, stripping transport headers and upstream CORS headers
|
||||
const responseHeaders = new Headers();
|
||||
for (const [key, value] of response.headers.entries()) {
|
||||
const lower = key.toLowerCase();
|
||||
if (!RESPONSE_HEADERS_TO_STRIP.has(lower) && !lower.startsWith('access-control-')) {
|
||||
if (lower === 'set-cookie') {
|
||||
responseHeaders.append(key, value);
|
||||
} else {
|
||||
responseHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add our own CORS headers
|
||||
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
||||
responseHeaders.set(key, value);
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[openapi-proxy] upstream fetch failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch from target URL' }, { status: 502 });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with manual redirect handling to validate each redirect target
|
||||
* against SSRF protection (prevents redirect-based SSRF attacks).
|
||||
*/
|
||||
async function fetchWithRedirectValidation(
|
||||
url: string,
|
||||
options: RequestInit & { duplex?: string },
|
||||
remaining = MAX_REDIRECTS
|
||||
): Promise<Response> {
|
||||
const response = await fetch(url, { ...options, redirect: 'manual' });
|
||||
|
||||
if (!REDIRECT_STATUSES.has(response.status) || remaining <= 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const location = response.headers.get('location');
|
||||
if (!location) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const redirectUrl = new URL(location, url);
|
||||
|
||||
if (redirectUrl.protocol !== 'https:' && redirectUrl.protocol !== 'http:') {
|
||||
throw new Error('Redirect to non-HTTP protocol is not allowed');
|
||||
}
|
||||
|
||||
if (await isBlockedHost(redirectUrl.hostname)) {
|
||||
throw new Error('Redirect to private address 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);
|
||||
redirectHeaders.set('host', redirectUrl.host);
|
||||
|
||||
let redirectOptions: RequestInit & { duplex?: string };
|
||||
if (preserveMethod) {
|
||||
if (options.body instanceof ReadableStream) {
|
||||
throw new Error('Cannot follow 307/308 redirect with a streaming request body');
|
||||
}
|
||||
redirectOptions = { ...options, headers: redirectHeaders };
|
||||
} else {
|
||||
redirectOptions = { ...options, method: 'GET', body: undefined, headers: redirectHeaders };
|
||||
}
|
||||
|
||||
return fetchWithRedirectValidation(redirectUrl.toString(), redirectOptions, remaining - 1);
|
||||
}
|
||||
|
||||
export function handleOpenAPIProxyOptions() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
'access-control-max-age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,12 @@ export interface OpenAPICustomSpecProperties {
|
||||
*/
|
||||
'x-hideTryItPanel'?: boolean;
|
||||
|
||||
/**
|
||||
* If `true`, the Scalar API client will proxy requests through the server
|
||||
* to avoid CORS issues.
|
||||
*/
|
||||
'x-enable-proxy'?: boolean;
|
||||
|
||||
/**
|
||||
* Description in HTML format.
|
||||
*/
|
||||
|
||||
@@ -243,6 +243,7 @@ function OpenAPICodeSampleFooter(props: {
|
||||
{!hideTryItPanel && hasValidHost && (
|
||||
<ScalarApiButton
|
||||
context={getOpenAPIClientContext(context)}
|
||||
withProxy={Boolean(data['x-enable-proxy'])}
|
||||
method={method}
|
||||
path={path}
|
||||
securities={securities}
|
||||
|
||||
@@ -21,9 +21,10 @@ export function ScalarApiButton(props: {
|
||||
securities: OpenAPIOperationData['securities'];
|
||||
servers: OpenAPIOperationData['servers'];
|
||||
specUrl: string;
|
||||
withProxy: boolean;
|
||||
context: OpenAPIClientContext;
|
||||
}) {
|
||||
const { method, path, securities, servers, specUrl, context } = props;
|
||||
const { method, path, securities, servers, specUrl, withProxy, context } = props;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const controllerRef = useRef<ScalarModalControllerRef>(null);
|
||||
|
||||
@@ -51,6 +52,7 @@ export function ScalarApiButton(props: {
|
||||
<Suspense fallback={null}>
|
||||
<ScalarModal
|
||||
controllerRef={controllerRef}
|
||||
withProxy={withProxy}
|
||||
method={method}
|
||||
path={path}
|
||||
securities={securities}
|
||||
@@ -70,9 +72,10 @@ function ScalarModal(props: {
|
||||
securities: OpenAPIOperationData['securities'];
|
||||
servers: OpenAPIOperationData['servers'];
|
||||
specUrl: string;
|
||||
withProxy: boolean;
|
||||
controllerRef: React.Ref<ScalarModalControllerRef>;
|
||||
}) {
|
||||
const { method, path, securities, servers, specUrl, controllerRef } = props;
|
||||
const { method, path, securities, servers, specUrl, withProxy, controllerRef } = props;
|
||||
|
||||
const getPrefillInputContextData = useOpenAPIPrefillContext();
|
||||
const prefillInputContext = getPrefillInputContextData();
|
||||
@@ -84,7 +87,11 @@ function ScalarModal(props: {
|
||||
|
||||
return (
|
||||
<ApiClientModalProvider
|
||||
configuration={{ url: specUrl, ...prefillConfig }}
|
||||
configuration={{
|
||||
url: specUrl,
|
||||
...prefillConfig,
|
||||
proxyUrl: withProxy ? '/~scalar/proxy' : undefined,
|
||||
}}
|
||||
initialRequest={{ method: toScalarHttpMethod(method), path }}
|
||||
>
|
||||
<ScalarModalController method={method} path={path} controllerRef={controllerRef} />
|
||||
|
||||
@@ -184,6 +184,83 @@ describe('#resolveOpenAPIOperation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('x-enable-proxy', () => {
|
||||
it('should extract x-enable-proxy when set to true', async () => {
|
||||
const filesystem = await loadFixture({
|
||||
openapi: '3.1.0',
|
||||
info: { title: 'Test', version: '1.0' },
|
||||
'x-enable-proxy': true,
|
||||
paths: {
|
||||
'/test': {
|
||||
get: { responses: { '200': { description: 'OK' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const resolved = await resolveOpenAPIOperation(filesystem, {
|
||||
method: 'get',
|
||||
path: '/test',
|
||||
});
|
||||
|
||||
expect(resolved?.['x-enable-proxy']).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract x-enable-proxy when set to false', async () => {
|
||||
const filesystem = await loadFixture({
|
||||
openapi: '3.1.0',
|
||||
info: { title: 'Test', version: '1.0' },
|
||||
'x-enable-proxy': false,
|
||||
paths: {
|
||||
'/test': {
|
||||
get: { responses: { '200': { description: 'OK' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const resolved = await resolveOpenAPIOperation(filesystem, {
|
||||
method: 'get',
|
||||
path: '/test',
|
||||
});
|
||||
|
||||
expect(resolved?.['x-enable-proxy']).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined when x-enable-proxy is not set', async () => {
|
||||
const filesystem = await loadFixture({
|
||||
openapi: '3.1.0',
|
||||
info: { title: 'Test', version: '1.0' },
|
||||
paths: {
|
||||
'/test': {
|
||||
get: { responses: { '200': { description: 'OK' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const resolved = await resolveOpenAPIOperation(filesystem, {
|
||||
method: 'get',
|
||||
path: '/test',
|
||||
});
|
||||
|
||||
expect(resolved?.['x-enable-proxy']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore x-enable-proxy when not a boolean', async () => {
|
||||
const filesystem = await loadFixture({
|
||||
openapi: '3.1.0',
|
||||
info: { title: 'Test', version: '1.0' },
|
||||
'x-enable-proxy': 'yes',
|
||||
paths: {
|
||||
'/test': {
|
||||
get: { responses: { '200': { description: 'OK' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const resolved = await resolveOpenAPIOperation(filesystem, {
|
||||
method: 'get',
|
||||
path: '/test',
|
||||
});
|
||||
|
||||
expect(resolved?.['x-enable-proxy']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('server precedence', () => {
|
||||
it('should use root-level servers when no path or operation servers are defined', async () => {
|
||||
const filesystem = await loadFixture(serverPrecedenceSpec);
|
||||
|
||||
@@ -82,6 +82,8 @@ export async function resolveOpenAPIOperation(
|
||||
typeof schema['x-hideTryItPanel'] === 'boolean'
|
||||
? schema['x-hideTryItPanel']
|
||||
: undefined,
|
||||
'x-enable-proxy':
|
||||
typeof schema['x-enable-proxy'] === 'boolean' ? schema['x-enable-proxy'] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user