Merge pull request #351 from ArnasDon/fix/ghsa-34q7-fv77-625j-role-checks

fix(security): enforce agent role on service-role write routes (GHSA-34q7-fv77-625j)
This commit is contained in:
Arnas Donauskas 2026-07-08 09:39:41 +03:00 committed by GitHub
commit 03e851bea5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 86 additions and 2 deletions

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { supabaseAdmin } from '@/lib/automations/admin-client' import { supabaseAdmin } from '@/lib/automations/admin-client'
export async function POST( export async function POST(
@ -7,6 +8,16 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
const { id } = await params const { id } = await params
// Duplicating creates a new automation row — a write. Enforce `agent`
// (the service-role client below bypasses the agent-gated
// automations_insert RLS).
try {
await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const supabase = await createClient() const supabase = await createClient()
const { const {
data: { user }, data: { user },

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { supabaseAdmin } from '@/lib/automations/admin-client' import { supabaseAdmin } from '@/lib/automations/admin-client'
import { import {
loadStepsTree, loadStepsTree,
@ -47,6 +48,16 @@ export async function PATCH(
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
const { id } = await params const { id } = await params
// Editing an automation is a write — the RLS automations_update policy
// requires `agent`, but this route mutates via the service-role client
// which bypasses RLS, so enforce the role here.
try {
await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const user = await requireUser() const user = await requireUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@ -125,6 +136,15 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
const { id } = await params const { id } = await params
// Deleting an automation is a write — enforce `agent` (the service-role
// client below bypasses the agent-gated automations_delete RLS).
try {
await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const user = await requireUser() const user = await requireUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

View File

@ -1,5 +1,5 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { getCurrentAccount, toErrorResponse } from '@/lib/auth/account' import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { runAutomationsForTrigger } from '@/lib/automations/engine' import { runAutomationsForTrigger } from '@/lib/automations/engine'
import type { AutomationTriggerType } from '@/types' import type { AutomationTriggerType } from '@/types'
@ -9,9 +9,11 @@ import type { AutomationTriggerType } from '@/types'
* account_id and dispatch over the account's automations. * account_id and dispatch over the account's automations.
*/ */
export async function POST(request: Request) { export async function POST(request: Request) {
// Firing automations sends outbound WhatsApp — a write action. Require
// at least `agent`; a viewer must not be able to trigger sends.
let accountId: string let accountId: string
try { try {
const ctx = await getCurrentAccount() const ctx = await requireRole('agent')
accountId = ctx.accountId accountId = ctx.accountId
} catch (err) { } catch (err) {
return toErrorResponse(err) return toErrorResponse(err)

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { supabaseAdmin } from '@/lib/automations/admin-client' import { supabaseAdmin } from '@/lib/automations/admin-client'
import { getTemplate } from '@/lib/automations/templates' import { getTemplate } from '@/lib/automations/templates'
import { insertSteps, type BuilderStepInput } from '@/lib/automations/steps-tree' import { insertSteps, type BuilderStepInput } from '@/lib/automations/steps-tree'
@ -24,6 +25,15 @@ export async function GET() {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
// Creating an automation is a write — the RLS automations_insert policy
// requires `agent`, but this route inserts via the service-role client
// which bypasses RLS, so the role must be enforced here.
try {
await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const supabase = await createClient() const supabase = await createClient()
const { const {
data: { user }, data: { user },

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { supabaseAdmin } from '@/lib/flows/admin-client' import { supabaseAdmin } from '@/lib/flows/admin-client'
import { validateFlowForActivation } from '@/lib/flows/validate' import { validateFlowForActivation } from '@/lib/flows/validate'
@ -23,6 +24,16 @@ export async function POST(
) { ) {
const { id } = await context.params const { id } = await context.params
// Changing status (activate / draft / archive) is a write — the RLS
// flows_update policy requires `agent`, but the service-role client
// below bypasses RLS, so enforce the role here (a viewer passes the
// membership-only ownership check).
try {
await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const supabase = await createClient() const supabase = await createClient()
const { const {
data: { user }, data: { user },

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { supabaseAdmin } from '@/lib/flows/admin-client' import { supabaseAdmin } from '@/lib/flows/admin-client'
/** /**
@ -91,6 +92,16 @@ export async function PUT(
context: { params: Promise<{ id: string }> }, context: { params: Promise<{ id: string }> },
) { ) {
const { id } = await context.params const { id } = await context.params
// Writes require at least `agent` — the RLS flows_update policy demands
// it, but this route mutates via the service-role client which bypasses
// RLS, so the role must be enforced here (a viewer passes ownership).
try {
await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const guard = await requireOwnership(id) const guard = await requireOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status }) if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
@ -177,6 +188,15 @@ export async function DELETE(
context: { params: Promise<{ id: string }> }, context: { params: Promise<{ id: string }> },
) { ) {
const { id } = await context.params const { id } = await context.params
// Writes require at least `agent` — see the PUT handler note. The
// service-role client below bypasses the agent-gated flows_delete RLS.
try {
await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const guard = await requireOwnership(id) const guard = await requireOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status }) if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })

View File

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { supabaseAdmin } from '@/lib/flows/admin-client' import { supabaseAdmin } from '@/lib/flows/admin-client'
import { getFlowTemplate } from '@/lib/flows/templates' import { getFlowTemplate } from '@/lib/flows/templates'
@ -45,6 +46,15 @@ export async function GET() {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
// Creating a flow is a write — the RLS flows_insert policy requires
// `agent`, but this route inserts via the service-role client which
// bypasses RLS, so the role must be enforced here.
try {
await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const guard = await requireUser() const guard = await requireUser()
if (!guard.ok) { if (!guard.ok) {
return NextResponse.json(guard.body, { status: guard.status }) return NextResponse.json(guard.body, { status: guard.status })