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-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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+14
-2
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user