security: add authentication and baseUrl validation to summarize API
Add security controls to prevent unauthorized access and SSRF attacks: - Require authentication when AUTH_ENABLED=true (session cookie or Bearer token) - Validate baseUrl against allowlist in production (provider defaults + SUMMARY_ALLOWED_BASE_URLS env var) - Reject non-HTTPS URLs in production - Return explicit errors for invalid custom-openai baseUrl configurations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9cdeeca420
commit
7a13698a1c
1 changed files with 125 additions and 2 deletions
|
|
@ -4,9 +4,111 @@ import { createOpenAI } from '@ai-sdk/openai';
|
||||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||||
import { createGroq } from '@ai-sdk/groq';
|
import { createGroq } from '@ai-sdk/groq';
|
||||||
import type { SummarizeRequest, SummarizeResponse, SummarizeError } from '@/types/summary';
|
import type { SummarizeRequest, SummarizeResponse, SummarizeError } from '@/types/summary';
|
||||||
|
import { getAuthToken } from '@/lib/auth';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// Default provider endpoints - these are the only allowed baseUrls in production
|
||||||
|
// unless SUMMARY_ALLOWED_BASE_URLS is configured
|
||||||
|
const PROVIDER_DEFAULT_ENDPOINTS: Record<string, string> = {
|
||||||
|
openai: 'https://api.openai.com/v1',
|
||||||
|
anthropic: 'https://api.anthropic.com',
|
||||||
|
groq: 'https://api.groq.com/openai/v1',
|
||||||
|
openrouter: 'https://openrouter.ai/api/v1',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse allowed base URLs from environment variable (comma-separated)
|
||||||
|
function getAllowedBaseUrls(): string[] {
|
||||||
|
const envUrls = process.env.SUMMARY_ALLOWED_BASE_URLS;
|
||||||
|
if (!envUrls) return [];
|
||||||
|
return envUrls.split(',').map(url => url.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate baseUrl against allowlist in production
|
||||||
|
function validateBaseUrl(baseUrl: string, provider: string): string | null {
|
||||||
|
// Empty baseUrl is always allowed - will use SDK defaults
|
||||||
|
if (!baseUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In development, allow any baseUrl
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and normalize the URL
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(baseUrl);
|
||||||
|
} catch {
|
||||||
|
return null; // Invalid URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow https in production
|
||||||
|
if (parsedUrl.protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUrl = parsedUrl.origin + parsedUrl.pathname.replace(/\/$/, '');
|
||||||
|
|
||||||
|
// Check against provider defaults
|
||||||
|
const providerDefault = PROVIDER_DEFAULT_ENDPOINTS[provider];
|
||||||
|
if (providerDefault && normalizedUrl === providerDefault) {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against configured allowlist
|
||||||
|
const allowedUrls = getAllowedBaseUrls();
|
||||||
|
for (const allowed of allowedUrls) {
|
||||||
|
try {
|
||||||
|
const allowedParsed = new URL(allowed);
|
||||||
|
const normalizedAllowed = allowedParsed.origin + allowedParsed.pathname.replace(/\/$/, '');
|
||||||
|
if (normalizedUrl === normalizedAllowed || normalizedUrl.startsWith(normalizedAllowed + '/')) {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL not in allowlist - return null to use provider default
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate request - returns error response if auth fails, null if auth passes
|
||||||
|
function authenticateRequest(req: NextRequest): NextResponse | null {
|
||||||
|
// Auth disabled by default - set AUTH_ENABLED=true to enable
|
||||||
|
if (process.env.AUTH_ENABLED !== 'true') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for valid auth_session cookie
|
||||||
|
const sessionCookie = req.cookies.get('auth_session')?.value;
|
||||||
|
if (sessionCookie) {
|
||||||
|
const validToken = getAuthToken();
|
||||||
|
if (sessionCookie === validToken) {
|
||||||
|
return null; // Auth passed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Authorization header (Bearer token)
|
||||||
|
const authHeader = req.headers.get('Authorization');
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const bearerToken = authHeader.slice(7);
|
||||||
|
const validToken = getAuthToken();
|
||||||
|
if (bearerToken === validToken) {
|
||||||
|
return null; // Auth passed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth failed
|
||||||
|
const errorBody: SummarizeError = {
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'Authentication required. Please provide a valid session cookie or Authorization header.',
|
||||||
|
};
|
||||||
|
return NextResponse.json(errorBody, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const SYSTEM_PROMPTS: Record<string, string> = {
|
const SYSTEM_PROMPTS: Record<string, string> = {
|
||||||
current_page: `You are a helpful assistant that summarizes text content.
|
current_page: `You are a helpful assistant that summarizes text content.
|
||||||
Provide a clear, concise summary of the current page content provided.
|
Provide a clear, concise summary of the current page content provided.
|
||||||
|
|
@ -42,12 +144,25 @@ Do not include any preamble like "Here is a summary" - just provide the summary
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
// Authentication check - must pass before processing any other headers
|
||||||
|
const authError = authenticateRequest(req);
|
||||||
|
if (authError) {
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
// Get configuration from headers
|
// Get configuration from headers
|
||||||
const provider = req.headers.get('x-summary-provider') || 'openai';
|
const provider = req.headers.get('x-summary-provider') || 'openai';
|
||||||
const baseUrl = req.headers.get('x-summary-base-url') || '';
|
const requestedBaseUrl = req.headers.get('x-summary-base-url') || '';
|
||||||
const modelId = req.headers.get('x-summary-model') || 'gpt-4o-mini';
|
const modelId = req.headers.get('x-summary-model') || 'gpt-4o-mini';
|
||||||
|
|
||||||
|
// Validate and sanitize baseUrl - in production, only allow configured endpoints
|
||||||
|
const baseUrl = validateBaseUrl(requestedBaseUrl, provider);
|
||||||
|
if (requestedBaseUrl && !baseUrl && process.env.NODE_ENV === 'production') {
|
||||||
|
console.warn(`Rejected untrusted baseUrl: ${requestedBaseUrl} for provider: ${provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Get API key from headers or environment variables based on provider
|
// Get API key from headers or environment variables based on provider
|
||||||
|
// API key selection only occurs after authentication has passed
|
||||||
let apiKey = req.headers.get('x-summary-api-key') || '';
|
let apiKey = req.headers.get('x-summary-api-key') || '';
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
|
|
@ -136,13 +251,21 @@ export async function POST(req: NextRequest) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'custom-openai': {
|
case 'custom-openai': {
|
||||||
if (!baseUrl) {
|
if (!requestedBaseUrl) {
|
||||||
const errorBody: SummarizeError = {
|
const errorBody: SummarizeError = {
|
||||||
code: 'MISSING_BASE_URL',
|
code: 'MISSING_BASE_URL',
|
||||||
message: 'Custom provider requires a base URL',
|
message: 'Custom provider requires a base URL',
|
||||||
};
|
};
|
||||||
return NextResponse.json(errorBody, { status: 400 });
|
return NextResponse.json(errorBody, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (!baseUrl) {
|
||||||
|
// URL was provided but rejected by validation
|
||||||
|
const errorBody: SummarizeError = {
|
||||||
|
code: 'INVALID_BASE_URL',
|
||||||
|
message: 'The provided base URL is not allowed. In production, only configured endpoints are permitted.',
|
||||||
|
};
|
||||||
|
return NextResponse.json(errorBody, { status: 400 });
|
||||||
|
}
|
||||||
const customOpenAI = createOpenAI({
|
const customOpenAI = createOpenAI({
|
||||||
apiKey: apiKey || 'not-needed',
|
apiKey: apiKey || 'not-needed',
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue