security: CAPA privilege escalation, CSV injection, AI rate-limit, prompt injection guard, open redirect, timing-safe cron secret, server-only admin client, notifications RLS

This commit is contained in:
2026-07-12 17:33:58 +08:00
parent c0ec6660ef
commit c80091c8d6
7 changed files with 49 additions and 6 deletions
+10
View File
@@ -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<string, unknown> = {}
for (const key of allowed) {
+7 -3
View File
@@ -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 })
}
+17 -1
View File
@@ -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)}`,
<zone_data>
${JSON.stringify(aggregates, null, 2)}
</zone_data>`,
}],
})
} catch {