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 { createGroq } from '@ai-sdk/groq';
|
||||
import type { SummarizeRequest, SummarizeResponse, SummarizeError } from '@/types/summary';
|
||||
import { getAuthToken } from '@/lib/auth';
|
||||
|
||||
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> = {
|
||||
current_page: `You are a helpful assistant that summarizes text content.
|
||||
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) {
|
||||
try {
|
||||
// Authentication check - must pass before processing any other headers
|
||||
const authError = authenticateRequest(req);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
// Get configuration from headers
|
||||
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';
|
||||
|
||||
// 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
|
||||
// API key selection only occurs after authentication has passed
|
||||
let apiKey = req.headers.get('x-summary-api-key') || '';
|
||||
if (!apiKey) {
|
||||
switch (provider) {
|
||||
|
|
@ -136,13 +251,21 @@ export async function POST(req: NextRequest) {
|
|||
break;
|
||||
}
|
||||
case 'custom-openai': {
|
||||
if (!baseUrl) {
|
||||
if (!requestedBaseUrl) {
|
||||
const errorBody: SummarizeError = {
|
||||
code: 'MISSING_BASE_URL',
|
||||
message: 'Custom provider requires a base URL',
|
||||
};
|
||||
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({
|
||||
apiKey: apiKey || 'not-needed',
|
||||
baseURL: baseUrl,
|
||||
|
|
|
|||
Loading…
Reference in a new issue