Private
Public Access
1
0

Add CSP/HSTS headers, attachment extension whitelist, and upload rate limiting

- Add Content-Security-Policy and Strict-Transport-Security headers
- Whitelist allowed file extensions on attachment uploads
- Add rate limiting to password change (5/5min) and file uploads (20/60s)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-03-07 19:25:02 -05:00
parent d418c367cb
commit 0852d5a804
6 changed files with 40 additions and 5 deletions
+2
View File
@@ -71,6 +71,8 @@ export const handle: Handle = async ({ event, resolve }) => {
response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
response.headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'");
logger.info({ logger.info({
method: event.request.method, method: event.request.method,
+10
View File
@@ -42,6 +42,16 @@ export function registerLimiter(ip: string): RateLimitResult {
return rateLimit(ip, { window: 3_600_000, max: 5 }); return rateLimit(ip, { window: 3_600_000, max: 5 });
} }
/** Pre-configured: 5 password changes per 300s */
export function passwordChangeLimiter(ip: string): RateLimitResult {
return rateLimit(ip, { window: 300_000, max: 5 });
}
/** Pre-configured: 20 file uploads per 60s */
export function uploadLimiter(ip: string): RateLimitResult {
return rateLimit(ip, { window: 60_000, max: 20 });
}
// Periodic cleanup of stale entries every 60s // Periodic cleanup of stale entries every 60s
setInterval(() => { setInterval(() => {
const now = Date.now(); const now = Date.now();
+6 -1
View File
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
import { requireAuth } from '$lib/server/guards.js'; import { requireAuth } from '$lib/server/guards.js';
import { hashPassword, verifyPassword } from '$lib/server/auth.js'; import { hashPassword, verifyPassword } from '$lib/server/auth.js';
import { withBypassRLS } from '$lib/server/rls.js'; import { withBypassRLS } from '$lib/server/rls.js';
import { passwordChangeLimiter } from '$lib/server/ratelimit.js';
import { z } from 'zod'; import { z } from 'zod';
const profileSchema = z.object({ const profileSchema = z.object({
@@ -16,13 +17,17 @@ const passwordSchema = z.object({
}); });
// Update profile // Update profile
export const PATCH: RequestHandler = async ({ request, locals }) => { export const PATCH: RequestHandler = async ({ request, locals, getClientAddress }) => {
requireAuth(locals.user); requireAuth(locals.user);
const body = await request.json(); const body = await request.json();
// Password change // Password change
if (body.currentPassword !== undefined) { if (body.currentPassword !== undefined) {
const limit = passwordChangeLimiter(getClientAddress());
if (!limit.ok) {
return json({ error: 'Too many attempts', retryAfter: limit.retryAfter }, { status: 429 });
}
const result = passwordSchema.safeParse(body); const result = passwordSchema.safeParse(body);
if (!result.success) { if (!result.success) {
return json({ error: result.error.issues[0].message }, { status: 400 }); return json({ error: result.error.issues[0].message }, { status: 400 });
+4 -1
View File
@@ -2,14 +2,17 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js'; import type { RequestHandler } from './$types.js';
import { withBypassRLS } from '$lib/server/rls.js'; import { withBypassRLS } from '$lib/server/rls.js';
import { requireAuth } from '$lib/server/guards.js'; import { requireAuth } from '$lib/server/guards.js';
import { uploadLimiter } from '$lib/server/ratelimit.js';
import { writeFile, mkdir, unlink } from 'fs/promises'; import { writeFile, mkdir, unlink } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
const MAX_SIZE = 2 * 1024 * 1024; // 2MB const MAX_SIZE = 2 * 1024 * 1024; // 2MB
const UPLOAD_ROOT = '/app/uploads'; const UPLOAD_ROOT = '/app/uploads';
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals, getClientAddress }) => {
requireAuth(locals.user); requireAuth(locals.user);
const limit = uploadLimiter(getClientAddress());
if (!limit.ok) throw error(429, 'Too many uploads');
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get('avatar') as File | null; const file = formData.get('avatar') as File | null;
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js'; import { withTenant } from '$lib/server/rls.js';
import { requireTenantAdmin } from '$lib/server/guards.js'; import { requireTenantAdmin } from '$lib/server/guards.js';
import { clearTenantCache } from '$lib/server/tenant.js'; import { clearTenantCache } from '$lib/server/tenant.js';
import { uploadLimiter } from '$lib/server/ratelimit.js';
import { writeFile, mkdir, unlink } from 'fs/promises'; import { writeFile, mkdir, unlink } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
@@ -19,10 +20,12 @@ const SLOT_TO_FIELD: Record<Slot, string> = {
'logo': 'logoUrl' 'logo': 'logoUrl'
}; };
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals, getClientAddress }) => {
const tenant = locals.tenant; const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant'); if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user); requireTenantAdmin(locals.user);
const limit = uploadLimiter(getClientAddress());
if (!limit.ok) throw error(429, 'Too many uploads');
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get('file') as File | null; const file = formData.get('file') as File | null;
@@ -7,14 +7,25 @@ import { join } from 'path';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { broadcast } from '$lib/server/realtime.js'; import { broadcast } from '$lib/server/realtime.js';
import { logActivity } from '$lib/server/activity.js'; import { logActivity } from '$lib/server/activity.js';
import { uploadLimiter } from '$lib/server/ratelimit.js';
const MAX_SIZE = 10 * 1024 * 1024; // 10MB const MAX_SIZE = 10 * 1024 * 1024; // 10MB
const UPLOAD_ROOT = '/app/uploads'; const UPLOAD_ROOT = '/app/uploads';
const ALLOWED_EXTENSIONS = new Set([
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg',
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.txt', '.csv', '.md', '.json', '.xml',
'.zip', '.gz', '.tar',
'.mp4', '.webm', '.mp3', '.wav'
]);
export const POST: RequestHandler = async ({ params, request, locals }) => { export const POST: RequestHandler = async ({ params, request, locals, getClientAddress }) => {
const tenant = locals.tenant; const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant'); if (!tenant) throw error(400, 'Unknown tenant');
const limit = uploadLimiter(getClientAddress());
if (!limit.ok) throw error(429, 'Too many uploads');
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get('file') as File | null; const file = formData.get('file') as File | null;
if (!file) throw error(400, 'No file provided'); if (!file) throw error(400, 'No file provided');
@@ -26,7 +37,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
const dir = join(UPLOAD_ROOT, tenant.id, params.cardId); const dir = join(UPLOAD_ROOT, tenant.id, params.cardId);
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
const ext = file.name.includes('.') ? '.' + file.name.split('.').pop() : ''; const ext = file.name.includes('.') ? '.' + file.name.split('.').pop()!.toLowerCase() : '';
if (ext && !ALLOWED_EXTENSIONS.has(ext)) throw error(400, 'File type not allowed');
const storedName = randomUUID() + ext; const storedName = randomUUID() + ext;
const filepath = join(dir, storedName); const filepath = join(dir, storedName);