Add server security hardening and draft quiz persistence

Security:
- Add AES-256-GCM encryption for user PII (email, API keys, config)
- Add rate limiting (helmet + express-rate-limit)
- Require auth for file uploads

UX:
- Persist draft quizzes to sessionStorage (survives refresh)
- Add URL-based edit routes (/edit/draft, /edit/:quizId)
- Fix QuizEditor async defaultConfig race condition
- Fix URL param accumulation in Landing
This commit is contained in:
Joey Yakimowich-Payne 2026-01-15 10:12:05 -07:00
commit e480ad06df
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
18 changed files with 1775 additions and 94 deletions

View file

@ -1,5 +1,7 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { db } from './db/connection.js';
import quizzesRouter from './routes/quizzes.js';
import usersRouter from './routes/users.js';
@ -9,12 +11,38 @@ import gamesRouter from './routes/games.js';
const app = express();
const PORT = process.env.PORT || 3001;
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
}));
const isDev = process.env.NODE_ENV !== 'production';
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: isDev ? 1000 : 500,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' },
skip: (req) => req.path === '/health',
});
const corsOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5173').split(',').map(o => o.trim());
app.use(cors({
origin: corsOrigins.length === 1 ? corsOrigins[0] : corsOrigins,
credentials: true,
}));
app.use(apiLimiter);
const LOG_REQUESTS = process.env.LOG_REQUESTS === 'true';
app.use((req: Request, res: Response, next: NextFunction) => {

View file

@ -1,9 +1,19 @@
import { Router, Request, Response } from 'express';
import rateLimit from 'express-rate-limit';
import { db } from '../db/connection.js';
import { randomBytes } from 'crypto';
const router = Router();
const isDev = process.env.NODE_ENV !== 'production';
const gameCreationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: isDev ? 100 : 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many game creations, please try again later.' },
});
const SESSION_TTL_MINUTES = parseInt(process.env.GAME_SESSION_TTL_MINUTES || '5', 10);
interface GameSession {
@ -34,7 +44,7 @@ const cleanupExpiredSessions = () => {
setInterval(cleanupExpiredSessions, 60 * 1000);
router.post('/', (req: Request, res: Response) => {
router.post('/', gameCreationLimiter, (req: Request, res: Response) => {
try {
const { pin, hostPeerId, quiz, gameConfig } = req.body;

View file

@ -1,9 +1,12 @@
import { Router } from 'express';
import multer from 'multer';
import { processDocument, SUPPORTED_TYPES } from '../services/documentParser.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
const storage = multer.memoryStorage();
const upload = multer({

View file

@ -1,26 +1,55 @@
import { Router, Response } from 'express';
import { db } from '../db/connection.js';
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
import { encryptForUser, decryptForUser, encryptJsonForUser, decryptJsonForUser } from '../services/encryption.js';
const router = Router();
router.use(requireAuth);
interface UserRow {
id: string;
username: string;
email: string | null;
displayName: string | null;
defaultGameConfig: string | null;
colorScheme: string | null;
geminiApiKey: string | null;
createdAt: string | null;
lastLogin: string | null;
}
function decryptUserData(row: UserRow, userSub: string) {
return {
id: row.id,
username: row.username,
email: decryptForUser(row.email, userSub),
displayName: decryptForUser(row.displayName, userSub),
defaultGameConfig: decryptJsonForUser(row.defaultGameConfig, userSub),
colorScheme: row.colorScheme,
geminiApiKey: decryptForUser(row.geminiApiKey, userSub),
createdAt: row.createdAt,
lastLogin: row.lastLogin,
};
}
router.get('/me', (req: AuthenticatedRequest, res: Response) => {
const user = db.prepare(`
const userSub = req.user!.sub;
const row = db.prepare(`
SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig,
color_scheme as colorScheme, gemini_api_key as geminiApiKey,
created_at as createdAt, last_login as lastLogin
FROM users
WHERE id = ?
`).get(req.user!.sub) as Record<string, unknown> | undefined;
`).get(userSub) as UserRow | undefined;
const groups = req.user!.groups || [];
const hasAIAccess = groups.includes('kaboot-ai-access');
if (!user) {
if (!row) {
res.json({
id: req.user!.sub,
id: userSub,
username: req.user!.preferred_username,
email: req.user!.email,
displayName: req.user!.name,
@ -35,26 +64,23 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
return;
}
let parsedConfig = null;
if (user.defaultGameConfig && typeof user.defaultGameConfig === 'string') {
try {
parsedConfig = JSON.parse(user.defaultGameConfig);
} catch {
parsedConfig = null;
}
}
const decrypted = decryptUserData(row, userSub);
res.json({
...user,
defaultGameConfig: parsedConfig,
...decrypted,
hasAIAccess,
isNew: false
});
});
router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub;
const { defaultGameConfig } = req.body;
const encryptedConfig = encryptJsonForUser(defaultGameConfig, userSub);
const encryptedEmail = encryptForUser(req.user!.email || null, userSub);
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, default_game_config, last_login)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
@ -63,40 +89,45 @@ router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
last_login = CURRENT_TIMESTAMP
`);
const configJson = defaultGameConfig ? JSON.stringify(defaultGameConfig) : null;
upsertUser.run(
req.user!.sub,
userSub,
req.user!.preferred_username,
req.user!.email || null,
req.user!.name || null,
configJson,
configJson
encryptedEmail,
encryptedDisplayName,
encryptedConfig,
encryptedConfig
);
res.json({ success: true });
});
router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub;
const user = db.prepare(`
SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey
FROM users
WHERE id = ?
`).get(req.user!.sub) as { colorScheme: string | null; geminiApiKey: string | null } | undefined;
`).get(userSub) as { colorScheme: string | null; geminiApiKey: string | null } | undefined;
const groups = req.user!.groups || [];
const hasAIAccess = groups.includes('kaboot-ai-access');
res.json({
colorScheme: user?.colorScheme || 'blue',
geminiApiKey: user?.geminiApiKey || null,
geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub),
hasAIAccess,
});
});
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub;
const { colorScheme, geminiApiKey } = req.body;
const encryptedApiKey = encryptForUser(geminiApiKey || null, userSub);
const encryptedEmail = encryptForUser(req.user!.email || null, userSub);
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, last_login)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
@ -107,14 +138,14 @@ router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
`);
upsertUser.run(
req.user!.sub,
userSub,
req.user!.preferred_username,
req.user!.email || null,
req.user!.name || null,
encryptedEmail,
encryptedDisplayName,
colorScheme || 'blue',
geminiApiKey || null,
encryptedApiKey,
colorScheme || 'blue',
geminiApiKey || null
encryptedApiKey
);
res.json({ success: true });

View file

@ -0,0 +1,123 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash, hkdfSync } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const SALT_LENGTH = 16;
let encryptionWarningShown = false;
function getMasterKey(): Buffer | null {
const key = process.env.ENCRYPTION_KEY;
if (!key) {
if (!encryptionWarningShown) {
console.warn('[SECURITY WARNING] ENCRYPTION_KEY not set. User data will NOT be encrypted.');
console.warn('Generate one with: openssl rand -base64 36');
encryptionWarningShown = true;
}
return null;
}
return createHash('sha256').update(key).digest();
}
function deriveUserKey(userSub: string, salt: Buffer): Buffer | null {
const masterKey = getMasterKey();
if (!masterKey) return null;
return Buffer.from(
hkdfSync('sha256', masterKey, salt, `kaboot-user-data:${userSub}`, 32)
);
}
export function encryptForUser(plaintext: string | null | undefined, userSub: string): string | null {
if (plaintext === null || plaintext === undefined || plaintext === '') {
return null;
}
const salt = randomBytes(SALT_LENGTH);
const key = deriveUserKey(userSub, salt);
if (!key) {
return plaintext;
}
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final()
]);
const authTag = cipher.getAuthTag();
const combined = Buffer.concat([salt, iv, authTag, encrypted]);
return combined.toString('base64');
}
export function decryptForUser(ciphertext: string | null | undefined, userSub: string): string | null {
if (ciphertext === null || ciphertext === undefined || ciphertext === '') {
return null;
}
const masterKey = getMasterKey();
if (!masterKey) {
return ciphertext;
}
try {
const combined = Buffer.from(ciphertext, 'base64');
if (combined.length < SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1) {
return ciphertext;
}
const salt = combined.subarray(0, SALT_LENGTH);
const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
const authTag = combined.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
const key = Buffer.from(
hkdfSync('sha256', masterKey, salt, `kaboot-user-data:${userSub}`, 32)
);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
return decrypted.toString('utf8');
} catch {
return ciphertext;
}
}
export function isEncrypted(value: string | null | undefined): boolean {
if (!value) return false;
try {
const decoded = Buffer.from(value, 'base64');
return decoded.length >= SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1;
} catch {
return false;
}
}
export function encryptJsonForUser(data: object | null | undefined, userSub: string): string | null {
if (data === null || data === undefined) {
return null;
}
return encryptForUser(JSON.stringify(data), userSub);
}
export function decryptJsonForUser<T>(ciphertext: string | null | undefined, userSub: string): T | null {
const plaintext = decryptForUser(ciphertext, userSub);
if (!plaintext) return null;
try {
return JSON.parse(plaintext) as T;
} catch {
return null;
}
}