From 4cf886ea43c180c254be55dcb5d2a3ae3f215b75 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sat, 16 May 2026 23:10:56 +0530 Subject: [PATCH] Add security libs: auth, audit, rate-limit, scoped db --- scripts/security-audit.sh | 46 ++++++++++ src/__tests__/security.test.ts | 102 +++++++++++++++++++++ src/app/api/cron/backup/route.ts | 71 +++++++++++++++ src/db/scoped.ts | 38 ++++++++ src/lib/audit.ts | 28 ++++++ src/lib/auth.ts | 148 +++++++++++++++++++++++++++++++ src/lib/rate-limit.ts | 64 +++++++++++++ 7 files changed, 497 insertions(+) create mode 100644 scripts/security-audit.sh create mode 100644 src/__tests__/security.test.ts create mode 100644 src/app/api/cron/backup/route.ts create mode 100644 src/db/scoped.ts create mode 100644 src/lib/audit.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/rate-limit.ts diff --git a/scripts/security-audit.sh b/scripts/security-audit.sh new file mode 100644 index 0000000..1a475e3 --- /dev/null +++ b/scripts/security-audit.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Security audit script - run to verify security implementation + +echo "=== Security Audit ===" +echo "" + +BASE_URL="${1:-http://localhost:3000}" + +echo "1. Testing unauthenticated API access (should fail)..." +echo " Testing /api/children..." +curl -s "$BASE_URL/api/children" | grep -q "error" && echo " ✓ PASS - Blocked" || echo " ✗ FAIL - Should return error" + +echo "" +echo "2. Testing unauthenticated growth access (should fail)..." +curl -s "$BASE_URL/api/growth?childId=test" | grep -q "error" && echo " ✓ PASS - Blocked" || echo " ✗ FAIL - Should return error" + +echo "" +echo "3. Testing unauthenticated logs access (should fail)..." +curl -s "$BASE_URL/api/logs?childId=test&type=feed" | grep -q "error" && echo " ✓ PASS - Blocked" || echo " ✗ FAIL - Should return error" + +echo "" +echo "4. Testing vaccinations access (should fail)..." +curl -s "$BASE_URL/api/vaccinations?childId=test" | grep -q "error" && echo " ✓ PASS - Blocked" || echo " ✗ FAIL - Should return error" + +echo "" +echo "5. Testing medicines access (should fail)..." +curl -s "$BASE_URL/api/medicines?childId=test" | grep -q "error" && echo " ✓ PASS - Blocked" || echo " ✗ FAIL - Should return error" + +echo "" +echo "6. Testing session cookie setting..." +RESPONSE=$(curl -s -c /tmp/cookies.txt -X POST "$BASE_URL/api/auth/signin" \ + -H "Content-Type: application/json" \ + -d '{"email":"test@test.com","password":"wrong"}') +echo " Response: $RESPONSE" + +echo "" +echo "7. Testing rate limiting..." +for i in 1 2 3 4 5 6; do + curl -s "$BASE_URL/api/auth/signin" \ + -H "Content-Type: application/json" \ + -d '{"email":"hacker@test.com","password":"guess"}' | grep -q "Too many" && break +done +echo " Rate limited after $i attempts" + +echo "" +echo "=== Audit Complete ===" \ No newline at end of file diff --git a/src/__tests__/security.test.ts b/src/__tests__/security.test.ts new file mode 100644 index 0000000..7b1b349 --- /dev/null +++ b/src/__tests__/security.test.ts @@ -0,0 +1,102 @@ +// Security verification tests +// Run with: pnpm test -- src/__tests__/security.test.ts + +import { validateSession, requireFamily, requireOwnership, requireTier } from "@/lib/auth"; + +describe("Security: Session Validation", () => { + describe("validateSession", () => { + it("should reject invalid session token", async () => { + // Note: This requires mocking cookies() + // In real tests, use next/headers in test environment + }); + + it("should accept valid session token", async () => { + // Validates session and returns user/family info + }); + }); + + describe("requireFamily", () => { + it("should reject unauthenticated requests", async () => { + const result = await requireFamily(); + expect(result.success).toBe(false); + expect(result.status).toBe(401); + }); + + it("should reject authenticated user without family", async () => { + // User authenticated but familyId is null + }); + }); +}); + +describe("Security: Ownership Validation", () => { + describe("requireOwnership", () => { + it("should reject access to other family's children", async () => { + // Try to access child that doesn't belong to user's family + }); + + it("should allow access to own family's children", async () => { + // Try to access child that belongs to user's family + }); + }); +}); + +describe("Security: Tier Validation", () => { + describe("requireTier", () => { + it("should reject free tier for pro features", async () => { + // User has free tier but accessing pro feature + }); + + it("should allow pro tier for pro features", async () => { + // User has pro tier accessing pro feature + }); + }); +}); + +describe("Security: Rate Limiting", () => { + describe("Auth Rate Limits", () => { + it("should block after 5 failed signin attempts", async () => { + // 5 signin failures within 15 minutes + }); + + it("should block after 3 failed signup attempts", async () => { + // 3 signup failures within 1 hour + }); + }); +}); + +describe("Security: Password", () => { + describe("bcrypt", () => { + it("should hash password with cost 12", async () => { + const hash = await bcrypt.hash("testpassword", 12); + expect(hash.startsWith("$2a$12$")).toBe(true); + }); + + it("should verify correct password", async () => { + const hash = await bcrypt.hash("testpassword", 12); + const valid = await bcrypt.compare("testpassword", hash); + expect(valid).toBe(true); + }); + + it("should reject incorrect password", async () => { + const hash = await bcrypt.hash("testpassword", 12); + const valid = await bcrypt.compare("wrongpassword", hash); + expect(valid).toBe(false); + }); + }); +}); + +describe("Security: Session Cookie", () => { + describe("Cookie Settings", () => { + it("should be httpOnly", () => { + // Verify cookie configuration + }); + + it("should be secure in production", () => { + // Verify secure flag in production env + }); + + it("should have sameSite: lax", () => { + // Verify sameSite setting + }); + }); +}); \ No newline at end of file diff --git a/src/app/api/cron/backup/route.ts b/src/app/api/cron/backup/route.ts new file mode 100644 index 0000000..9656d22 --- /dev/null +++ b/src/app/api/cron/backup/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"; +import fs from "fs/promises"; +import { gzip } from "zlib"; + +const execAsync = promisify(exec); +const gzipAsync = promisify(gzip); + +export async function POST(request: Request) { + const secret = request.headers.get("x-cron-secret"); + if (secret !== process.env.CRON_SECRET) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const date = new Date().toISOString().split("T")[0]; + const filename = `tia-backup-${date}.sql.gz`; + const tmpPath = `/tmp/${filename}`; + + try { + // 1. Dump database + const dbUrl = process.env.DATABASE_URL_SUPERUSER || process.env.DATABASE_URL; + await execAsync(`pg_dump "${dbUrl}" -F p -f /tmp/dump.sql`); + + // 2. Compress + const raw = await fs.readFile("/tmp/dump.sql"); + const compressed = await gzipAsync(raw); + + // 3. Upload to R2 + if (process.env.R2_BACKUP_BUCKET && process.env.R2_BACKUP_ACCESS_KEY_ID) { + const s3 = new S3Client({ + region: "auto", + endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: process.env.R2_BACKUP_ACCESS_KEY_ID!, + secretAccessKey: process.env.R2_BACKUP_SECRET_ACCESS_KEY!, + }, + }); + + await s3.send(new PutObjectCommand({ + Bucket: process.env.R2_BACKUP_BUCKET!, + Key: filename, + Body: compressed, + })); + + // 4. Delete backups older than 30 days + const list = await s3.send(new ListObjectsV2Command({ + Bucket: process.env.R2_BACKUP_BUCKET!, + })); + + const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; + for (const obj of list.Contents || []) { + if (obj.LastModified && obj.LastModified.getTime() < cutoff) { + await s3.send(new DeleteObjectCommand({ + Bucket: process.env.R2_BACKUP_BUCKET!, + Key: obj.Key!, + })); + } + } + } + + // 5. Cleanup local + await fs.unlink("/tmp/dump.sql").catch(() => {}); + + return NextResponse.json({ success: true, filename, size: compressed.length }); + } catch (e) { + console.error("Backup failed:", e); + return NextResponse.json({ error: String(e) }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/db/scoped.ts b/src/db/scoped.ts new file mode 100644 index 0000000..72176fb --- /dev/null +++ b/src/db/scoped.ts @@ -0,0 +1,38 @@ +import { sql, dbUnscoped } from "./index"; +import { validateSession, requireFamily } from "@/lib/auth"; + +/** + * Run a callback within a Postgres transaction where + * app.current_family_id is set. ALL data queries must go through this. + */ +export async function withFamilyContext( + familyId: string, + callback: (tx: typeof sql) => Promise +): Promise { + return await sql.begin(async (tx) => { + await tx`SELECT set_config('app.current_family_id', ${familyId}, true)`; + return await callback(tx); + }); +} + +/** + * Convenience: get session + scoped client in one call. + * Use this in API routes instead of importing sql directly. + */ +export async function getScopedDb() { + const auth = await requireFamily(); + if (!auth.success) { + throw new NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const { familyId, userId } = auth.session!; + + return { + session: auth.session!, + run: (cb: (tx: typeof sql) => Promise) => + withFamilyContext(familyId!, cb), + }; +} + +// Need to import NextResponse for error throwing +import { NextResponse } from "next/server"; \ No newline at end of file diff --git a/src/lib/audit.ts b/src/lib/audit.ts new file mode 100644 index 0000000..a70fc4b --- /dev/null +++ b/src/lib/audit.ts @@ -0,0 +1,28 @@ +import { sql } from "@/db"; + +interface AuditOpts { + familyId?: string; + userId?: string; + action: string; + resourceType?: string; + resourceId?: string; + request?: Request; + metadata?: Record; +} + +export async function logAudit(opts: AuditOpts) { + try { + const ip = opts.request?.headers.get("x-forwarded-for")?.split(",")[0] + || opts.request?.headers.get("x-real-ip"); + const ua = opts.request?.headers.get("user-agent"); + + await sql` + INSERT INTO audit_log (family_id, user_id, action, resource_type, resource_id, ip_address, user_agent, metadata) + VALUES (${opts.familyId || null}, ${opts.userId || null}, ${opts.action}, + ${opts.resourceType || null}, ${opts.resourceId || null}, + ${ip || null}, ${ua || null}, ${JSON.stringify(opts.metadata || {})})`; + } catch (e) { + console.error("Audit log failed:", e); + // Never throw - audit failures shouldn't break the request + } +} \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..df901ca --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,148 @@ +// Session validation and family resolver for API routes +// All data routes must use this to validate sessions and get familyId + +import { sql } from "@/db"; +import { cookies } from "next/headers"; + +interface SessionData { + userId: string; + familyId: string | null; + familyName: string | null; + tier: string | null; +} + +interface AuthResult { + success: boolean; + error?: string; + status?: number; + session?: SessionData; +} + +/** + * Validate session cookie and resolve family information + * Use this at the start of every data API route + */ +export async function validateSession(): Promise { + try { + const cookieStore = await cookies(); + const sessionToken = cookieStore.get("tia_session")?.value; + + if (!sessionToken) { + return { success: false, error: "Not authenticated", status: 401 }; + } + + // Verify session and get family info + const sessions = await sql` + SELECT s.user_id, s.expires, fm.family_id as family_id, f.name as family_name, f.tier + FROM sessions s + JOIN users u ON u.id = s.user_id + LEFT JOIN family_members fm ON fm.user_id = u.id + LEFT JOIN families f ON f.id = fm.family_id + WHERE s.session_token = ${sessionToken} + AND s.expires > NOW() + LIMIT 1 + `; + + if (!sessions || sessions.length === 0) { + return { success: false, error: "Invalid or expired session", status: 401 }; + } + + const session = sessions[0]; + + return { + success: true, + session: { + userId: session.user_id, + familyId: session.family_id, + familyName: session.family_name, + tier: session.tier, + }, + }; + } catch (error) { + console.error("Session validation error:", error); + return { success: false, error: "Session validation failed", status: 500 }; + } +} + +/** + * Require authenticated user with a family + * Use for routes that require family data + */ +export async function requireFamily(): Promise { + const auth = await validateSession(); + + if (!auth.success) { + return auth; + } + + if (!auth.session?.familyId) { + return { success: false, error: "No family associated with session", status: 403 }; + } + + return auth; +} + +/** + * Require authenticated user who owns a specific resource + * Use to verify user has access to a resource (e.g., a specific child's data) + * + * @param resourceId - The ID of the resource to check + * @param table - The table name to check ownership in + * @param resourceLabel - Human-readable label for error messages + */ +export async function requireOwnership( + resourceId: string, + table: string, + resourceLabel: string +): Promise { + const auth = await requireFamily(); + + if (!auth.success) { + return auth; + } + + const familyId = auth.session!.familyId!; + + // Check if resource belongs to user's family + const result = await sql.unsafe( + `SELECT id FROM ${table} WHERE id = $1 AND family_id = $2 LIMIT 1`, + [resourceId, familyId] + ); + + if (!result || result.length === 0) { + return { + success: false, + error: `${resourceLabel} not found or access denied`, + status: 404, + }; + } + + return auth; +} + +/** + * Require a specific tier level + * Use for premium features + */ +export async function requireTier(minTier: "free" | "pro"): Promise { + const auth = await requireFamily(); + + if (!auth.success) { + return auth; + } + + const tier = auth.session?.tier || "free"; + const tierLevels = { free: 0, pro: 1 }; + const userLevel = tierLevels[tier as keyof typeof tierLevels] || 0; + const requiredLevel = tierLevels[minTier]; + + if (userLevel < requiredLevel) { + return { + success: false, + error: `Requires ${minTier} tier or higher`, + status: 403, + }; + } + + return auth; +} \ No newline at end of file diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..b178528 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,64 @@ +// Rate limiting utility +// Uses in-memory storage for simplicity - should use Redis in production + +interface RateLimitResult { + success: boolean; + remaining: number; + reset: Date; +} + +const store = new Map(); + +export function getClientIp(request: Request): string { + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) { + return forwarded.split(",")[0].trim(); + } + return request.headers.get("x-real-ip") || "unknown"; +} + +export async function rateLimit( + identifier: string, + opts: { max: number; windowSec: number } +): Promise { + const key = identifier; + const now = new Date(); + const windowMs = opts.windowSec * 1000; + + const record = store.get(key); + + if (!record || record.reset.getTime() < now.getTime()) { + // New window + const reset = new Date(now.getTime() + windowMs); + store.set(key, { count: 1, reset }); + return { + success: true, + remaining: opts.max - 1, + reset, + }; + } + + if (record.count >= opts.max) { + // Rate limited + return { + success: false, + remaining: 0, + reset: record.reset, + }; + } + + // Increment count + record.count++; + store.set(key, record); + + return { + success: true, + remaining: opts.max - record.count, + reset: record.reset, + }; +} + +// Helper to get rate limit key for an endpoint +export function getRateLimitKey(endpoint: string, identifier: string): string { + return `ratelimit:${endpoint}:${identifier}`; +} \ No newline at end of file