Security audit #1
This commit is contained in:
parent
68bc591150
commit
cd04d34b23
8 changed files with 131 additions and 56 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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--;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 */ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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, '.'),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue