diff --git a/.changeset/bright-streets-hammer.md b/.changeset/bright-streets-hammer.md new file mode 100644 index 000000000..757332bb2 --- /dev/null +++ b/.changeset/bright-streets-hammer.md @@ -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 diff --git a/packages/gitbook/src/app/~scalar/proxy/route.ts b/packages/gitbook/src/app/~scalar/proxy/route.ts new file mode 100644 index 000000000..4fd141720 --- /dev/null +++ b/packages/gitbook/src/app/~scalar/proxy/route.ts @@ -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(); +} diff --git a/packages/gitbook/src/routes/openapi-proxy.test.ts b/packages/gitbook/src/routes/openapi-proxy.test.ts new file mode 100644 index 000000000..40f0b7bdc --- /dev/null +++ b/packages/gitbook/src/routes/openapi-proxy.test.ts @@ -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; 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).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).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'); + }); +}); diff --git a/packages/gitbook/src/routes/openapi-proxy.ts b/packages/gitbook/src/routes/openapi-proxy.ts new file mode 100644 index 000000000..15c33674e --- /dev/null +++ b/packages/gitbook/src/routes/openapi-proxy.ts @@ -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 { + // 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 { + 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 { + 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', + }, + }); +} diff --git a/packages/openapi-parser/src/types.ts b/packages/openapi-parser/src/types.ts index 1e6d42aa9..39c5531c5 100644 --- a/packages/openapi-parser/src/types.ts +++ b/packages/openapi-parser/src/types.ts @@ -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. */ diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 194438b38..dd927216b 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -243,6 +243,7 @@ function OpenAPICodeSampleFooter(props: { {!hideTryItPanel && hasValidHost && ( (null); @@ -51,6 +52,7 @@ export function ScalarApiButton(props: { ; }) { - 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 ( diff --git a/packages/react-openapi/src/resolveOpenAPIOperation.test.ts b/packages/react-openapi/src/resolveOpenAPIOperation.test.ts index 26f0fe3eb..e73f316f1 100644 --- a/packages/react-openapi/src/resolveOpenAPIOperation.test.ts +++ b/packages/react-openapi/src/resolveOpenAPIOperation.test.ts @@ -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); diff --git a/packages/react-openapi/src/resolveOpenAPIOperation.ts b/packages/react-openapi/src/resolveOpenAPIOperation.ts index aa31cea59..48d93652f 100644 --- a/packages/react-openapi/src/resolveOpenAPIOperation.ts +++ b/packages/react-openapi/src/resolveOpenAPIOperation.ts @@ -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, }; }