harden: defense-in-depth on vote/signup/area-lead boundaries
Audit follow-up (RLS already enforced these; adding boundary checks so a future RLS regression cannot silently allow cross-space writes): - castVote: verify the proposal is in the caller's space and still open before the upsert. - cancelMySignup: scope every admin-client query/update in the waitlist promotion path by member.space_id (not session_id alone). - setAreaLead: reject a lead_id that is not a member of the space. - signUpForClass: comment the space-safety invariant on the count. No behavior change for valid callers.
This commit is contained in:
@@ -28,6 +28,19 @@ export async function upsertAreaLead(formData: {
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
// If a lead member is referenced, confirm they belong to this space (the
|
||||
// row is space-scoped and RLS scopes reads, but reject a foreign id at the
|
||||
// boundary for consistency with the rest of the codebase).
|
||||
if (v.data.lead_id) {
|
||||
const { data: lead } = await supabase
|
||||
.from('space_members')
|
||||
.select('id')
|
||||
.eq('id', v.data.lead_id)
|
||||
.eq('space_id', member.space_id)
|
||||
.maybeSingle()
|
||||
if (!lead) return { error: 'The selected lead is not a member of this space.' }
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('area_leads')
|
||||
.upsert(
|
||||
|
||||
+10
-1
@@ -531,6 +531,8 @@ export async function signUpForClass(input: unknown) {
|
||||
session.capacity as number | null,
|
||||
cls?.capacity ?? null,
|
||||
)
|
||||
// session was fetched scoped by member.space_id above, so counting by
|
||||
// session_id alone is space-safe (do not loosen the session lookup).
|
||||
const { count: registered } = await admin
|
||||
.from('class_signups')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
@@ -571,9 +573,13 @@ export async function cancelMySignup(input: unknown) {
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const admin = createAdminClient()
|
||||
// All admin-client queries below are additionally scoped by member.space_id:
|
||||
// member.id can only hold a signup in its own space, but the writes must not
|
||||
// be reachable cross-space even if a future edit loosens the member filter.
|
||||
const { data: signup } = await admin
|
||||
.from('class_signups')
|
||||
.select('id, status')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('session_id', v.data.sessionId)
|
||||
.eq('member_id', member.id)
|
||||
.neq('status', 'cancelled')
|
||||
@@ -585,6 +591,7 @@ export async function cancelMySignup(input: unknown) {
|
||||
.from('class_signups')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', signup.id)
|
||||
.eq('space_id', member.space_id)
|
||||
if (error) return { error: error.message }
|
||||
|
||||
// If a registered seat freed up, promote the earliest waitlisted member.
|
||||
@@ -593,6 +600,7 @@ export async function cancelMySignup(input: unknown) {
|
||||
.from('class_sessions')
|
||||
.select('capacity, classes(capacity)')
|
||||
.eq('id', v.data.sessionId)
|
||||
.eq('space_id', member.space_id)
|
||||
.maybeSingle()
|
||||
const cap = effectiveCapacity(
|
||||
(session as { capacity: number | null } | null)?.capacity ?? null,
|
||||
@@ -601,6 +609,7 @@ export async function cancelMySignup(input: unknown) {
|
||||
const { data: all } = await admin
|
||||
.from('class_signups')
|
||||
.select('id, status, signed_up_at')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('session_id', v.data.sessionId)
|
||||
.neq('status', 'cancelled')
|
||||
const promoteId = pickPromotion(
|
||||
@@ -608,7 +617,7 @@ export async function cancelMySignup(input: unknown) {
|
||||
cap,
|
||||
)
|
||||
if (promoteId) {
|
||||
await admin.from('class_signups').update({ status: 'registered' }).eq('id', promoteId)
|
||||
await admin.from('class_signups').update({ status: 'registered' }).eq('id', promoteId).eq('space_id', member.space_id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,19 @@ export async function castVote(formData: {
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
// Defense in depth: the votes RLS policies already enforce same-space,
|
||||
// open status and the voting window, but this is a sensitive mutation, so
|
||||
// validate at the action boundary too (a future RLS regression must not
|
||||
// silently allow cross-space vote injection).
|
||||
const { data: proposal } = await supabase
|
||||
.from('proposals')
|
||||
.select('id, status')
|
||||
.eq('id', v.data.proposalId)
|
||||
.eq('space_id', member.space_id)
|
||||
.maybeSingle()
|
||||
if (!proposal) return { error: 'Proposal not found' }
|
||||
if (proposal.status !== 'open') return { error: 'Voting is closed for this proposal.' }
|
||||
|
||||
const { error } = await supabase
|
||||
.from('proposal_votes')
|
||||
.upsert(
|
||||
|
||||
Reference in New Issue
Block a user