Security audit #1

This commit is contained in:
Joey Yakimowich-Payne 2026-02-03 06:59:07 -07:00
commit cd04d34b23
8 changed files with 131 additions and 56 deletions

View file

@ -124,7 +124,7 @@ entries:
name: kaboot-ai-access name: kaboot-ai-access
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# GROUPS SCOPE MAPPING # SCOPE MAPPINGS
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
- id: groups-scope-mapping - id: groups-scope-mapping
@ -138,6 +138,17 @@ entries:
expression: | expression: |
return {"groups": [group.name for group in request.user.ak_groups.all()]} 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 # OAUTH2/OIDC PROVIDER
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@ -172,6 +183,7 @@ entries:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, offline_access]] - !Find [authentik_providers_oauth2.scopemapping, [scope_name, offline_access]]
- !KeyOf groups-scope-mapping - !KeyOf groups-scope-mapping
- !KeyOf audience-scope-mapping
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# APPLICATION # APPLICATION

View file

@ -124,7 +124,7 @@ entries:
name: kaboot-early-access name: kaboot-early-access
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# GROUPS SCOPE MAPPING # SCOPE MAPPINGS
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
- id: groups-scope-mapping - id: groups-scope-mapping
@ -138,6 +138,17 @@ entries:
expression: | expression: |
return {"groups": [group.name for group in request.user.ak_groups.all()]} 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 # OAUTH2/OIDC PROVIDER
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@ -172,6 +183,7 @@ entries:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, offline_access]] - !Find [authentik_providers_oauth2.scopemapping, [scope_name, offline_access]]
- !KeyOf groups-scope-mapping - !KeyOf groups-scope-mapping
- !KeyOf audience-scope-mapping
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# APPLICATION # APPLICATION

View file

