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:
@@ -36,7 +36,17 @@ export async function PATCH(
|
|||||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||||
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
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()
|
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 allowed = ['description', 'due_date', 'priority', 'status', 'department', 'root_cause_ref']
|
||||||
const update: Record<string, unknown> = {}
|
const update: Record<string, unknown> = {}
|
||||||
for (const key of allowed) {
|
for (const key of allowed) {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
import { timingSafeEqual } from 'crypto'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { escalateOverdueCapa } from '@/lib/notifications/capa-escalation'
|
import { escalateOverdueCapa } from '@/lib/notifications/capa-escalation'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const auth = request.headers.get('authorization')
|
const auth = request.headers.get('authorization') ?? ''
|
||||||
const expected = `Bearer ${process.env.CRON_SECRET}`
|
const expected = `Bearer ${process.env.CRON_SECRET ?? ''}`
|
||||||
if (!auth || auth !== expected) {
|
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 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,20 @@ export async function POST() {
|
|||||||
second_half: a.second_half,
|
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 anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY')
|
||||||
const anthropic = createAnthropicClient(anthropicKey)
|
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.
|
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 {
|
} catch {
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
export function escapeCsv(value: string | number | null | undefined): string {
|
export function escapeCsv(value: string | number | null | undefined): string {
|
||||||
if (value === null || value === undefined) return ''
|
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')) {
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||||
return `"${str.replace(/"/g, '""')}"`
|
return `"${str.replace(/"/g, '""')}"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'server-only'
|
||||||
import { createClient as createSupabaseClient, type SupabaseClient } from '@supabase/supabase-js'
|
import { createClient as createSupabaseClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
// Service-role client — bypasses RLS. Server-side only, and only for operations
|
// Service-role client — bypasses RLS. Server-side only, and only for operations
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
if (user && (pathname === '/' || pathname === '/login')) {
|
if (user && (pathname === '/' || pathname === '/login')) {
|
||||||
const redirect = request.nextUrl.searchParams.get('redirect')
|
const redirect = request.nextUrl.searchParams.get('redirect')
|
||||||
if (redirect && redirect.startsWith('/')) {
|
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
||||||
const url = request.nextUrl.clone()
|
const url = request.nextUrl.clone()
|
||||||
url.pathname = redirect
|
url.pathname = redirect
|
||||||
url.search = ''
|
url.search = ''
|
||||||
|
|||||||
@@ -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');
|
||||||
Reference in New Issue
Block a user