From 70df689701fb10b912eca8e462010b1faed0ad19 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 3 Feb 2026 08:24:48 -0700 Subject: [PATCH 1/3] Sandboxing --- docker-compose.prod.yml | 27 +++++ docker-compose.yml | 30 +++++ server/Dockerfile | 3 +- server/sandbox/Dockerfile | 23 ++++ server/sandbox/convert.js | 168 ++++++++++++++++++++++++++ server/sandbox/package.json | 9 ++ server/src/services/documentParser.ts | 98 ++++++++++----- 7 files changed, 324 insertions(+), 34 deletions(-) create mode 100644 server/sandbox/Dockerfile create mode 100644 server/sandbox/convert.js create mode 100644 server/sandbox/package.json diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6fb48aa..3275186 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -85,6 +85,29 @@ services: networks: - kaboot-network + kaboot-sandbox: + build: + context: ./server/sandbox + dockerfile: Dockerfile + restart: unless-stopped + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + environment: + MAX_CONCURRENT: "2" + tmpfs: + - /tmp:size=200M,mode=1777 + deploy: + replicas: ${SANDBOX_REPLICAS:-4} + resources: + limits: + cpus: '1' + memory: 512M + networks: + - kaboot-network + kaboot-backend: build: context: ./server @@ -105,10 +128,14 @@ services: STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} STRIPE_PRICE_ID_MONTHLY: ${STRIPE_PRICE_ID_MONTHLY:-} STRIPE_PRICE_ID_YEARLY: ${STRIPE_PRICE_ID_YEARLY:-} + SANDBOX_URL: http://kaboot-sandbox:3002 + USE_SANDBOX: "true" volumes: - kaboot-data:/data tmpfs: - /tmp:size=100M + depends_on: + - kaboot-sandbox networks: - kaboot-network diff --git a/docker-compose.yml b/docker-compose.yml index 1a9e809..43f7abb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -97,6 +97,33 @@ services: networks: - kaboot-network + # ═══════════════════════════════════════════════════════════════════════════ + # KABOOT - Document Conversion Sandbox (isolated LibreOffice) + # ═══════════════════════════════════════════════════════════════════════════ + + kaboot-sandbox: + build: + context: ./server/sandbox + dockerfile: Dockerfile + restart: unless-stopped + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + environment: + MAX_CONCURRENT: "2" + tmpfs: + - /tmp:size=200M,mode=1777 + deploy: + replicas: ${SANDBOX_REPLICAS:-2} + resources: + limits: + cpus: '1' + memory: 512M + networks: + - kaboot-network + # ═══════════════════════════════════════════════════════════════════════════ # KABOOT - Application Backend # ═══════════════════════════════════════════════════════════════════════════ @@ -118,6 +145,8 @@ services: CORS_ORIGIN: http://localhost:${KABOOT_FRONTEND_PORT:-5173},http://${KABOOT_HOST:-localhost}:${KABOOT_FRONTEND_PORT:-5173} LOG_REQUESTS: ${LOG_REQUESTS:-true} GEMINI_API_KEY: ${GEMINI_API_KEY:-} + SANDBOX_URL: http://kaboot-sandbox:3002 + USE_SANDBOX: "true" volumes: - ./data:/data tmpfs: @@ -126,6 +155,7 @@ services: - "${KABOOT_BACKEND_PORT:-3001}:3001" depends_on: - authentik-server + - kaboot-sandbox networks: - kaboot-network diff --git a/server/Dockerfile b/server/Dockerfile index 1b1dfc1..9439812 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -2,8 +2,7 @@ FROM node:22-alpine WORKDIR /app -# Build dependencies + LibreOffice for legacy Office format conversion -RUN apk add --no-cache python3 make g++ libreoffice curl +RUN apk add --no-cache python3 make g++ curl COPY package*.json ./ RUN npm install diff --git a/server/sandbox/Dockerfile b/server/sandbox/Dockerfile new file mode 100644 index 0000000..1b28fc9 --- /dev/null +++ b/server/sandbox/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-alpine + +RUN apk add --no-cache libreoffice curl \ + && addgroup -g 1000 sandbox \ + && adduser -u 1000 -G sandbox -s /bin/sh -D sandbox \ + && mkdir -p /app /tmp/convert \ + && chown -R sandbox:sandbox /app /tmp/convert + +WORKDIR /app + +COPY --chown=sandbox:sandbox package.json ./ +RUN npm install --omit=dev + +COPY --chown=sandbox:sandbox convert.js ./ + +USER sandbox + +EXPOSE 3002 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3002/health || exit 1 + +CMD ["node", "convert.js"] diff --git a/server/sandbox/convert.js b/server/sandbox/convert.js new file mode 100644 index 0000000..10eaf09 --- /dev/null +++ b/server/sandbox/convert.js @@ -0,0 +1,168 @@ +import { createServer } from 'http'; +import { exec } from 'child_process'; +import { writeFile, readFile, unlink, mkdir, readdir } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; + +const PORT = process.env.PORT || 3002; +const TEMP_DIR = '/tmp/convert'; +const TIMEOUT_MS = 30000; +const MAX_FILE_SIZE = 50 * 1024 * 1024; +const MAX_CONCURRENT = parseInt(process.env.MAX_CONCURRENT || '3', 10); + +const EXTENSION_TO_OUTPUT = { + '.ppt': 'pptx', + '.doc': 'docx', + '.xls': 'xlsx', +}; + +let activeJobs = 0; +let queuedJobs = 0; + +async function cleanup(dir) { + if (!existsSync(dir)) return; + try { + for (const file of await readdir(dir)) { + await unlink(join(dir, file)).catch(() => {}); + } + await unlink(dir).catch(() => {}); + } catch {} +} + +function runLibreOffice(inputPath, outputDir, outputExt) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('timeout')); + }, TIMEOUT_MS); + + exec( + `libreoffice --headless --convert-to ${outputExt} --outdir "${outputDir}" "${inputPath}"`, + { maxBuffer: 10 * 1024 * 1024 }, + (error) => { + clearTimeout(timer); + if (error) reject(error); + else resolve(); + } + ); + }); +} + +async function processConversion(buffer, ext) { + const jobId = randomUUID(); + const jobDir = join(TEMP_DIR, jobId); + const inputPath = join(jobDir, `input${ext}`); + const outputExt = EXTENSION_TO_OUTPUT[ext]; + const outputPath = join(jobDir, `input.${outputExt}`); + + try { + await mkdir(jobDir, { mode: 0o700 }); + await writeFile(inputPath, buffer, { mode: 0o600 }); + + await runLibreOffice(inputPath, jobDir, outputExt); + + if (!existsSync(outputPath)) { + throw new Error('no_output'); + } + + return await readFile(outputPath); + } finally { + await cleanup(jobDir); + } +} + +async function handleConvert(req, res) { + if (activeJobs >= MAX_CONCURRENT) { + queuedJobs++; + if (queuedJobs > MAX_CONCURRENT * 2) { + queuedJobs--; + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Server busy, try again later' })); + return; + } + + await new Promise((resolve) => { + const check = setInterval(() => { + if (activeJobs < MAX_CONCURRENT) { + clearInterval(check); + queuedJobs--; + resolve(); + } + }, 100); + }); + } + + activeJobs++; + + const chunks = []; + let size = 0; + + req.on('data', (chunk) => { + size += chunk.length; + if (size <= MAX_FILE_SIZE) { + chunks.push(chunk); + } + }); + + req.on('end', async () => { + if (size > MAX_FILE_SIZE) { + activeJobs--; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'File too large' })); + return; + } + + const url = new URL(req.url, `http://localhost:${PORT}`); + const ext = url.searchParams.get('ext'); + + if (!ext || !EXTENSION_TO_OUTPUT[ext]) { + activeJobs--; + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid or missing extension parameter' })); + return; + } + + try { + const converted = await processConversion(Buffer.concat(chunks), ext); + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Length': converted.length, + }); + res.end(converted); + } catch (err) { + const message = err.message === 'timeout' + ? 'Conversion timed out' + : err.message === 'no_output' + ? 'Conversion produced no output' + : 'Conversion failed'; + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: message })); + } finally { + activeJobs--; + } + }); +} + +const server = createServer((req, res) => { + if (req.method === 'GET' && req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', active: activeJobs, queued: queuedJobs })); + return; + } + + if (req.method === 'POST' && req.url?.startsWith('/convert')) { + handleConvert(req, res); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); +}); + +if (!existsSync(TEMP_DIR)) { + await mkdir(TEMP_DIR, { recursive: true, mode: 0o700 }); +} + +server.listen(PORT, '0.0.0.0', () => { + console.log(`Sandbox running on port ${PORT} (max ${MAX_CONCURRENT} concurrent)`); +}); diff --git a/server/sandbox/package.json b/server/sandbox/package.json new file mode 100644 index 0000000..685582d --- /dev/null +++ b/server/sandbox/package.json @@ -0,0 +1,9 @@ +{ + "name": "kaboot-sandbox", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "start": "node convert.js" + } +} diff --git a/server/src/services/documentParser.ts b/server/src/services/documentParser.ts index bf7f88c..c2d4643 100644 --- a/server/src/services/documentParser.ts +++ b/server/src/services/documentParser.ts @@ -8,6 +8,8 @@ import { join } from 'path'; import { randomUUID } from 'crypto'; const PROCESSING_TIMEOUT_MS = 30000; +const SANDBOX_URL = process.env.SANDBOX_URL || 'http://localhost:3002'; +const USE_SANDBOX = process.env.USE_SANDBOX !== 'false'; export const GEMINI_NATIVE_TYPES = [ 'application/pdf', @@ -201,14 +203,41 @@ function extractWithSheetJS(buffer: Buffer): string { return textParts.join('\n\n'); } -// Map legacy extensions to their modern equivalents for LibreOffice conversion const LEGACY_TO_MODERN: Record = { '.ppt': 'pptx', '.doc': 'docx', '.xls': 'xlsx', }; -async function extractWithLibreOffice(buffer: Buffer, extension: string, useOcr: boolean = false): Promise { +async function convertViaSandbox(buffer: Buffer, extension: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PROCESSING_TIMEOUT_MS); + + try { + const response = await fetch(`${SANDBOX_URL}/convert?ext=${extension}`, { + method: 'POST', + body: buffer, + signal: controller.signal, + headers: { 'Content-Type': 'application/octet-stream' }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Conversion failed' })); + throw new Error(error.error || 'Document conversion failed'); + } + + return Buffer.from(await response.arrayBuffer()); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + throw new Error('Document conversion timed out. Try a smaller file.'); + } + throw err; + } finally { + clearTimeout(timeout); + } +} + +async function convertLocally(buffer: Buffer, extension: string): Promise { const tempId = randomUUID(); const privateTempDir = join(tmpdir(), `kaboot-${tempId}`); const inputPath = join(privateTempDir, `input${extension}`); @@ -225,47 +254,52 @@ async function extractWithLibreOffice(buffer: Buffer, extension: string, useOcr: mkdirSync(privateTempDir, { mode: 0o700 }); writeFileSync(inputPath, buffer, { mode: 0o600 }); - try { - execSync( - `libreoffice --headless --convert-to ${modernExt} --outdir "${privateTempDir}" "${inputPath}"`, - { timeout: PROCESSING_TIMEOUT_MS, stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 } - ); - } catch (execError) { - 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('Document conversion failed. The file may be corrupted.'); - } + execSync( + `libreoffice --headless --convert-to ${modernExt} --outdir "${privateTempDir}" "${inputPath}"`, + { timeout: PROCESSING_TIMEOUT_MS, stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 } + ); 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 readFileSync(outputPath); + } catch (execError) { + const error = execError as Error & { code?: string; killed?: boolean }; + if (error.killed) { + throw new Error('Document conversion timed out. Try a smaller file.'); } - return text; + if (error.code === 'ENOENT' || error.message?.includes('not found')) { + throw new Error( + `Legacy ${extension} files require LibreOffice for text extraction. ` + + `Please convert to .${LEGACY_TO_MODERN[extension]} format or ensure LibreOffice is installed.` + ); + } + throw new Error('Document conversion failed. The file may be corrupted.'); } finally { cleanup(); } } +async function extractWithLibreOffice(buffer: Buffer, extension: string, useOcr: boolean = false): Promise { + const convertedBuffer = USE_SANDBOX + ? await convertViaSandbox(buffer, extension) + : await convertLocally(buffer, extension); + + 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; +} + export async function processDocument( buffer: Buffer, mimeType: string, From b7126b0d0740d22be8b8b27429192b2fda65d4af Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 3 Feb 2026 08:35:10 -0700 Subject: [PATCH 2/3] Circuit breaker --- docs/AUTHENTIK_SETUP.md | 62 ++++++++++++++ docs/SECURITY_PLAN.md | 114 ++++++++++++++++++++++++++ server/src/services/documentParser.ts | 61 +++++++++++++- 3 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 docs/SECURITY_PLAN.md diff --git a/docs/AUTHENTIK_SETUP.md b/docs/AUTHENTIK_SETUP.md index 94f4c34..685fc03 100644 --- a/docs/AUTHENTIK_SETUP.md +++ b/docs/AUTHENTIK_SETUP.md @@ -408,6 +408,68 @@ Then run the dev server with `npm run dev -- --host` to bind to all interfaces. - Verify `OIDC_ISSUER` and `OIDC_JWKS_URI` are correct - The backend must be able to reach Authentik at `http://authentik-server:9000` (Docker network) +## Required OIDC Claims + +The Kaboot backend validates the following JWT claims: + +| Claim | Required | Description | +|-------|----------|-------------| +| `sub` | Yes | User identifier (subject) | +| `iss` | Yes | Must match `OIDC_ISSUER` env var | +| `aud` | Recommended | Must match `OIDC_AUDIENCE` env var if set | +| `preferred_username` | No | Display name, falls back to `sub` | +| `email` | No | User's email address | +| `groups` | No | Array of group names for access control | + +### Audience Claim Configuration + +The `aud` (audience) claim prevents tokens issued for other applications from being accepted. The blueprint configures this automatically via the `kaboot` scope. + +**Backend Configuration:** +```bash +# Set to your OIDC client ID +OIDC_AUDIENCE=kaboot-spa +``` + +**Frontend Configuration:** +The frontend must request the `kaboot` scope to include the audience claim: +```typescript +// src/config/oidc.ts +scope: 'openid profile email offline_access groups kaboot' +``` + +**Manual Authentik Setup (if not using blueprints):** + +1. Go to **Customisation** > **Property Mappings** +2. Click **Create** > **Scope Mapping** +3. Configure: + | Field | Value | + |-------|-------| + | Name | `Kaboot Audience Scope` | + | Scope name | `kaboot` | + | Expression | `return {"aud": "kaboot-spa"}` | + +4. Go to **Applications** > **Providers** > **Kaboot OAuth2** +5. Edit and add the new scope mapping to **Scopes** + +### Groups Claim for Access Control + +The `groups` claim controls access to premium features: + +| Group | Access Level | +|-------|--------------| +| `kaboot-users` | Basic access (required to use app) | +| `kaboot-ai-access` | Unlimited AI generations, OCR access | +| `kaboot-admin` | Admin features | + +The groups scope mapping is configured in the blueprint. For manual setup: + +1. Create a scope mapping with expression: + ```python + return {"groups": [group.name for group in request.user.ak_groups.all()]} + ``` +2. Add it to the provider's scopes + ## Production Notes For production deployment, see [PRODUCTION.md](./PRODUCTION.md) for full instructions. diff --git a/docs/SECURITY_PLAN.md b/docs/SECURITY_PLAN.md new file mode 100644 index 0000000..b7169c1 --- /dev/null +++ b/docs/SECURITY_PLAN.md @@ -0,0 +1,114 @@ +# AUDIT + +High / Critical Findings +- Client-exposed Gemini system key risk. vite.config.ts injects process.env.GEMINI_API_KEY into the frontend bundle and services/geminiService.ts falls back to process.env.API_KEY; if a server/system key is set, it becomes visible to all clients and bypasses backend access controls. Recommend removing env injection for secrets and using only POST /api/generate for system AI. Evidence: vite.config.ts, services/geminiService.ts:6-12. +- Untrusted document parsing runs native tooling. server/src/services/documentParser.ts uses LibreOffice + multiple parsers on attacker-supplied files; even without command injection, crafted docs are a common RCE vector. Treat as high risk unless sandboxed. Recommend running conversions in a container/worker with seccomp/AppArmor, low privileges, CPU/memory limits, and timeouts. Evidence: server/src/services/documentParser.ts:223-226. +- Memory-heavy file uploads without per-endpoint rate limits. multer.memoryStorage() accepts up to 50MB and parsing happens synchronously; repeated requests can exhaust RAM/CPU. Recommend per-route rate limits + streaming to disk or queueing with concurrency caps. Evidence: server/src/routes/upload.ts:11-18. +Medium Findings +- JWT audience not validated. requireAuth verifies issuer/alg but not aud. A token minted for another client with the same issuer could be accepted. Recommend adding audience validation (client_id) and optionally azp. Evidence: server/src/middleware/auth.ts:58-64. +- Tokens and API keys stored in localStorage. OIDC user store and API keys are persisted client-side, which is vulnerable to XSS. Mitigation: prefer in-memory storage or httpOnly cookies; avoid persisting API keys. Evidence: src/config/oidc.ts:17, hooks/useUserPreferences.ts:17-48, hooks/useGame.ts:62-74. +- Public game lookup is enumerable if PINs are short. GET /api/games/:pin is unauthenticated (rate-limited) and returns game metadata. If PIN length is small, enumeration is plausible. Consider longer pins, additional throttling, or auth for non-host data. Evidence: server/src/routes/games.ts:85-107. +Low Findings / Hardening +- Auth error details echoed to client. requireAuth returns details: err.message which can leak verifier details. Recommend generic responses and server-only logs. Evidence: server/src/middleware/auth.ts:66-69. +- Logging includes share tokens and pins. When LOG_REQUESTS=true, paths like /api/shared/:token are logged. Consider redacting or omitting sensitive paths. Evidence: server/src/index.ts:52-58, server/src/routes/shared.ts. +- CORS uses credentials: true with configurable origin. If cookies are ever used in future, misconfiguration could enable CSRF. Recommend disabling credentials unless needed, and enforce HTTPS origins in prod. Evidence: server/src/index.ts:42-46. +Positive Controls Observed +- Parametrized SQL across routes (no obvious SQLi). server/src/routes/*.ts. +- JWT verification uses RS256 and JWKS with caching. server/src/middleware/auth.ts. +- Stripe webhook signature verification present. server/src/routes/payments.ts:337. +- Helmet with CSP enabled on backend responses. server/src/index.ts:17-28. +- No dangerouslySetInnerHTML found in frontend. +Key Recommendation Order +1. Remove client-side exposure of GEMINI_API_KEY and force system AI through backend. (vite.config.ts, services/geminiService.ts) +2. Sandbox document parsing / LibreOffice conversions. (server/src/services/documentParser.ts) +3. Add audience validation for JWTs. (server/src/middleware/auth.ts) +4. Add upload-specific throttling and reduce memory pressure. (server/src/routes/upload.ts) +5. Reduce client-side secret persistence (move to in-memory / httpOnly). (src/config/oidc.ts, hooks/useUserPreferences.ts) + +# Security Plan (Kaboot) + +This plan turns the audit findings into a tracked, testable remediation roadmap. + +## Goals +- [x] Eliminate client-side exposure of system AI secrets and enforce server-side access control only. +- [x] Reduce risk of RCE/DoS in document parsing and uploads. *(sandboxing, rate limits, circuit breaker all done)* +- [ ] Harden token validation and client storage practices. *(JWT aud validation done; client storage pending)* +- [ ] Improve auditability, logging hygiene, and safe rollout. + +## Scope +- [ ] Backend entrypoints and middleware: `server/src/index.ts`, `server/src/routes/*.ts`, `server/src/middleware/auth.ts`. +- [ ] Document processing: `server/src/services/documentParser.ts`, `server/src/routes/upload.ts`. +- [ ] Frontend auth/storage and AI clients: `src/config/oidc.ts`, `hooks/useUserPreferences.ts`, `hooks/useAuthenticatedFetch.ts`, `services/geminiService.ts`. +- [ ] Build/config surfaces: `vite.config.ts`, `.env.example`, `docker-compose*.yml`, `docs/*`. + +## Assumptions +- [ ] Backend is the only trusted system for system AI generation. +- [ ] Auth provider is Authentik with OIDC JWTs. +- [ ] Uploads may be attacker-controlled and should be treated as hostile. + +## Phase 0 - Baseline and Inventory +- [ ] Inventory all secrets and confirm none are present in repo or frontend bundles. +- [ ] Verify current auth boundary: which routes require `requireAuth` vs public. +- [ ] Record current rate limits and error handling for uploads, generation, games, and shared endpoints. +- [ ] Add a basic secret scan in CI (gitleaks/trufflehog) and document false-positive exclusions. + +## Phase 1 - Critical Remediations + +### 1) Remove Client Exposure of System AI Key +- [x] Remove `process.env.GEMINI_API_KEY` exposure from `vite.config.ts`. +- [x] Remove `process.env.API_KEY` fallback in `services/geminiService.ts`. +- [x] Ensure all system AI usage flows through `POST /api/generate` only. +- [ ] Validate that built frontend bundles do not contain Gemini keys. + +### 2) Sandbox Document Processing +- [x] Move LibreOffice conversion to a sandboxed worker/container (no network, low privileges). +- [x] Enforce per-job CPU/memory limits and execution timeouts. +- [x] Store temp files in a private directory (0700) and clean up consistently. +- [x] Add a circuit breaker for repeated failures or timeouts. + +### 3) Upload Abuse Protection +- [x] Add route-specific rate limiting for `POST /api/upload`. +- [x] Cap concurrent uploads per user and per IP. +- [ ] Consider streaming to disk or queueing (avoid large in-memory buffers when possible). + +### 4) JWT Audience Validation +- [x] Validate `aud` (and `azp` if present) in `server/src/middleware/auth.ts`. +- [x] Document required OIDC claims in `docs/AUTHENTIK_SETUP.md`. + +## Phase 2 - Hardening + +### 1) Games Endpoint Risk Reduction +- [x] Increase PIN entropy or move to a join token. +- [ ] Require host proof (secret or auth) for state mutation endpoints. +- [ ] Add per-endpoint throttling for public lookups. + +### 2) Logging and Error Hygiene +- [ ] Redact sensitive paths/tokens in request logs (`server/src/index.ts`). +- [x] Return generic auth errors to clients; keep details server-only (`server/src/middleware/auth.ts`). + +### 3) CORS and CSRF Posture +- [ ] Review use of `credentials: true` and disable if not required. +- [ ] Enforce HTTPS origins in production configs. + +### 4) Privacy and Disclosure +- [ ] Add a user-facing notice that documents may be sent to external AI providers. +- [ ] Document data handling and retention for uploads and AI generation. + +## Phase 3 - Verification +- [ ] Add tests for JWT `aud` mismatches and invalid tokens. +- [ ] Add upload tests for size limits, malformed files, and parser errors. +- [ ] Add regression tests for shared and game endpoints (rate limiting and access). +- [ ] Run dependency vulnerability checks in CI (`npm audit` or Snyk). +- [ ] Verify no secrets appear in logs or built assets. + +## Phase 4 - Rollout +- [ ] Deploy to staging and validate auth, upload, generation, and payments end-to-end. +- [ ] Rotate exposed keys after client bundle no longer includes secrets. +- [ ] Use feature flags for upload pipeline changes. +- [ ] Monitor latency, 429s, error rates, and upload failures for 48h post-deploy. + +## Deliverables +- [ ] Code changes with references and rationale per file. +- [ ] Updated docs for system AI, uploads, and privacy. +- [ ] Test report and monitoring checklist. +- [ ] Post-remediation security summary with residual risks. diff --git a/server/src/services/documentParser.ts b/server/src/services/documentParser.ts index c2d4643..a37a704 100644 --- a/server/src/services/documentParser.ts +++ b/server/src/services/documentParser.ts @@ -11,6 +11,40 @@ const PROCESSING_TIMEOUT_MS = 30000; const SANDBOX_URL = process.env.SANDBOX_URL || 'http://localhost:3002'; const USE_SANDBOX = process.env.USE_SANDBOX !== 'false'; +const CIRCUIT_BREAKER_THRESHOLD = 5; +const CIRCUIT_BREAKER_RESET_MS = 60000; + +class CircuitBreaker { + private failures = 0; + private lastFailure = 0; + private open = false; + + recordSuccess(): void { + this.failures = 0; + this.open = false; + } + + recordFailure(): void { + this.failures++; + this.lastFailure = Date.now(); + if (this.failures >= CIRCUIT_BREAKER_THRESHOLD) { + this.open = true; + } + } + + isOpen(): boolean { + if (!this.open) return false; + if (Date.now() - this.lastFailure > CIRCUIT_BREAKER_RESET_MS) { + this.open = false; + this.failures = 0; + return false; + } + return true; + } +} + +const sandboxCircuit = new CircuitBreaker(); + export const GEMINI_NATIVE_TYPES = [ 'application/pdf', 'text/plain', @@ -210,6 +244,10 @@ const LEGACY_TO_MODERN: Record = { }; async function convertViaSandbox(buffer: Buffer, extension: string): Promise { + if (sandboxCircuit.isOpen()) { + throw new Error('CIRCUIT_OPEN'); + } + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), PROCESSING_TIMEOUT_MS); @@ -223,14 +261,20 @@ async function convertViaSandbox(buffer: Buffer, extension: string): Promise ({ error: 'Conversion failed' })); + sandboxCircuit.recordFailure(); throw new Error(error.error || 'Document conversion failed'); } + sandboxCircuit.recordSuccess(); return Buffer.from(await response.arrayBuffer()); } catch (err) { if (err instanceof Error && err.name === 'AbortError') { + sandboxCircuit.recordFailure(); throw new Error('Document conversion timed out. Try a smaller file.'); } + if (err instanceof Error && err.message !== 'CIRCUIT_OPEN') { + sandboxCircuit.recordFailure(); + } throw err; } finally { clearTimeout(timeout); @@ -282,9 +326,20 @@ async function convertLocally(buffer: Buffer, extension: string): Promise { - const convertedBuffer = USE_SANDBOX - ? await convertViaSandbox(buffer, extension) - : await convertLocally(buffer, extension); + let convertedBuffer: Buffer; + + if (USE_SANDBOX) { + try { + convertedBuffer = await convertViaSandbox(buffer, extension); + } catch (err) { + if (err instanceof Error && err.message === 'CIRCUIT_OPEN') { + throw new Error('Document conversion service temporarily unavailable. Please try again in a minute.'); + } + throw err; + } + } else { + convertedBuffer = await convertLocally(buffer, extension); + } const config = useOcr ? { extractAttachments: true, ocr: true, ocrLanguage: 'eng' } : {}; const ast = await officeParser.parseOffice(convertedBuffer, config); From c162c4bdde8912cac2db1e873f368221736ad116 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 3 Feb 2026 09:05:05 -0700 Subject: [PATCH 3/3] Implement host proof --- server/src/db/connection.ts | 7 +++ server/src/db/schema.sql | 1 + server/src/middleware/auth.ts | 47 ++++++++++++++ server/src/routes/games.ts | 76 ++++++++++++++++------- server/tests/api.test.ts | 112 ++++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 22 deletions(-) diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts index b820a79..5987914 100644 --- a/server/src/db/connection.ts +++ b/server/src/db/connection.ts @@ -164,6 +164,7 @@ const runMigrations = () => { pin TEXT PRIMARY KEY, host_peer_id TEXT NOT NULL, host_secret TEXT NOT NULL, + host_user_id TEXT, quiz_data TEXT NOT NULL, game_config TEXT NOT NULL, game_state TEXT NOT NULL DEFAULT 'LOBBY', @@ -185,6 +186,12 @@ const runMigrations = () => { console.log("Migration: Added first_correct_player_id to game_sessions"); } + const hasHostUserId = sessionTableInfo.some(col => col.name === "host_user_id"); + if (!hasHostUserId) { + db.exec("ALTER TABLE game_sessions ADD COLUMN host_user_id TEXT"); + console.log("Migration: Added host_user_id to game_sessions"); + } + const hasColorScheme = userTableInfo2.some(col => col.name === "color_scheme"); if (!hasColorScheme) { db.exec("ALTER TABLE users ADD COLUMN color_scheme TEXT DEFAULT 'blue'"); diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index 070cdf9..9d3e3c0 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS game_sessions ( pin TEXT PRIMARY KEY, host_peer_id TEXT NOT NULL, host_secret TEXT NOT NULL, + host_user_id TEXT, quiz_data TEXT NOT NULL, game_config TEXT NOT NULL, game_state TEXT NOT NULL DEFAULT 'LOBBY', diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index da2fc5b..c0a2d8f 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -42,6 +42,53 @@ export interface AuthenticatedRequest extends Request { user?: AuthenticatedUser; } +export function optionalAuth( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + next(); + return; + } + + 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, + verifyOptions, + (err, decoded) => { + if (err) { + console.error('Optional auth - token verification failed:', err.message); + next(); + return; + } + + const payload = decoded as jwt.JwtPayload; + req.user = { + sub: payload.sub!, + preferred_username: payload.preferred_username || payload.sub!, + email: payload.email, + name: payload.name, + groups: payload.groups || [], + }; + + next(); + } + ); +} + export function requireAuth( req: AuthenticatedRequest, res: Response, diff --git a/server/src/routes/games.ts b/server/src/routes/games.ts index 1978529..7ef2c45 100644 --- a/server/src/routes/games.ts +++ b/server/src/routes/games.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express'; import rateLimit from 'express-rate-limit'; import { db } from '../db/connection.js'; import { randomBytes } from 'crypto'; +import { optionalAuth, AuthenticatedRequest } from '../middleware/auth.js'; const router = Router(); @@ -31,6 +32,7 @@ interface GameSession { pin: string; host_peer_id: string; host_secret: string; + host_user_id: string | null; quiz_data: string; game_config: string; game_state: string; @@ -55,7 +57,7 @@ const cleanupExpiredSessions = () => { setInterval(cleanupExpiredSessions, 60 * 1000); -router.post('/', gameCreationLimiter, (req: Request, res: Response) => { +router.post('/', gameCreationLimiter, optionalAuth, (req: AuthenticatedRequest, res: Response) => { try { const { pin, hostPeerId, quiz, gameConfig } = req.body; @@ -65,11 +67,12 @@ router.post('/', gameCreationLimiter, (req: Request, res: Response) => { } const hostSecret = randomBytes(32).toString('hex'); + const hostUserId = req.user?.sub || null; db.prepare(` - INSERT INTO game_sessions (pin, host_peer_id, host_secret, quiz_data, game_config, game_state, players_data) - VALUES (?, ?, ?, ?, ?, 'LOBBY', '[]') - `).run(pin, hostPeerId, hostSecret, JSON.stringify(quiz), JSON.stringify(gameConfig)); + INSERT INTO game_sessions (pin, host_peer_id, host_secret, host_user_id, quiz_data, game_config, game_state, players_data) + VALUES (?, ?, ?, ?, ?, ?, 'LOBBY', '[]') + `).run(pin, hostPeerId, hostSecret, hostUserId, JSON.stringify(quiz), JSON.stringify(gameConfig)); res.status(201).json({ success: true, hostSecret }); } catch (err: any) { @@ -111,20 +114,29 @@ router.get('/:pin', gameLookupLimiter, (req: Request, res: Response) => { } }); -router.get('/:pin/host', (req: Request, res: Response) => { +router.get('/:pin/host', optionalAuth, (req: AuthenticatedRequest, res: Response) => { try { const { pin } = req.params; const hostSecret = req.headers['x-host-secret'] as string; + const hostUserId = req.user?.sub; - if (!hostSecret) { - res.status(401).json({ error: 'Host secret required' }); + if (!hostSecret && !hostUserId) { + res.status(401).json({ error: 'Host authentication required (X-Host-Secret header or Authorization token)' }); return; } - const session = db.prepare('SELECT * FROM game_sessions WHERE pin = ? AND host_secret = ?').get(pin, hostSecret) as GameSession | undefined; + const session = db.prepare('SELECT * FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined; if (!session) { - res.status(404).json({ error: 'Game not found or invalid secret' }); + res.status(404).json({ error: 'Game not found' }); + return; + } + + const isValidSecret = hostSecret && session.host_secret === hostSecret; + const isValidUser = hostUserId && session.host_user_id === hostUserId; + + if (!isValidSecret && !isValidUser) { + res.status(404).json({ error: 'Game not found or invalid credentials' }); return; } @@ -144,21 +156,30 @@ router.get('/:pin/host', (req: Request, res: Response) => { } }); -router.patch('/:pin', (req: Request, res: Response) => { +router.patch('/:pin', optionalAuth, (req: AuthenticatedRequest, res: Response) => { try { const { pin } = req.params; const hostSecret = req.headers['x-host-secret'] as string; + const hostUserId = req.user?.sub; const { hostPeerId, gameState, currentQuestionIndex, players, firstCorrectPlayerId } = req.body; - if (!hostSecret) { - res.status(401).json({ error: 'Host secret required' }); + if (!hostSecret && !hostUserId) { + res.status(401).json({ error: 'Host authentication required (X-Host-Secret header or Authorization token)' }); return; } - const session = db.prepare('SELECT pin FROM game_sessions WHERE pin = ? AND host_secret = ?').get(pin, hostSecret); + const session = db.prepare('SELECT pin, host_secret, host_user_id FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined; if (!session) { - res.status(404).json({ error: 'Game not found or invalid secret' }); + res.status(404).json({ error: 'Game not found' }); + return; + } + + const isValidSecret = hostSecret && session.host_secret === hostSecret; + const isValidUser = hostUserId && session.host_user_id === hostUserId; + + if (!isValidSecret && !isValidUser) { + res.status(404).json({ error: 'Game not found or invalid credentials' }); return; } @@ -192,12 +213,12 @@ router.patch('/:pin', (req: Request, res: Response) => { } updates.push('updated_at = CURRENT_TIMESTAMP'); - values.push(pin, hostSecret); + values.push(pin); db.prepare(` UPDATE game_sessions SET ${updates.join(', ')} - WHERE pin = ? AND host_secret = ? + WHERE pin = ? `).run(...values); res.json({ success: true }); @@ -207,23 +228,34 @@ router.patch('/:pin', (req: Request, res: Response) => { } }); -router.delete('/:pin', (req: Request, res: Response) => { +router.delete('/:pin', optionalAuth, (req: AuthenticatedRequest, res: Response) => { try { const { pin } = req.params; const hostSecret = req.headers['x-host-secret'] as string; + const hostUserId = req.user?.sub; - if (!hostSecret) { - res.status(401).json({ error: 'Host secret required' }); + if (!hostSecret && !hostUserId) { + res.status(401).json({ error: 'Host authentication required (X-Host-Secret header or Authorization token)' }); return; } - const result = db.prepare('DELETE FROM game_sessions WHERE pin = ? AND host_secret = ?').run(pin, hostSecret); + const session = db.prepare('SELECT pin, host_secret, host_user_id FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined; - if (result.changes === 0) { - res.status(404).json({ error: 'Game not found or invalid secret' }); + if (!session) { + res.status(404).json({ error: 'Game not found' }); return; } + const isValidSecret = hostSecret && session.host_secret === hostSecret; + const isValidUser = hostUserId && session.host_user_id === hostUserId; + + if (!isValidSecret && !isValidUser) { + res.status(404).json({ error: 'Game not found or invalid credentials' }); + return; + } + + db.prepare('DELETE FROM game_sessions WHERE pin = ?').run(pin); + res.json({ success: true }); } catch (err) { console.error('Error deleting game session:', err); diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index a1de080..2df6fb9 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -1994,6 +1994,118 @@ console.log('\n=== Game Session Tests ==='); }); }); + console.log('\nHost Proof Validation Tests:'); + + let authGamePin: string | null = null; + + await test('POST /api/games with Authorization token stores host_user_id', async () => { + const gameData = { + pin: '777777', + hostPeerId: 'kaboot-777777', + quiz: { title: 'Auth Test Quiz', questions: [] }, + gameConfig: {}, + }; + + const { data } = await gameRequest('POST', '/api/games', gameData, { + 'Authorization': `Bearer ${TOKEN}`, + }, 201); + + if (!(data as { success: boolean }).success) throw new Error('Expected success: true'); + authGamePin = '777777'; + }); + + await test('GET /api/games/:pin/host with valid Authorization token succeeds', async () => { + if (!authGamePin) throw new Error('No auth game created'); + + const { data } = await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, { + 'Authorization': `Bearer ${TOKEN}`, + }); + + const game = data as Record; + if (game.pin !== authGamePin) throw new Error('Wrong PIN'); + if (!game.quiz) throw new Error('Missing quiz'); + }); + + await test('PATCH /api/games/:pin with valid Authorization token succeeds', async () => { + if (!authGamePin) throw new Error('No auth game created'); + + await gameRequest('PATCH', `/api/games/${authGamePin}`, { + gameState: 'QUESTION', + }, { + 'Authorization': `Bearer ${TOKEN}`, + }); + + const { data } = await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, { + 'Authorization': `Bearer ${TOKEN}`, + }); + + const game = data as Record; + if (game.gameState !== 'QUESTION') throw new Error('gameState not updated'); + }); + + await test('GET /api/games/:pin/host with neither secret nor token returns 401', async () => { + if (!authGamePin) throw new Error('No auth game created'); + await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, {}, 401); + }); + + await test('PATCH /api/games/:pin with neither secret nor token returns 401', async () => { + if (!authGamePin) throw new Error('No auth game created'); + await gameRequest('PATCH', `/api/games/${authGamePin}`, { gameState: 'LOBBY' }, {}, 401); + }); + + await test('DELETE /api/games/:pin with neither secret nor token returns 401', async () => { + if (!authGamePin) throw new Error('No auth game created'); + await gameRequest('DELETE', `/api/games/${authGamePin}`, undefined, {}, 401); + }); + + await test('GET /api/games/:pin/host with invalid token returns 401', async () => { + if (!authGamePin) throw new Error('No auth game created'); + await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, { + 'Authorization': 'Bearer invalid-token-12345', + }, 401); + }); + + await test('PATCH /api/games/:pin with invalid token returns 401', async () => { + if (!authGamePin) throw new Error('No auth game created'); + await gameRequest('PATCH', `/api/games/${authGamePin}`, { gameState: 'LOBBY' }, { + 'Authorization': 'Bearer invalid-token-12345', + }, 401); + }); + + await test('DELETE /api/games/:pin with valid Authorization token succeeds', async () => { + if (!authGamePin) throw new Error('No auth game created'); + + await gameRequest('DELETE', `/api/games/${authGamePin}`, undefined, { + 'Authorization': `Bearer ${TOKEN}`, + }); + + await gameRequest('GET', `/api/games/${authGamePin}`, undefined, {}, 404); + }); + + await test('POST /api/games without auth allows access via secret only', async () => { + const gameData = { + pin: '888888', + hostPeerId: 'kaboot-888888', + quiz: { title: 'No Auth Quiz', questions: [] }, + gameConfig: {}, + }; + + const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201); + const secret = (data as { hostSecret: string }).hostSecret; + + await gameRequest('GET', `/api/games/888888/host`, undefined, { + 'X-Host-Secret': secret, + }); + + await gameRequest('GET', `/api/games/888888/host`, undefined, { + 'Authorization': `Bearer ${TOKEN}`, + }, 404); + + await gameRequest('DELETE', '/api/games/888888', undefined, { + 'X-Host-Secret': secret, + }); + }); + console.log('\n=== AI Generate Endpoint Tests ==='); console.log('\nGenerate Status Tests:');