@ -5,6 +5,7 @@ import jwksClient from 'jwks-rsa';
const OIDC_ISSUER = process.env.OIDC_ISSUER || 'http://localhost:9000/application/o/kaboot/'; 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_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_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({ const client = jwksClient({
jwksUri: OIDC_INTERNAL_JWKS_URI, jwksUri: OIDC_INTERNAL_JWKS_URI,
@ -55,17 +56,22 @@ export function requireAuth(
const token = authHeader.slice(7); const token = authHeader.slice(7);
const verifyOptions: jwt.VerifyOptions = {
issuer: OIDC_ISSUER,
algorithms: ['RS256'],
};
if (OIDC_AUDIENCE) {
verifyOptions.audience = OIDC_AUDIENCE;
}
jwt.verify( jwt.verify(
token, token,
getSigningKey, getSigningKey,
{ verifyOptions,
issuer: OIDC_ISSUER,
algorithms: ['RS256'],
},
(err, decoded) => { (err, decoded) => {
if (err) { if (err) {
console.error('Token verification failed:', err.message); 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; return;
} }

View file

@ -1,12 +1,52 @@
import { Router } from 'express'; import { Router, Response, NextFunction } from 'express';
import multer from 'multer'; import multer from 'multer';
import rateLimit from 'express-rate-limit';
import { processDocument, SUPPORTED_TYPES, normalizeMimeType } from '../services/documentParser.js'; import { processDocument, SUPPORTED_TYPES, normalizeMimeType } from '../services/documentParser.js';
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js'; import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
import { getSubscriptionStatus } from '../services/stripe.js'; import { getSubscriptionStatus } from '../services/stripe.js';
const router = Router(); 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(requireAuth);
router.use(tieredUploadLimiter);
const storage = multer.memoryStorage(); const storage = multer.memoryStorage();
@ -26,8 +66,15 @@ const upload = multer({
}); });
router.post('/', upload.single('document'), async (req: AuthenticatedRequest, res) => { 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 { try {
if (!req.file) { if (!req.file) {
activeUploads--;
return res.status(400).json({ error: 'No file uploaded' }); 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({ res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to process document' error: error instanceof Error ? error.message : 'Failed to process document'
}); });
} finally {
activeUploads--;
} }
}); });

View file

@ -2,11 +2,13 @@ import officeParser from 'officeparser';
import WordExtractor from 'word-extractor'; import WordExtractor from 'word-extractor';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { execSync } from 'child_process'; 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 { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
const PROCESSING_TIMEOUT_MS = 30000;
export const GEMINI_NATIVE_TYPES = [ export const GEMINI_NATIVE_TYPES = [
'application/pdf', 'application/pdf',
'text/plain', 'text/plain',
@ -207,62 +209,60 @@ const LEGACY_TO_MODERN: Record<string, string> = {
}; };
async function extractWithLibreOffice(buffer: Buffer, extension: string, useOcr: boolean = false): Promise<string> { async function extractWithLibreOffice(buffer: Buffer, extension: string, useOcr: boolean = false): Promise<string> {
// Use LibreOffice to convert legacy Office files to modern format, then parse
const tempId = randomUUID(); const tempId = randomUUID();
const tempDir = tmpdir(); const privateTempDir = join(tmpdir(), `kaboot-${tempId}`);
const inputPath = join(tempDir, `input-${tempId}${extension}`); const inputPath = join(privateTempDir, `input${extension}`);
const modernExt = LEGACY_TO_MODERN[extension] || 'pdf'; 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 { try {
// Write input file mkdirSync(privateTempDir, { mode: 0o700 });
writeFileSync(inputPath, buffer); writeFileSync(inputPath, buffer, { mode: 0o600 });
// Convert to modern format using LibreOffice
try { try {
execSync( execSync(
`libreoffice --headless --convert-to ${modernExt} --outdir "${tempDir}" "${inputPath}"`, `libreoffice --headless --convert-to ${modernExt} --outdir "${privateTempDir}" "${inputPath}"`,
{ timeout: 60000, stdio: 'pipe' } { timeout: PROCESSING_TIMEOUT_MS, stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 }
); );
} catch (execError) { } catch (execError) {
// LibreOffice not available or conversion failed const error = execError as Error & { code?: string; killed?: boolean };
const error = execError as Error & { code?: string }; if (error.killed) {
throw new Error('Document conversion timed out. Try a smaller file.');
}
if (error.code === 'ENOENT' || error.message?.includes('not found')) { if (error.code === 'ENOENT' || error.message?.includes('not found')) {
throw new Error( throw new Error(
`Legacy ${extension} files require LibreOffice for text extraction. ` + `Legacy ${extension} files require LibreOffice for text extraction. ` +
`Please convert to .${modernExt} format or ensure LibreOffice is installed.` `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)) {
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();
// Include OCR text from attachments if available const convertedBuffer = readFileSync(outputPath);
if (useOcr && ast.attachments) { const config = useOcr ? { extractAttachments: true, ocr: true, ocrLanguage: 'eng' } : {};
for (const attachment of ast.attachments) { const ast = await officeParser.parseOffice(convertedBuffer, config);
if (attachment.ocrText) { let text = ast.toText();
text += '\n' + attachment.ocrText;
} if (useOcr && ast.attachments) {
for (const attachment of ast.attachments) {
if (attachment.ocrText) {
text += '\n' + attachment.ocrText;
} }
} }
return text;
} }
return text;
throw new Error('LibreOffice conversion produced no output');
} finally { } finally {
// Cleanup temp files cleanup();
try { unlinkSync(inputPath); } catch { /* ignore */ }
try { unlinkSync(outputPath); } catch { /* ignore */ }
} }
} }

View file

@ -4,11 +4,10 @@ import { buildQuizPrompt, JSON_EXAMPLE_GUIDANCE } from "../server/src/shared/qui
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const getGeminiClient = (apiKey?: string) => { const getGeminiClient = (apiKey?: string) => {
const key = apiKey || process.env.API_KEY; if (!apiKey) {
if (!key) { throw new Error("Gemini API key is required. Use your own API key or enable System AI.");
throw new Error("Gemini API key is missing");
} }
return new GoogleGenAI({ apiKey: key }); return new GoogleGenAI({ apiKey });
}; };
const DEFAULT_GEMINI_MODEL = 'gemini-3-flash-preview'; const DEFAULT_GEMINI_MODEL = 'gemini-3-flash-preview';

View file

@ -10,7 +10,7 @@ export const oidcConfig = {
redirect_uri: `${window.location.origin}/callback`, redirect_uri: `${window.location.origin}/callback`,
post_logout_redirect_uri: window.location.origin, post_logout_redirect_uri: window.location.origin,
response_type: 'code', response_type: 'code',
scope: 'openid profile email offline_access groups', scope: 'openid profile email offline_access groups kaboot',
automaticSilentRenew: true, automaticSilentRenew: true,
silentRequestTimeoutInSeconds: 10, silentRequestTimeoutInSeconds: 10,
loadUserInfo: true, loadUserInfo: true,

View file

@ -1,19 +1,16 @@
import path from 'path'; import path from 'path';
import { defineConfig, loadEnv } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => { export default defineConfig(() => {
const env = loadEnv(mode, '.', '');
return { return {
server: { server: {
port: 5173, port: 5173,
host: '0.0.0.0', host: '0.0.0.0',
}, },
plugins: [react()], plugins: [react()],
define: { // SECURITY: Do NOT expose GEMINI_API_KEY to frontend - use /api/generate endpoint
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), define: {},
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, '.'), '@': path.resolve(__dirname, '.'),