diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 801f465..c37817d 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -20,6 +20,7 @@ import type { StatelessChatRequest, StatelessEvent } from '@/lib/types/chat'; import type { ThinkingConfig } from '@/lib/types/provider'; import { apiError } from '@/lib/server/api-response'; import { createLogger } from '@/lib/logger'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; const log = createLogger('Chat API'); // Allow streaming responses up to 60 seconds @@ -63,8 +64,21 @@ export async function POST(req: NextRequest) { // Resolve API key: client > server > empty const modelString = body.model || 'gpt-4o-mini'; const { providerId, modelId } = parseModelString(modelString); - const effectiveApiKey = resolveApiKey(providerId, body.apiKey); - const effectiveBaseUrl = resolveBaseUrl(providerId, body.baseUrl); + + const clientBaseUrl = body.baseUrl || undefined; + if (clientBaseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = validateUrlForSSRF(clientBaseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + } + + const effectiveApiKey = clientBaseUrl + ? body.apiKey || '' + : resolveApiKey(providerId, body.apiKey); + const effectiveBaseUrl = clientBaseUrl + ? clientBaseUrl + : resolveBaseUrl(providerId, body.baseUrl); const proxy = resolveProxy(providerId); if (!effectiveApiKey) { diff --git a/app/api/generate/image/route.ts b/app/api/generate/image/route.ts index 1c95618..a3b8c3a 100644 --- a/app/api/generate/image/route.ts +++ b/app/api/generate/image/route.ts @@ -21,6 +21,7 @@ import { resolveImageApiKey, resolveImageBaseUrl } from '@/lib/server/provider-c import type { ImageProviderId, ImageGenerationOptions } from '@/lib/media/types'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; const log = createLogger('ImageGeneration API'); @@ -39,7 +40,16 @@ export async function POST(request: NextRequest) { const clientBaseUrl = request.headers.get('x-base-url') || undefined; const clientModel = request.headers.get('x-image-model') || undefined; - const apiKey = resolveImageApiKey(providerId, clientApiKey); + if (clientBaseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = validateUrlForSSRF(clientBaseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + } + + const apiKey = clientBaseUrl + ? clientApiKey || '' + : resolveImageApiKey(providerId, clientApiKey); if (!apiKey) { return apiError( 'MISSING_API_KEY', @@ -48,7 +58,7 @@ export async function POST(request: NextRequest) { ); } - const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl); + const baseUrl = clientBaseUrl ? clientBaseUrl : resolveImageBaseUrl(providerId, clientBaseUrl); // Resolve dimensions from aspect ratio if not explicitly set if (!body.width && !body.height && body.aspectRatio) { diff --git a/app/api/generate/tts/route.ts b/app/api/generate/tts/route.ts index 4ae820c..542f105 100644 --- a/app/api/generate/tts/route.ts +++ b/app/api/generate/tts/route.ts @@ -13,6 +13,7 @@ import { resolveTTSApiKey, resolveTTSBaseUrl } from '@/lib/server/provider-confi import type { TTSProviderId } from '@/lib/audio/types'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; const log = createLogger('TTS API'); @@ -45,9 +46,20 @@ export async function POST(req: NextRequest) { return apiError('INVALID_REQUEST', 400, 'browser-native-tts must be handled client-side'); } - // Resolve API key and base URL (server-side fallback) - const apiKey = resolveTTSApiKey(ttsProviderId, ttsApiKey || undefined); - const baseUrl = resolveTTSBaseUrl(ttsProviderId, ttsBaseUrl || undefined); + const clientBaseUrl = ttsBaseUrl || undefined; + if (clientBaseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = validateUrlForSSRF(clientBaseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + } + + const apiKey = clientBaseUrl + ? ttsApiKey || '' + : resolveTTSApiKey(ttsProviderId, ttsApiKey || undefined); + const baseUrl = clientBaseUrl + ? clientBaseUrl + : resolveTTSBaseUrl(ttsProviderId, ttsBaseUrl || undefined); // Build TTS config const config = { diff --git a/app/api/generate/video/route.ts b/app/api/generate/video/route.ts index c852666..7ec3399 100644 --- a/app/api/generate/video/route.ts +++ b/app/api/generate/video/route.ts @@ -22,6 +22,7 @@ import { resolveVideoApiKey, resolveVideoBaseUrl } from '@/lib/server/provider-c import type { VideoProviderId, VideoGenerationOptions } from '@/lib/media/types'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; const log = createLogger('VideoGeneration API'); @@ -40,7 +41,16 @@ export async function POST(request: NextRequest) { const clientBaseUrl = request.headers.get('x-base-url') || undefined; const clientModel = request.headers.get('x-video-model') || undefined; - const apiKey = resolveVideoApiKey(providerId, clientApiKey); + if (clientBaseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = validateUrlForSSRF(clientBaseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + } + + const apiKey = clientBaseUrl + ? clientApiKey || '' + : resolveVideoApiKey(providerId, clientApiKey); if (!apiKey) { return apiError( 'MISSING_API_KEY', @@ -49,7 +59,7 @@ export async function POST(request: NextRequest) { ); } - const baseUrl = resolveVideoBaseUrl(providerId, clientBaseUrl); + const baseUrl = clientBaseUrl ? clientBaseUrl : resolveVideoBaseUrl(providerId, clientBaseUrl); // Normalize options against provider capabilities const options = normalizeVideoOptions(providerId, body); diff --git a/app/api/parse-pdf/route.ts b/app/api/parse-pdf/route.ts index 2a6e5ab..94feff5 100644 --- a/app/api/parse-pdf/route.ts +++ b/app/api/parse-pdf/route.ts @@ -5,6 +5,7 @@ import type { PDFProviderId } from '@/lib/pdf/types'; import type { ParsedPdfContent } from '@/lib/types/pdf'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; const log = createLogger('Parse PDF'); export async function POST(req: NextRequest) { @@ -32,10 +33,22 @@ export async function POST(req: NextRequest) { // providerId is required from the client — no server-side store to fall back to const effectiveProviderId = providerId || ('unpdf' as PDFProviderId); + const clientBaseUrl = baseUrl || undefined; + if (clientBaseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = validateUrlForSSRF(clientBaseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + } + const config = { providerId: effectiveProviderId, - apiKey: resolvePDFApiKey(effectiveProviderId, apiKey || undefined), - baseUrl: resolvePDFBaseUrl(effectiveProviderId, baseUrl || undefined), + apiKey: clientBaseUrl + ? apiKey || '' + : resolvePDFApiKey(effectiveProviderId, apiKey || undefined), + baseUrl: clientBaseUrl + ? clientBaseUrl + : resolvePDFBaseUrl(effectiveProviderId, baseUrl || undefined), }; // Convert PDF to buffer diff --git a/app/api/transcription/route.ts b/app/api/transcription/route.ts index d496ff1..c3bf5a2 100644 --- a/app/api/transcription/route.ts +++ b/app/api/transcription/route.ts @@ -4,6 +4,7 @@ import { resolveASRApiKey, resolveASRBaseUrl } from '@/lib/server/provider-confi import type { ASRProviderId } from '@/lib/audio/types'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; const log = createLogger('Transcription'); export const maxDuration = 60; @@ -24,11 +25,23 @@ export async function POST(req: NextRequest) { // providerId is required from the client — no server-side store to fall back to const effectiveProviderId = providerId || ('openai-whisper' as ASRProviderId); + const clientBaseUrl = baseUrl || undefined; + if (clientBaseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = validateUrlForSSRF(clientBaseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + } + const config = { providerId: effectiveProviderId, language: language || 'auto', - apiKey: resolveASRApiKey(effectiveProviderId, apiKey || undefined), - baseUrl: resolveASRBaseUrl(effectiveProviderId, baseUrl || undefined), + apiKey: clientBaseUrl + ? apiKey || '' + : resolveASRApiKey(effectiveProviderId, apiKey || undefined), + baseUrl: clientBaseUrl + ? clientBaseUrl + : resolveASRBaseUrl(effectiveProviderId, baseUrl || undefined), }; // Convert audio file to buffer diff --git a/app/api/verify-image-provider/route.ts b/app/api/verify-image-provider/route.ts index 75dc8fd..9ec0446 100644 --- a/app/api/verify-image-provider/route.ts +++ b/app/api/verify-image-provider/route.ts @@ -20,6 +20,7 @@ import { resolveImageApiKey, resolveImageBaseUrl } from '@/lib/server/provider-c import type { ImageProviderId } from '@/lib/media/types'; import { apiError, apiSuccess } from '@/lib/server/api-response'; import { createLogger } from '@/lib/logger'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; const log = createLogger('VerifyImageProvider'); @@ -30,8 +31,17 @@ export async function POST(request: NextRequest) { const clientApiKey = request.headers.get('x-api-key') || undefined; const clientBaseUrl = request.headers.get('x-base-url') || undefined; - const apiKey = resolveImageApiKey(providerId, clientApiKey); - const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl); + if (clientBaseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = validateUrlForSSRF(clientBaseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + } + + const apiKey = clientBaseUrl + ? clientApiKey || '' + : resolveImageApiKey(providerId, clientApiKey); + const baseUrl = clientBaseUrl ? clientBaseUrl : resolveImageBaseUrl(providerId, clientBaseUrl); if (!apiKey) { return apiError('MISSING_API_KEY', 400, 'No API key configured'); diff --git a/app/api/verify-pdf-provider/route.ts b/app/api/verify-pdf-provider/route.ts index 97b3e0b..3b0ac3f 100644 --- a/app/api/verify-pdf-provider/route.ts +++ b/app/api/verify-pdf-provider/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; import { resolvePDFApiKey, resolvePDFBaseUrl } from '@/lib/server/provider-config'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; const log = createLogger('Verify PDF Provider'); @@ -13,12 +14,22 @@ export async function POST(req: NextRequest) { return apiError('MISSING_REQUIRED_FIELD', 400, 'Provider ID is required'); } - const resolvedBaseUrl = resolvePDFBaseUrl(providerId, baseUrl); + const clientBaseUrl = (baseUrl as string | undefined) || undefined; + if (clientBaseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = validateUrlForSSRF(clientBaseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + } + + const resolvedBaseUrl = clientBaseUrl ? clientBaseUrl : resolvePDFBaseUrl(providerId, baseUrl); if (!resolvedBaseUrl) { return apiError('MISSING_REQUIRED_FIELD', 400, 'Base URL is required'); } - const resolvedApiKey = resolvePDFApiKey(providerId, apiKey); + const resolvedApiKey = clientBaseUrl + ? (apiKey as string | undefined) || '' + : resolvePDFApiKey(providerId, apiKey); const headers: Record = {}; if (resolvedApiKey) { @@ -28,8 +39,13 @@ export async function POST(req: NextRequest) { const response = await fetch(resolvedBaseUrl, { headers, signal: AbortSignal.timeout(10000), + redirect: 'manual', }); + if (response.status >= 300 && response.status < 400) { + return apiError('REDIRECT_NOT_ALLOWED', 403, 'Redirects are not allowed'); + } + // MinerU's FastAPI root returns 404 (no root route), but the server is reachable. // Any HTTP response (including 404) means the server is up. return apiSuccess({ diff --git a/app/api/verify-video-provider/route.ts b/app/api/verify-video-provider/route.ts index d11f971..69d4d33 100644 --- a/app/api/verify-video-provider/route.ts +++ b/app/api/verify-video-provider/route.ts @@ -20,6 +20,7 @@ import { resolveVideoApiKey, resolveVideoBaseUrl } from '@/lib/server/provider-c import type { VideoProviderId } from '@/lib/media/types'; import { apiError, apiSuccess } from '@/lib/server/api-response'; import { createLogger } from '@/lib/logger'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; const log = createLogger('VerifyVideoProvider'); @@ -30,8 +31,17 @@ export async function POST(request: NextRequest) { const clientApiKey = request.headers.get('x-api-key') || undefined; const clientBaseUrl = request.headers.get('x-base-url') || undefined; - const apiKey = resolveVideoApiKey(providerId, clientApiKey); - const baseUrl = resolveVideoBaseUrl(providerId, clientBaseUrl); + if (clientBaseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = validateUrlForSSRF(clientBaseUrl); + if (ssrfError) { + return apiError('INVALID_URL', 403, ssrfError); + } + } + + const apiKey = clientBaseUrl + ? clientApiKey || '' + : resolveVideoApiKey(providerId, clientApiKey); + const baseUrl = clientBaseUrl ? clientBaseUrl : resolveVideoBaseUrl(providerId, clientBaseUrl); if (!apiKey) { return apiError('MISSING_API_KEY', 400, 'No API key configured'); diff --git a/lib/server/resolve-model.ts b/lib/server/resolve-model.ts index 02c5084..71b9500 100644 --- a/lib/server/resolve-model.ts +++ b/lib/server/resolve-model.ts @@ -8,6 +8,7 @@ import type { NextRequest } from 'next/server'; import { getModel, parseModelString, type ModelWithInfo } from '@/lib/ai/providers'; import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config'; +import { validateUrlForSSRF } from '@/lib/server/ssrf-guard'; export interface ResolvedModel extends ModelWithInfo { /** Original model string (e.g. "openai/gpt-4o-mini") */ @@ -28,8 +29,19 @@ export function resolveModel(params: { }): ResolvedModel { const modelString = params.modelString || process.env.DEFAULT_MODEL || 'gpt-4o-mini'; const { providerId, modelId } = parseModelString(modelString); - const apiKey = resolveApiKey(providerId, params.apiKey || ''); - const baseUrl = resolveBaseUrl(providerId, params.baseUrl); + + const clientBaseUrl = params.baseUrl || undefined; + if (clientBaseUrl && process.env.NODE_ENV === 'production') { + const ssrfError = validateUrlForSSRF(clientBaseUrl); + if (ssrfError) { + throw new Error(ssrfError); + } + } + + const apiKey = clientBaseUrl + ? params.apiKey || '' + : resolveApiKey(providerId, params.apiKey || ''); + const baseUrl = clientBaseUrl ? clientBaseUrl : resolveBaseUrl(providerId, params.baseUrl); const proxy = resolveProxy(providerId); const { model, modelInfo } = getModel({ providerId,