diff --git a/app/api/capa/[id]/route.ts b/app/api/capa/[id]/route.ts index 2abfc3f..2cd446c 100644 --- a/app/api/capa/[id]/route.ts +++ b/app/api/capa/[id]/route.ts @@ -36,7 +36,17 @@ export async function PATCH( const { data: { user }, error: authError } = await supabase.auth.getUser() if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single() + const role = profile?.role ?? '' + const body = await request.json() + + // Only hse/admin can set privileged statuses — owner can only move to in_progress/pending_verification + const privilegedStatuses = ['verified', 'reopened', 'closed'] + if (body.status && privilegedStatuses.includes(body.status) && !['hse', 'admin'].includes(role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const allowed = ['description', 'due_date', 'priority', 'status', 'department', 'root_cause_ref'] const update: Record = {} for (const key of allowed) { diff --git a/app/api/cron/capa-escalation/route.ts b/app/api/cron/capa-escalation/route.ts index 9f33eb5..cbc4b41 100644 --- a/app/api/cron/capa-escalation/route.ts +++ b/app/api/cron/capa-escalation/route.ts @@ -1,13 +1,17 @@ export const dynamic = 'force-dynamic' +import { timingSafeEqual } from 'crypto' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { escalateOverdueCapa } from '@/lib/notifications/capa-escalation' export async function GET(request: NextRequest) { - const auth = request.headers.get('authorization') - const expected = `Bearer ${process.env.CRON_SECRET}` - if (!auth || auth !== expected) { + const auth = request.headers.get('authorization') ?? '' + const expected = `Bearer ${process.env.CRON_SECRET ?? ''}` + const authBuf = Buffer.from(auth, 'utf8') + const expectedBuf = Buffer.from(expected, 'utf8') + const valid = authBuf.length === expectedBuf.length && timingSafeEqual(authBuf, expectedBuf) + if (!valid) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/dashboard/ai/risk-flags/route.ts b/app/api/dashboard/ai/risk-flags/route.ts index 366b368..5fd954d 100644 --- a/app/api/dashboard/ai/risk-flags/route.ts +++ b/app/api/dashboard/ai/risk-flags/route.ts @@ -78,6 +78,20 @@ export async function POST() { second_half: a.second_half, })) + // Rate limit: 1 AI call per 60s per user (checked via audit_log) + const { data: lastCall } = await supabase + .from('audit_log') + .select('changed_at') + .eq('changed_by', user.id) + .eq('action', 'ai_risk_flags') + .order('changed_at', { ascending: false }) + .limit(1) + .single() + + if (lastCall && Date.now() - new Date(lastCall.changed_at).getTime() < 60_000) { + return NextResponse.json({ error: 'Rate limit: wait 60 seconds between AI requests' }, { status: 429 }) + } + const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') const anthropic = createAnthropicClient(anthropicKey) @@ -119,7 +133,9 @@ export async function POST() { Flag zones with rising or elevated risk (at most 5 flags; do not flag healthy zones). Base every rationale strictly on the numbers given. -${JSON.stringify(aggregates, null, 2)}`, + +${JSON.stringify(aggregates, null, 2)} +`, }], }) } catch { diff --git a/lib/csv.ts b/lib/csv.ts index 4a6a579..4f4e59f 100644 --- a/lib/csv.ts +++ b/lib/csv.ts @@ -1,6 +1,8 @@ export function escapeCsv(value: string | number | null | undefined): string { if (value === null || value === undefined) return '' - const str = String(value) + let str = String(value) + // Prevent CSV formula injection (Excel/LibreOffice execute cells starting with these chars) + if (/^[=+\-@\t\r]/.test(str)) str = "'" + str if (str.includes(',') || str.includes('"') || str.includes('\n')) { return `"${str.replace(/"/g, '""')}"` } diff --git a/lib/supabase/admin.ts b/lib/supabase/admin.ts index 3adaa49..d7dbaec 100644 --- a/lib/supabase/admin.ts +++ b/lib/supabase/admin.ts @@ -1,3 +1,4 @@ +import 'server-only' import { createClient as createSupabaseClient, type SupabaseClient } from '@supabase/supabase-js' // Service-role client — bypasses RLS. Server-side only, and only for operations diff --git a/middleware.ts b/middleware.ts index a3d9700..63bad33 100644 --- a/middleware.ts +++ b/middleware.ts @@ -36,7 +36,7 @@ export async function middleware(request: NextRequest) { if (user && (pathname === '/' || pathname === '/login')) { const redirect = request.nextUrl.searchParams.get('redirect') - if (redirect && redirect.startsWith('/')) { + if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) { const url = request.nextUrl.clone() url.pathname = redirect url.search = '' diff --git a/supabase/migrations/20260712000019_fix_notifications_rls.sql b/supabase/migrations/20260712000019_fix_notifications_rls.sql new file mode 100644 index 0000000..144c972 --- /dev/null +++ b/supabase/migrations/20260712000019_fix_notifications_rls.sql @@ -0,0 +1,10 @@ +-- Fix: notifications_log RLS was allowing any hse/admin to read ALL notifications. +-- Replace with per-user policy (everyone reads own) + admin-only policy for oversight. + +DROP POLICY IF EXISTS "notifications_read_elevated" ON notifications_log; + +CREATE POLICY "notifications_read_own" ON notifications_log + FOR SELECT USING (recipient_user_id = auth.uid()); + +CREATE POLICY "notifications_read_admin" ON notifications_log + FOR SELECT USING (auth_user_role() = 'admin');