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:
@@ -71,6 +71,8 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
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({
|
||||
method: event.request.method,
|
||||
|
||||
@@ -42,6 +42,16 @@ export function registerLimiter(ip: string): RateLimitResult {
|
||||
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
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
|
||||
import { requireAuth } from '$lib/server/guards.js';
|
||||
import { hashPassword, verifyPassword } from '$lib/server/auth.js';
|
||||
import { withBypassRLS } from '$lib/server/rls.js';
|
||||
import { passwordChangeLimiter } from '$lib/server/ratelimit.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const profileSchema = z.object({
|
||||
@@ -16,13 +17,17 @@ const passwordSchema = z.object({
|
||||
});
|
||||
|
||||
// Update profile
|
||||
export const PATCH: RequestHandler = async ({ request, locals }) => {
|
||||
export const PATCH: RequestHandler = async ({ request, locals, getClientAddress }) => {
|
||||
requireAuth(locals.user);
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Password change
|
||||
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);
|
||||
if (!result.success) {
|
||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||
|
||||
@@ -2,14 +2,17 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { withBypassRLS } from '$lib/server/rls.js';
|
||||
import { requireAuth } from '$lib/server/guards.js';
|
||||
import { uploadLimiter } from '$lib/server/ratelimit.js';
|
||||
import { writeFile, mkdir, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
const UPLOAD_ROOT = '/app/uploads';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
export const POST: RequestHandler = async ({ request, locals, getClientAddress }) => {
|
||||
requireAuth(locals.user);
|
||||
const limit = uploadLimiter(getClientAddress());
|
||||
if (!limit.ok) throw error(429, 'Too many uploads');
|
||||
|
||||
const formData = await request.formData();
|
||||
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 { requireTenantAdmin } from '$lib/server/guards.js';
|
||||
import { clearTenantCache } from '$lib/server/tenant.js';
|
||||
import { uploadLimiter } from '$lib/server/ratelimit.js';
|
||||
import { writeFile, mkdir, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -19,10 +20,12 @@ const SLOT_TO_FIELD: Record<Slot, string> = {
|
||||
'logo': 'logoUrl'
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
export const POST: RequestHandler = async ({ request, locals, getClientAddress }) => {
|
||||
const tenant = locals.tenant;
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
requireTenantAdmin(locals.user);
|
||||
const limit = uploadLimiter(getClientAddress());
|
||||
if (!limit.ok) throw error(429, 'Too many uploads');
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
+14
-2
@@ -7,14 +7,25 @@ import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { broadcast } from '$lib/server/realtime.js';
|
||||
import { logActivity } from '$lib/server/activity.js';
|
||||
import { uploadLimiter } from '$lib/server/ratelimit.js';
|
||||
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
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;
|
||||
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 file = formData.get('file') as File | null;
|
||||
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);
|
||||
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 filepath = join(dir, storedName);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user