From 9b74d463d55489335ee8dbba7e9890703419652d Mon Sep 17 00:00:00 2001 From: KARLalpha4768 <48635074+KARLalpha4768@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:16:40 -0400 Subject: [PATCH] Lock down /api/nlp: API key auth, rate limit, input validation --- api/nlp.js | 143 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 127 insertions(+), 16 deletions(-) diff --git a/api/nlp.js b/api/nlp.js index 11e4221..5cce40d 100644 --- a/api/nlp.js +++ b/api/nlp.js @@ -1,17 +1,106 @@ // Vercel Serverless Function — Gemini NLU for SentinelForge Terminal +import { timingSafeEqual } from 'node:crypto'; + +// Best-effort in-memory per-IP rate limit (20 req/min) +const ipHits = new Map(); + +function checkRateLimit(ip) { + const now = Date.now(); + const windowMs = 60 * 1000; + const maxReqs = 20; + const hits = (ipHits.get(ip) || []).filter(t => now - t < windowMs); + + if (hits.length >= maxReqs) { + return false; + } + hits.push(now); + ipHits.set(ip, hits); + + if (ipHits.size > 2000) { + for (const [k, v] of ipHits.entries()) { + if (v.length === 0 || now - v[v.length - 1] > windowMs) { + ipHits.delete(k); + } + } + } + return true; +} + +function validApiKey(provided, expected) { + if (typeof provided !== 'string' || typeof expected !== 'string') { + return false; + } + const providedBytes = Buffer.from(provided, 'utf8'); + const expectedBytes = Buffer.from(expected, 'utf8'); + return providedBytes.length === expectedBytes.length + && timingSafeEqual(providedBytes, expectedBytes); +} + +const ALLOWED_COMMANDS = new Set([ + 'ls', 'cat', 'cd', 'pwd', 'tree', 'find', 'head', 'grep', 'wc', + 'python', 'pytest', 'git', 'make', 'nano', 'ssh', 'slingshot-pipeline', + 'jetson_health', 'df', 'uptime', 'whoami', 'uname', 'date', 'echo', + 'clear', 'help', '_reply' +]); + export default async function handler(req, res) { - res.setHeader('Access-Control-Allow-Origin', '*'); + const origin = req.headers.origin; + const allowedOrigins = (process.env.ALLOWED_ORIGINS || 'https://sentinelforge.vercel.app') + .split(',') + .map(s => s.trim()) + .filter(Boolean); + + if (origin) { + if (allowedOrigins.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } else { + return res.status(403).json({ error: 'Origin not allowed' }); + } + } + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - if (req.method === 'OPTIONS') return res.status(200).end(); - if (req.method !== 'POST') return res.status(405).json({ error: 'POST only' }); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key'); - const { input } = req.body || {}; - if (!input || typeof input !== 'string' || input.length > 500) + if (req.method === 'OPTIONS') { + return res.status(204).end(); + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'POST only' }); + } + + // Authentication is mandatory and fail-closed when the server is not configured. + const expectedApiKey = process.env.NLP_API_KEY; + if (!expectedApiKey) { + return res.status(503).json({ error: 'Service authentication is not configured' }); + } + if (!validApiKey(req.headers['x-api-key'], expectedApiKey)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + // Best-effort per-IP rate limiting. Serverless instances do not share memory, + // so this bounds bursts per warm instance rather than providing a global quota. + const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown'; + if (!checkRateLimit(ip)) { + return res.status(429).json({ error: 'Too many requests. Please wait a minute.' }); + } + + // Strict input validation + let input = req.body?.input; + if (!input || typeof input !== 'string') { return res.status(400).json({ error: 'Invalid input' }); + } + // Strip control characters (retain printable characters and whitespace) + input = input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').trim(); + if (input.length < 1 || input.length > 500) { + return res.status(400).json({ error: 'Invalid input length' }); + } const apiKey = process.env.GEMINI_API_KEY; - if (!apiKey) return res.status(500).json({ error: 'API key not configured' }); + if (!apiKey) { + return res.status(500).json({ error: 'API key not configured' }); + } const systemPrompt = `You are a command interpreter for the SentinelForge space surveillance terminal. The user types natural language. You must respond with ONLY a JSON object — no markdown, no explanation. @@ -64,9 +153,10 @@ Examples: "how's the gpu?" → {"cmd":"jetson_health","args":[],"narr":"Running GPU diagnostics..."} "hello" → {"cmd":"_reply","text":"Hello! I'm the SentinelForge terminal. Ask me anything about the codebase, or tell me what to run."}`; + // Strict fixed model allow-list (cannot be used as an open proxy) const models = ['gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-2.0-flash-lite']; const reqBody = JSON.stringify({ - contents: [{ parts: [{ text: `${systemPrompt}\n\nUser input: "${input}"` }] }], + contents: [{ parts: [{ text: `${systemPrompt}\n\nUser input: ${JSON.stringify(input)}` }] }], generationConfig: { temperature: 0.1, maxOutputTokens: 256, @@ -76,19 +166,25 @@ Examples: for (const model of models) { try { + // Pass API key via header, NEVER in URL query params const response = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, - { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: reqBody } + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': apiKey + }, + body: reqBody + } ); if (response.status === 503 || response.status === 429) { - console.log(`${model}: ${response.status}, trying next model...`); continue; // Try next model } if (!response.ok) { - const errText = await response.text(); - console.error(`${model} error:`, errText); + // Do not leak upstream error body to client continue; } @@ -96,13 +192,28 @@ Examples: const text = data?.candidates?.[0]?.content?.parts?.[0]?.text; if (!text) continue; - const parsed = JSON.parse(text); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + continue; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return res.status(200).json({ cmd: '_reply', text: 'Command not recognized.' }); + } + + // Validate command is from allowed set + if (typeof parsed.cmd !== 'string' || !ALLOWED_COMMANDS.has(parsed.cmd)) { + return res.status(200).json({ cmd: '_reply', text: 'Command not recognized.' }); + } + return res.status(200).json(parsed); - } catch (err) { - console.error(`${model} failed:`, err.message); + } catch { continue; } } + // Generic failure response without leaking internal errors return res.status(502).json({ error: 'All models unavailable. Try again in a moment.' }); }