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

@ -11,6 +11,8 @@
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"express": "^4.21.2",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"multer": "^2.0.2",
@ -21,6 +23,7 @@
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/express-rate-limit": "^5.1.3",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7",
@ -778,6 +781,16 @@
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-rate-limit": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/@types/express-rate-limit/-/express-rate-limit-5.1.3.tgz",
"integrity": "sha512-H+TYy3K53uPU2TqPGFYaiWc2xJV6+bIFkDd/Ma2/h67Pa6ARk9kWE0p/K9OH1Okm0et9Sfm66fmXoAxsH2PHXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
@ -1396,6 +1409,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@ -1437,6 +1451,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/file-type": {
"version": "16.5.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
@ -1618,6 +1650,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -1688,6 +1729,15 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",

View file

@ -14,6 +14,8 @@
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"express": "^4.21.2",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"multer": "^2.0.2",
@ -24,6 +26,7 @@
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/express-rate-limit": "^5.1.3",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7",

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;
}
}