From 16dd62df1139928600634d99bea1dc7877f90d01 Mon Sep 17 00:00:00 2001 From: weeihan Date: Sun, 12 Jul 2026 20:47:32 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20P1=20API=20security=20hardening=20?= =?UTF-8?q?=E2=80=94=20rate=20limits,=20auth=20guards,=20duplicate=20preve?= =?UTF-8?q?ntion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify/route.ts: setDate → setUTCDate to avoid timezone off-by-one on recheck date - triage-suggest, rca-draft, quality-check: 60s per-user rate limit via audit_log - quality-check: add write_audit_log (was missing, CLAUDE.md violation) - investigation POST: 409 if investigation already exists for incident - incidents POST: 60s per-user rate limit via audit_log - addenda GET: restrict to hse/admin/supervisor roles - dashboard/stats GET: restrict to hse/admin/management roles Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ --- app/api/capa/[id]/verify/route.ts | 2 +- app/api/dashboard/stats/route.ts | 4 ++++ app/api/incidents/[id]/addenda/route.ts | 4 ++++ app/api/incidents/[id]/ai/rca-draft/route.ts | 10 ++++++++++ .../incidents/[id]/ai/triage-suggest/route.ts | 10 ++++++++++ app/api/incidents/[id]/investigation/route.ts | 7 +++++++ app/api/incidents/ai/quality-check/route.ts | 18 ++++++++++++++++++ app/api/incidents/route.ts | 11 +++++++++++ 8 files changed, 65 insertions(+), 1 deletion(-) diff --git a/app/api/capa/[id]/verify/route.ts b/app/api/capa/[id]/verify/route.ts index 2b2b7fc..945642f 100644 --- a/app/api/capa/[id]/verify/route.ts +++ b/app/api/capa/[id]/verify/route.ts @@ -31,7 +31,7 @@ export async function POST( const verifiedAt = new Date() const recheckDate = new Date(verifiedAt) - recheckDate.setDate(recheckDate.getDate() + 30) + recheckDate.setUTCDate(recheckDate.getUTCDate() + 30) const update: Record = { status: body.verdict, diff --git a/app/api/dashboard/stats/route.ts b/app/api/dashboard/stats/route.ts index 6b4bc28..67e1843 100644 --- a/app/api/dashboard/stats/route.ts +++ b/app/api/dashboard/stats/route.ts @@ -8,6 +8,10 @@ export async function GET() { const { data: { user } } = await supabase.auth.getUser() if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single() + if (!profile || !['hse', 'admin', 'management'].includes(profile.role)) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + const { data: incidents, error } = await supabase .from('incidents') .select('id, status, incident_type, sites (name)') diff --git a/app/api/incidents/[id]/addenda/route.ts b/app/api/incidents/[id]/addenda/route.ts index ab9f369..741f8ee 100644 --- a/app/api/incidents/[id]/addenda/route.ts +++ b/app/api/incidents/[id]/addenda/route.ts @@ -13,6 +13,10 @@ export async function GET( 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() + if (!profile || !['hse', 'admin', 'supervisor'].includes(profile.role)) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + const { data, error } = await supabase .from('incident_addenda') .select('id, body, created_at, author:users!author (name)') diff --git a/app/api/incidents/[id]/ai/rca-draft/route.ts b/app/api/incidents/[id]/ai/rca-draft/route.ts index e6214fe..a84b557 100644 --- a/app/api/incidents/[id]/ai/rca-draft/route.ts +++ b/app/api/incidents/[id]/ai/rca-draft/route.ts @@ -18,6 +18,16 @@ export async function POST( if (!profile || !['hse', 'admin'].includes(profile.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + const since = new Date(Date.now() - 60_000).toISOString() + const { count: recentCount } = await supabase + .from('audit_log') + .select('id', { count: 'exact', head: true }) + .eq('changed_by', user.id) + .eq('action', 'ai_rca_draft') + .gte('changed_at', since) + if ((recentCount ?? 0) > 0) + return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 }) + const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') const anthropic = createAnthropicClient(anthropicKey) diff --git a/app/api/incidents/[id]/ai/triage-suggest/route.ts b/app/api/incidents/[id]/ai/triage-suggest/route.ts index 8362486..7237ef1 100644 --- a/app/api/incidents/[id]/ai/triage-suggest/route.ts +++ b/app/api/incidents/[id]/ai/triage-suggest/route.ts @@ -18,6 +18,16 @@ export async function POST( if (!profile || !['hse', 'admin'].includes(profile.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + const since = new Date(Date.now() - 60_000).toISOString() + const { count: recentCount } = await supabase + .from('audit_log') + .select('id', { count: 'exact', head: true }) + .eq('changed_by', user.id) + .eq('action', 'ai_triage_suggest') + .gte('changed_at', since) + if ((recentCount ?? 0) > 0) + return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 }) + const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') const anthropic = createAnthropicClient(anthropicKey) diff --git a/app/api/incidents/[id]/investigation/route.ts b/app/api/incidents/[id]/investigation/route.ts index 2e126d9..a2adba2 100644 --- a/app/api/incidents/[id]/investigation/route.ts +++ b/app/api/incidents/[id]/investigation/route.ts @@ -24,6 +24,13 @@ export async function POST( if (incident.status !== 'triaged') return NextResponse.json({ error: 'Incident must be triaged first' }, { status: 409 }) + const { count: existingCount } = await supabase + .from('investigations') + .select('id', { count: 'exact', head: true }) + .eq('incident_id', id) + if ((existingCount ?? 0) > 0) + return NextResponse.json({ error: 'Investigation already exists for this incident' }, { status: 409 }) + const body = await request.json() const method: 'five_why' | 'fishbone' | 'other' = body.method ?? 'five_why' diff --git a/app/api/incidents/ai/quality-check/route.ts b/app/api/incidents/ai/quality-check/route.ts index 0f22dcc..dbc4eab 100644 --- a/app/api/incidents/ai/quality-check/route.ts +++ b/app/api/incidents/ai/quality-check/route.ts @@ -10,6 +10,16 @@ export async function POST(request: NextRequest) { const { data: { user }, error: authError } = await supabase.auth.getUser() if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const since = new Date(Date.now() - 60_000).toISOString() + const { count: recentCount } = await supabase + .from('audit_log') + .select('id', { count: 'exact', head: true }) + .eq('changed_by', user.id) + .eq('action', 'ai_quality_check') + .gte('changed_at', since) + if ((recentCount ?? 0) > 0) + return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 }) + const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') const anthropic = createAnthropicClient(anthropicKey) @@ -81,5 +91,13 @@ Score 1–10 based on: specificity (location, time, persons involved), completen ) { return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) } + + await supabase.rpc('write_audit_log', { + p_table_name: 'incidents', + p_record_id: user.id, + p_action: 'ai_quality_check', + p_new_value: { score: input.score, passes: input.passes, model: 'claude-opus-4-8' } as never, + }) + return NextResponse.json(input) } diff --git a/app/api/incidents/route.ts b/app/api/incidents/route.ts index c4c92cc..4eade5c 100644 --- a/app/api/incidents/route.ts +++ b/app/api/incidents/route.ts @@ -15,6 +15,17 @@ export async function POST(request: Request) { if (authError || !data?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const user = data.user + const since = new Date(Date.now() - 60_000).toISOString() + const { count: recentIncidents } = await supabase + .from('audit_log') + .select('id', { count: 'exact', head: true }) + .eq('changed_by', user.id) + .eq('table_name', 'incidents') + .eq('action', 'INSERT') + .gte('changed_at', since) + if ((recentIncidents ?? 0) > 0) + return NextResponse.json({ error: 'Rate limited — please wait 60 seconds before submitting another incident' }, { status: 429 }) + let body: Record let files: File[] = []