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:
parent
75c496e68f
commit
e480ad06df
18 changed files with 1775 additions and 94 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
123
server/src/services/encryption.ts
Normal file
123
server/src/services/encryption.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue