diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 76a5bfe..4cd6e16 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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, diff --git a/src/lib/server/ratelimit.ts b/src/lib/server/ratelimit.ts index 8f95150..eb55031 100644 --- a/src/lib/server/ratelimit.ts +++ b/src/lib/server/ratelimit.ts @@ -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(); diff --git a/src/routes/api/account/+server.ts b/src/routes/api/account/+server.ts index e585929..cd29c54 100644 --- a/src/routes/api/account/+server.ts +++ b/src/routes/api/account/+server.ts @@ -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 }); diff --git a/src/routes/api/account/avatar/+server.ts b/src/routes/api/account/avatar/+server.ts index 5080958..8b0bfe1 100644 --- a/src/routes/api/account/avatar/+server.ts +++ b/src/routes/api/account/avatar/+server.ts @@ -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; diff --git a/src/routes/api/admin/branding/images/+server.ts b/src/routes/api/admin/branding/images/+server.ts index ff444dd..95cf0a0 100644 --- a/src/routes/api/admin/branding/images/+server.ts +++ b/src/routes/api/admin/branding/images/+server.ts @@ -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 = { '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; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts index a94a7c3..4256839 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts @@ -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);