From cd04d34b2373a3c139e36d18e12f345d85010c3f Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 3 Feb 2026 06:59:07 -0700 Subject: [PATCH] Security audit #1 --- .../kaboot-setup-production.yaml.example | 14 +++- authentik/blueprints/kaboot-setup.yaml | 14 +++- server/src/middleware/auth.ts | 16 +++-- server/src/routes/upload.ts | 51 ++++++++++++- server/src/services/documentParser.ts | 72 +++++++++---------- services/geminiService.ts | 7 +- src/config/oidc.ts | 2 +- vite.config.ts | 11 ++- 8 files changed, 131 insertions(+), 56 deletions(-) diff --git a/authentik/blueprints/kaboot-setup-production.yaml.example b/authentik/blueprints/kaboot-setup-production.yaml.example index ad58b1a..d96f1fb 100644 --- a/authentik/blueprints/kaboot-setup-production.yaml.example +++ b/authentik/blueprints/kaboot-setup-production.yaml.example @@ -124,7 +124,7 @@ entries: name: kaboot-ai-access # ═══════════════════════════════════════════════════════════════════════════════ - # GROUPS SCOPE MAPPING + # SCOPE MAPPINGS # ═══════════════════════════════════════════════════════════════════════════════ - id: groups-scope-mapping @@ -138,6 +138,17 @@ entries: expression: | return {"groups": [group.name for group in request.user.ak_groups.all()]} + - id: audience-scope-mapping + model: authentik_providers_oauth2.scopemapping + identifiers: + managed: goauthentik.io/providers/oauth2/scope-kaboot-audience + attrs: + name: "Kaboot Audience Scope" + scope_name: kaboot + description: "Include audience claim for Kaboot backend validation" + expression: | + return {"aud": "kaboot-spa"} + # ═══════════════════════════════════════════════════════════════════════════════ # OAUTH2/OIDC PROVIDER # ═══════════════════════════════════════════════════════════════════════════════ @@ -172,6 +183,7 @@ entries: - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] - !Find [authentik_providers_oauth2.scopemapping, [scope_name, offline_access]] - !KeyOf groups-scope-mapping + - !KeyOf audience-scope-mapping # ═══════════════════════════════════════════════════════════════════════════════ # APPLICATION diff --git a/authentik/blueprints/kaboot-setup.yaml b/authentik/blueprints/kaboot-setup.yaml index 1c9e214..d6372f9 100644 --- a/authentik/blueprints/kaboot-setup.yaml +++ b/authentik/blueprints/kaboot-setup.yaml @@ -124,7 +124,7 @@ entries: name: kaboot-early-access # ═══════════════════════════════════════════════════════════════════════════════ - # GROUPS SCOPE MAPPING + # SCOPE MAPPINGS # ═══════════════════════════════════════════════════════════════════════════════ - id: groups-scope-mapping @@ -138,6 +138,17 @@ entries: expression: | return {"groups": [group.name for group in request.user.ak_groups.all()]} + - id: audience-scope-mapping + model: authentik_providers_oauth2.scopemapping + identifiers: + managed: goauthentik.io/providers/oauth2/scope-kaboot-audience + attrs: + name: "Kaboot Audience Scope" + scope_name: kaboot + description: "Include audience claim for Kaboot backend validation" + expression: | + return {"aud": "kaboot-spa"} + # ═══════════════════════════════════════════════════════════════════════════════ # OAUTH2/OIDC PROVIDER # ═══════════════════════════════════════════════════════════════════════════════ @@ -172,6 +183,7 @@ entries: - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] - !Find [authentik_providers_oauth2.scopemapping, [scope_name, offline_access]] - !KeyOf groups-scope-mapping + - !KeyOf audience-scope-mapping # ═══════════════════════════════════════════════════════════════════════════════ # APPLICATION diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index b660447..da2fc5b 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -5,6 +5,7 @@ import jwksClient from 'jwks-rsa'; const OIDC_ISSUER = process.env.OIDC_ISSUER || 'http://localhost:9000/application/o/kaboot/'; const OIDC_JWKS_URI = process.env.OIDC_JWKS_URI || 'http://localhost:9000/application/o/kaboot/jwks/'; const OIDC_INTERNAL_JWKS_URI = process.env.OIDC_INTERNAL_JWKS_URI || OIDC_JWKS_URI; +const OIDC_AUDIENCE = process.env.OIDC_AUDIENCE || process.env.OIDC_CLIENT_ID; const client = jwksClient({ jwksUri: OIDC_INTERNAL_JWKS_URI, @@ -55,17 +56,22 @@ export function requireAuth( const token = authHeader.slice(7); + const verifyOptions: jwt.VerifyOptions = { + issuer: OIDC_ISSUER, + algorithms: ['RS256'], + }; + if (OIDC_AUDIENCE) { + verifyOptions.audience = OIDC_AUDIENCE; + } + jwt.verify( token, getSigningKey, - { - issuer: OIDC_ISSUER, - algorithms: ['RS256'], - }, + verifyOptions, (err, decoded) => { if (err) { console.error('Token verification failed:', err.message); - res.status(401).json({ error: 'Invalid token', details: err.message }); + res.status(401).json({ error: 'Invalid token' }); return; } diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index 333e2b9..8db13b2 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -1,12 +1,52 @@ -import { Router } from 'express'; +import { Router, Response, NextFunction } from 'express'; import multer from 'multer'; +import rateLimit from 'express-rate-limit'; import { processDocument, SUPPORTED_TYPES, normalizeMimeType } from '../services/documentParser.js'; import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js'; import { getSubscriptionStatus } from '../services/stripe.js'; const router = Router(); +const isDev = process.env.NODE_ENV !== 'production'; +const isTest = process.env.NODE_ENV === 'test'; + +const freeUploadLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: isDev ? 50 : 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many uploads, please try again later.' }, + skip: () => isTest, + keyGenerator: (req: AuthenticatedRequest) => req.user?.sub || req.ip || 'unknown', +}); + +const paidUploadLimiter = rateLimit({ + windowMs: 5 * 60 * 1000, + max: isDev ? 200 : 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many uploads, please try again later.' }, + skip: () => isTest, + keyGenerator: (req: AuthenticatedRequest) => req.user?.sub || req.ip || 'unknown', +}); + +function tieredUploadLimiter(req: AuthenticatedRequest, res: Response, next: NextFunction) { + const groups = req.user?.groups || []; + const hasGroupAccess = groups.includes('kaboot-ai-access'); + const status = req.user ? getSubscriptionStatus(req.user.sub) : null; + const isPaidUser = hasGroupAccess || status?.status === 'active'; + + if (isPaidUser) { + return paidUploadLimiter(req, res, next); + } + return freeUploadLimiter(req, res, next); +} + +let activeUploads = 0; +const MAX_CONCURRENT_UPLOADS = 5; + router.use(requireAuth); +router.use(tieredUploadLimiter); const storage = multer.memoryStorage(); @@ -26,8 +66,15 @@ const upload = multer({ }); router.post('/', upload.single('document'), async (req: AuthenticatedRequest, res) => { + if (activeUploads >= MAX_CONCURRENT_UPLOADS) { + return res.status(503).json({ error: 'Server busy processing uploads. Please try again shortly.' }); + } + + activeUploads++; + try { if (!req.file) { + activeUploads--; return res.status(400).json({ error: 'No file uploaded' }); } @@ -65,6 +112,8 @@ router.post('/', upload.single('document'), async (req: AuthenticatedRequest, re res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to process document' }); + } finally { + activeUploads--; } }); diff --git a/server/src/services/documentParser.ts b/server/src/services/documentParser.ts index 663cb17..bf7f88c 100644 --- a/server/src/services/documentParser.ts +++ b/server/src/services/documentParser.ts @@ -2,11 +2,13 @@ import officeParser from 'officeparser'; import WordExtractor from 'word-extractor'; import * as XLSX from 'xlsx'; import { execSync } from 'child_process'; -import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'fs'; +import { writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync, rmdirSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { randomUUID } from 'crypto'; +const PROCESSING_TIMEOUT_MS = 30000; + export const GEMINI_NATIVE_TYPES = [ 'application/pdf', 'text/plain', @@ -207,62 +209,60 @@ const LEGACY_TO_MODERN: Record = { }; async function extractWithLibreOffice(buffer: Buffer, extension: string, useOcr: boolean = false): Promise { - // Use LibreOffice to convert legacy Office files to modern format, then parse const tempId = randomUUID(); - const tempDir = tmpdir(); - const inputPath = join(tempDir, `input-${tempId}${extension}`); + const privateTempDir = join(tmpdir(), `kaboot-${tempId}`); + const inputPath = join(privateTempDir, `input${extension}`); const modernExt = LEGACY_TO_MODERN[extension] || 'pdf'; - const outputPath = join(tempDir, `input-${tempId}.${modernExt}`); + const outputPath = join(privateTempDir, `input.${modernExt}`); + const cleanup = () => { + try { unlinkSync(inputPath); } catch { /* ignore */ } + try { unlinkSync(outputPath); } catch { /* ignore */ } + try { rmdirSync(privateTempDir); } catch { /* ignore */ } + }; + try { - // Write input file - writeFileSync(inputPath, buffer); + mkdirSync(privateTempDir, { mode: 0o700 }); + writeFileSync(inputPath, buffer, { mode: 0o600 }); - // Convert to modern format using LibreOffice try { execSync( - `libreoffice --headless --convert-to ${modernExt} --outdir "${tempDir}" "${inputPath}"`, - { timeout: 60000, stdio: 'pipe' } + `libreoffice --headless --convert-to ${modernExt} --outdir "${privateTempDir}" "${inputPath}"`, + { timeout: PROCESSING_TIMEOUT_MS, stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 } ); } catch (execError) { - // LibreOffice not available or conversion failed - const error = execError as Error & { code?: string }; + const error = execError as Error & { code?: string; killed?: boolean }; + if (error.killed) { + throw new Error('Document conversion timed out. Try a smaller file.'); + } if (error.code === 'ENOENT' || error.message?.includes('not found')) { throw new Error( `Legacy ${extension} files require LibreOffice for text extraction. ` + `Please convert to .${modernExt} format or ensure LibreOffice is installed.` ); } - throw new Error(`LibreOffice conversion failed: ${error.message}`); + throw new Error('Document conversion failed. The file may be corrupted.'); } - // Read the converted file and extract text using officeparser (with OCR if enabled) - if (existsSync(outputPath)) { - const convertedBuffer = readFileSync(outputPath); - const config = useOcr ? { - extractAttachments: true, - ocr: true, - ocrLanguage: 'eng' - } : {}; - const ast = await officeParser.parseOffice(convertedBuffer, config); - let text = ast.toText(); - - // Include OCR text from attachments if available - if (useOcr && ast.attachments) { - for (const attachment of ast.attachments) { - if (attachment.ocrText) { - text += '\n' + attachment.ocrText; - } + if (!existsSync(outputPath)) { + throw new Error('Document conversion produced no output.'); + } + + const convertedBuffer = readFileSync(outputPath); + const config = useOcr ? { extractAttachments: true, ocr: true, ocrLanguage: 'eng' } : {}; + const ast = await officeParser.parseOffice(convertedBuffer, config); + let text = ast.toText(); + + if (useOcr && ast.attachments) { + for (const attachment of ast.attachments) { + if (attachment.ocrText) { + text += '\n' + attachment.ocrText; } } - return text; } - - throw new Error('LibreOffice conversion produced no output'); + return text; } finally { - // Cleanup temp files - try { unlinkSync(inputPath); } catch { /* ignore */ } - try { unlinkSync(outputPath); } catch { /* ignore */ } + cleanup(); } } diff --git a/services/geminiService.ts b/services/geminiService.ts index a408c20..dbd4941 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -4,11 +4,10 @@ import { buildQuizPrompt, JSON_EXAMPLE_GUIDANCE } from "../server/src/shared/qui import { v4 as uuidv4 } from 'uuid'; const getGeminiClient = (apiKey?: string) => { - const key = apiKey || process.env.API_KEY; - if (!key) { - throw new Error("Gemini API key is missing"); + if (!apiKey) { + throw new Error("Gemini API key is required. Use your own API key or enable System AI."); } - return new GoogleGenAI({ apiKey: key }); + return new GoogleGenAI({ apiKey }); }; const DEFAULT_GEMINI_MODEL = 'gemini-3-flash-preview'; diff --git a/src/config/oidc.ts b/src/config/oidc.ts index 4fd04fa..4149d0b 100644 --- a/src/config/oidc.ts +++ b/src/config/oidc.ts @@ -10,7 +10,7 @@ export const oidcConfig = { redirect_uri: `${window.location.origin}/callback`, post_logout_redirect_uri: window.location.origin, response_type: 'code', - scope: 'openid profile email offline_access groups', + scope: 'openid profile email offline_access groups kaboot', automaticSilentRenew: true, silentRequestTimeoutInSeconds: 10, loadUserInfo: true, diff --git a/vite.config.ts b/vite.config.ts index af3544b..b28bd34 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,19 +1,16 @@ import path from 'path'; -import { defineConfig, loadEnv } from 'vite'; +import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, '.', ''); +export default defineConfig(() => { return { server: { port: 5173, host: '0.0.0.0', }, plugins: [react()], - define: { - 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), - 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) - }, + // SECURITY: Do NOT expose GEMINI_API_KEY to frontend - use /api/generate endpoint + define: {}, resolve: { alias: { '@': path.resolve(__dirname, '.'),