diff --git a/backend/package-lock.json b/backend/package-lock.json index ef9a7bb..8f6db15 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,7 +13,7 @@ "@fastify/helmet": "^13.0.2", "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^9.3.0", - "@fastify/rate-limit": "^10.1.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^6.0.4", "@fastify/static": "^8.3.0", "@libsql/client": "^0.10.0", diff --git a/backend/package.json b/backend/package.json index 2a7b4a6..2050241 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,7 +18,7 @@ "@fastify/helmet": "^13.0.2", "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^9.3.0", - "@fastify/rate-limit": "^10.1.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^6.0.4", "@fastify/static": "^8.3.0", "@libsql/client": "^0.10.0", diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 2164c37..2381154 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -20,6 +20,28 @@ const ARGON2_OPTIONS: argon2.Options = { hashLength: 32, // 256-bit hash }; +// ============================================================================= +// Rate Limiting Configuration for Auth Routes +// ============================================================================= +// Stricter rate limits for authentication endpoints to prevent brute-force attacks +const authRateLimitConfig = { + max: 10, // 10 requests + timeWindow: "1 minute", // per minute + errorResponseBuilder: () => ({ + error: "Too many requests. Please try again later.", + code: "RATE_LIMIT_EXCEEDED", + }), +}; + +const sensitiveRateLimitConfig = { + max: 5, // 5 requests + timeWindow: "15 minutes", // per 15 minutes (for login/register) + errorResponseBuilder: () => ({ + error: "Too many attempts. Please try again later.", + code: "RATE_LIMIT_EXCEEDED", + }), +}; + // ============================================================================= // Validation Schemas // ============================================================================= @@ -65,7 +87,9 @@ export async function authRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- // POST /auth/register - User registration // --------------------------------------------------------------------------- - app.post<{ Body: z.infer }>("/auth/register", async (request, reply) => { + app.post<{ Body: z.infer }>("/auth/register", { + config: { rateLimit: sensitiveRateLimitConfig }, + }, async (request, reply) => { // Check auth state const state = await getAuthState(); @@ -123,7 +147,9 @@ export async function authRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- // POST /auth/login - User login // --------------------------------------------------------------------------- - app.post<{ Body: z.infer }>("/auth/login", async (request, reply) => { + app.post<{ Body: z.infer }>("/auth/login", { + config: { rateLimit: sensitiveRateLimitConfig }, + }, async (request, reply) => { const state = await getAuthState(); if (!state.authEnabled) { @@ -223,7 +249,9 @@ export async function authRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- // POST /auth/refresh - Refresh access token // --------------------------------------------------------------------------- - app.post("/auth/refresh", async (request, reply) => { + app.post("/auth/refresh", { + config: { rateLimit: authRateLimitConfig }, + }, async (request, reply) => { const refreshTokenCookie = request.cookies.refresh_token; if (!refreshTokenCookie) { return reply.status(401).send({ error: "No refresh token", code: "NO_REFRESH_TOKEN" }); @@ -340,7 +368,10 @@ export async function authRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- // PUT /auth/me - Update current user profile // --------------------------------------------------------------------------- - app.put<{ Body: z.infer }>("/auth/me", { preHandler: requireAuth }, async (request, reply) => { + app.put<{ Body: z.infer }>("/auth/me", { + preHandler: requireAuth, + config: { rateLimit: authRateLimitConfig }, + }, async (request, reply) => { const authUser = request.user as unknown as AuthUser | null; if (!authUser) { return reply.status(401).send({ error: "Not authenticated" }); diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index dbd64cc..a659088 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -7,6 +7,18 @@ import type { AuthUser } from "../types/fastify.js"; import { requireAuth, getAnonymousUserId } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; +// Escape HTML to prevent XSS in email templates +function escapeHtml(text: string): string { + const htmlEscapes: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return text.replace(/[&<>"']/g, char => htmlEscapes[char] || char); +} + type PlannerRow = { medicationId: number; medicationName: string; @@ -100,7 +112,7 @@ export async function plannerRoutes(app: FastifyInstance) { .map( (row) => ` - ${row.medicationName} + ${escapeHtml(row.medicationName)} ${row.totalPills} ${row.plannerUsage} ${row.blistersNeeded} × ${row.blisterSize} @@ -281,7 +293,7 @@ Sent from MedAssist-ng Medication Planner`; const rowBg = isEmpty ? "#fef2f2" : "white"; return ` - ${statusIcon} ${row.name} + ${statusIcon} ${escapeHtml(row.name)} ${row.medsLeft} ${row.daysLeft ?? 0} ${isEmpty ? "NOW" : (row.depletionDate ?? "-")} diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 4ebad54..03f9324 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -327,9 +327,61 @@ export async function settingsRoutes(app: FastifyInstance) { }); } +// Validate URL to prevent SSRF attacks +function isAllowedNotificationUrl(urlStr: string): { allowed: boolean; error?: string } { + try { + // Convert ntfy:// to https:// for parsing + const normalizedUrl = urlStr.startsWith("ntfy://") + ? urlStr.replace("ntfy://", "https://") + : urlStr; + + const parsed = new URL(normalizedUrl); + + // Only allow http and https protocols + if (!['http:', 'https:'].includes(parsed.protocol)) { + return { allowed: false, error: "Only HTTP/HTTPS protocols are allowed" }; + } + + // Block private/internal IP addresses + const hostname = parsed.hostname.toLowerCase(); + + // Block localhost + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') { + return { allowed: false, error: "Localhost URLs are not allowed" }; + } + + // Block private IP ranges (basic check) + const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (ipMatch) { + const [, a, b] = ipMatch.map(Number); + // 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x (link-local) + if (a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || (a === 169 && b === 254)) { + return { allowed: false, error: "Private IP addresses are not allowed" }; + } + } + + // Block common internal hostnames + if (hostname.endsWith('.local') || hostname.endsWith('.internal') || + hostname.endsWith('.lan') || hostname === 'metadata.google.internal') { + return { allowed: false, error: "Internal hostnames are not allowed" }; + } + + return { allowed: true }; + } catch { + return { allowed: false, error: "Invalid URL format" }; + } +} + // Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.) export async function sendShoutrrrNotification(urlStr: string, title: string, message: string): Promise<{ success: boolean; error?: string }> { try { + // Validate URL to prevent SSRF + const validation = isAllowedNotificationUrl(urlStr); + if (!validation.allowed) { + return { success: false, error: validation.error }; + } + let targetUrl: string; let method = "POST"; let headers: Record = {}; diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 52c16f0..d25bfc0 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -1023,7 +1023,41 @@ describe("E2E Tests with Real Routes", () => { }); expect(response.statusCode).toBe(500); - expect(response.json().error).toContain("Unsupported URL format"); + // SSRF protection returns more specific error message + expect(response.json().error).toContain("HTTP/HTTPS protocols"); + }); + + it("should reject test-shoutrrr with localhost URL (SSRF protection)", async () => { + const response = await app.inject({ + method: "POST", + url: "/settings/test-shoutrrr", + payload: { url: "https://localhost/topic" }, + }); + + expect(response.statusCode).toBe(500); + expect(response.json().error).toContain("Localhost URLs are not allowed"); + }); + + it("should reject test-shoutrrr with private IP (SSRF protection)", async () => { + const response = await app.inject({ + method: "POST", + url: "/settings/test-shoutrrr", + payload: { url: "https://192.168.1.1/topic" }, + }); + + expect(response.statusCode).toBe(500); + expect(response.json().error).toContain("Private IP addresses are not allowed"); + }); + + it("should reject test-shoutrrr with internal hostname (SSRF protection)", async () => { + const response = await app.inject({ + method: "POST", + url: "/settings/test-shoutrrr", + payload: { url: "https://server.internal/topic" }, + }); + + expect(response.statusCode).toBe(500); + expect(response.json().error).toContain("Internal hostnames are not allowed"); }); });