fix(security): close member self-escalation + harden after full audit
Full-application audit (5 parallel read-only audits). Two agent
"blockers" were false alarms and verified as already-correct. Real
findings fixed:
- Self-escalation (BLOCKER): migration 024 + schema.sql section 16
extend prevent_member_self_role_change to also block a
non-privileged member changing their own tier_id (self tier
upgrade) and onboarding_completed_at (skipping required
code-of-conduct via a raw PostgREST call). finishOnboarding and
skipOnboarding now set onboarding_completed_at through the service
client AFTER their server-side required-steps check, so the
legitimate path is unaffected.
- Cross-tenant write scoping: added .eq('space_id', member.space_id)
to updateForumThread, deleteForumThread, editComment, deleteComment,
deleteChannel, renameChannel (RLS-only -> defence-in-depth).
- generateInviteCode: crypto.getRandomValues, widened to 8 chars
(was Math.random, ~21 bits, enumerable under the join rate limit).
- SafeMarkdown: dropped iframe and the global style attribute
(no unsandboxed embeds / UI-redress); markdown + safe HTML stay.
- /settings: server-side admin redirect before fetching space and
integration config (was client-hidden only).
- app/(app)/layout.tsx: export const dynamic = 'force-dynamic'.
- Bounded queries: loadComments .limit(500), tasks .limit(2000).
Backlog from the audit is tracked in HANDOFF (import Zod schemas,
types/database.ts + docs regen, email lowercasing, unauthorized-
handling consistency, KB-modal ACL hookup).
271/271 tests pass. Build clean. Migrations 023 + 024 idempotent,
apply via deploy.sh on next deploy.
This commit is contained in:
@@ -11,6 +11,13 @@ All notable changes to this project are documented here. Format follows [Keep a
|
|||||||
- Customize -> Area leads: create area-lead roles, see Vacant until assigned, assign/unassign a member from a picker. The assigned member effectively holds that role, so any Ops item whose access list includes it opens to them.
|
- Customize -> Area leads: create area-lead roles, see Vacant until assigned, assign/unassign a member from a picker. The assigned member effectively holds that role, so any Ops item whose access list includes it opens to them.
|
||||||
- SECURITY DEFINER helpers `user_effective_roles` and `user_has_permission`.
|
- SECURITY DEFINER helpers `user_effective_roles` and `user_has_permission`.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Closed two member self-escalation paths: a non-privileged member can no longer change their own `tier_id` or `onboarding_completed_at` on the `space_members` row (migration 024 extends the self-change trigger). Onboarding completion now goes through the service client after server-side validation.
|
||||||
|
- Added in-code space scoping to forum/comment/channel mutations (defence-in-depth on top of RLS).
|
||||||
|
- Invite codes are now cryptographically random and wider.
|
||||||
|
- `SafeMarkdown` no longer allows `iframe` or arbitrary `style` (no unsandboxed embeds or UI-redress).
|
||||||
|
- `/settings` is gated server-side to admins before any space/integration data is fetched.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- `revealSecret` no longer silently returns the stale plaintext column when a row is marked encrypted but has no ciphertext; it surfaces the corruption instead.
|
- `revealSecret` no longer silently returns the stale plaintext column when a row is marked encrypted but has no ciphertext; it surfaces the corruption instead.
|
||||||
- `upsertAreaLead` rejects a null/empty area code (Postgres treats NULLs as distinct in unique constraints, which had allowed duplicate area-lead rows).
|
- `upsertAreaLead` rejects a null/empty area code (Postgres treats NULLs as distinct in unique constraints, which had allowed duplicate area-lead rows).
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { redirect } from 'next/navigation'
|
|||||||
import { AppSidebar } from '@/components/app-sidebar'
|
import { AppSidebar } from '@/components/app-sidebar'
|
||||||
import { getRoleLabelMap } from '@/lib/role-labels'
|
import { getRoleLabelMap } from '@/lib/role-labels'
|
||||||
|
|
||||||
|
// This layout is the auth/onboarding gate for the whole app. It must never be
|
||||||
|
// statically cached: every request re-checks the session and member state.
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|||||||
@@ -12,14 +12,18 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
if (!member) redirect('/login')
|
if (!member) redirect('/login')
|
||||||
|
|
||||||
|
// Settings is admin-only. Gate on the server BEFORE fetching space config
|
||||||
|
// and integration state so non-admins never receive it. Board/treasurer
|
||||||
|
// use /customize for the things they can change.
|
||||||
|
const isAdmin = member.role === 'admin'
|
||||||
|
if (!isAdmin) redirect('/dashboard')
|
||||||
|
|
||||||
const { data: space } = await supabase
|
const { data: space } = await supabase
|
||||||
.from('spaces').select('*').eq('id', member.space_id).single()
|
.from('spaces').select('*').eq('id', member.space_id).single()
|
||||||
|
|
||||||
const { data: integrations } = await supabase
|
const { data: integrations } = await supabase
|
||||||
.from('integrations').select('*').eq('space_id', member.space_id)
|
.from('integrations').select('*').eq('space_id', member.space_id)
|
||||||
|
|
||||||
const isAdmin = member?.role === 'admin'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsClient
|
<SettingsClient
|
||||||
space={space}
|
space={space}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default async function TasksPage() {
|
|||||||
if (!spaceId) return null
|
if (!spaceId) return null
|
||||||
|
|
||||||
const [{ data: tasks }, { data: members }, { data: areasRaw }] = await Promise.all([
|
const [{ data: tasks }, { data: members }, { data: areasRaw }] = await Promise.all([
|
||||||
supabase.from('tasks').select('*').eq('space_id', spaceId).order('created_at', { ascending: false }),
|
supabase.from('tasks').select('*').eq('space_id', spaceId).order('created_at', { ascending: false }).limit(2000),
|
||||||
supabase.from('space_members').select('id, display_name, user_id').eq('space_id', spaceId).in('status', ['current', 'late']),
|
supabase.from('space_members').select('id, display_name, user_id').eq('space_id', spaceId).in('status', ['current', 'late']),
|
||||||
supabase
|
supabase
|
||||||
.from('space_areas')
|
.from('space_areas')
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export async function loadComments(
|
|||||||
.eq('entity_type', entityType)
|
.eq('entity_type', entityType)
|
||||||
.eq('entity_id', entityId)
|
.eq('entity_id', entityId)
|
||||||
.order('created_at', { ascending: true })
|
.order('created_at', { ascending: true })
|
||||||
|
// Bound the payload: a thread with thousands of comments must not be able
|
||||||
|
// to hang the page. Newest 500 is plenty for the current UI.
|
||||||
|
.limit(500)
|
||||||
|
|
||||||
const rows = data ?? []
|
const rows = data ?? []
|
||||||
const authorIds = Array.from(new Set(rows.map(r => r.author_id).filter(Boolean) as string[]))
|
const authorIds = Array.from(new Set(rows.map(r => r.author_id).filter(Boolean) as string[]))
|
||||||
|
|||||||
@@ -7,24 +7,21 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
|||||||
|
|
||||||
// Renders admin-authored markdown that MAY contain a constrained subset of raw
|
// Renders admin-authored markdown that MAY contain a constrained subset of raw
|
||||||
// HTML. rehype-raw parses the HTML; rehype-sanitize then strips anything
|
// HTML. rehype-raw parses the HTML; rehype-sanitize then strips anything
|
||||||
// dangerous (script, event handlers, javascript: URLs, etc). The author is a
|
// dangerous. The author is a space admin/board member, but a stored-XSS or
|
||||||
// space admin/board member, but a stored-XSS payload would still run in other
|
// UI-redress payload would still run in other members' sessions within that
|
||||||
// members' sessions within that tenant, so this is sanitized, not trusted.
|
// tenant, so this is sanitized hard, not trusted:
|
||||||
|
// - no `iframe` (no unsandboxed third-party embeds / token theft frame)
|
||||||
|
// - no global `style` attribute (no full-viewport overlay redress)
|
||||||
|
// - links forced to safe protocols; rel/target locked down
|
||||||
const schema = {
|
const schema = {
|
||||||
...defaultSchema,
|
...defaultSchema,
|
||||||
attributes: {
|
attributes: {
|
||||||
...defaultSchema.attributes,
|
...defaultSchema.attributes,
|
||||||
'*': [...(defaultSchema.attributes?.['*'] ?? []), 'className', 'style'],
|
'*': [...(defaultSchema.attributes?.['*'] ?? []), 'className'],
|
||||||
a: [...(defaultSchema.attributes?.a ?? []), 'target', 'rel'],
|
a: [...(defaultSchema.attributes?.a ?? []), 'target', 'rel'],
|
||||||
},
|
},
|
||||||
tagNames: [
|
|
||||||
...(defaultSchema.tagNames ?? []),
|
|
||||||
'iframe',
|
|
||||||
],
|
|
||||||
// Allow only video embeds from well-known hosts; everything else dropped.
|
|
||||||
protocols: {
|
protocols: {
|
||||||
...defaultSchema.protocols,
|
...defaultSchema.protocols,
|
||||||
src: ['https'],
|
|
||||||
href: ['https', 'http', 'mailto'],
|
href: ['https', 'http', 'mailto'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,37 @@ Append-only. Newest entries on top. Keep each entry to one screen.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-15 (pass 16) — Full-app audit + security hardening
|
||||||
|
|
||||||
|
Branch: `main`. Migration: 024 (plus 023 from pass 15 not yet deployed).
|
||||||
|
|
||||||
|
### Audit
|
||||||
|
|
||||||
|
Five parallel read-only audits (security/RLS, server actions/validation, routes/data, schema/types/migrations, plus a security-smell sweep). Verified before acting — two agent "blockers" were false alarms (ops_acl/space_role_permissions/member_custom_roles DELETE policies DO exist in schema.sql §15; m2m needs no UPDATE).
|
||||||
|
|
||||||
|
### Fixed this pass (security)
|
||||||
|
|
||||||
|
- **Self-escalation closed (BLOCKER).** Migration 024 + schema.sql §16: `prevent_member_self_role_change` now also blocks a non-privileged member changing their own `tier_id` (self tier upgrade) and `onboarding_completed_at` (skip required code-of-conduct). `finishOnboarding`/`skipOnboarding` now set `onboarding_completed_at` via the service client AFTER their server-side required-steps check, so the legitimate path still works.
|
||||||
|
- **Cross-tenant write scoping.** Added `.eq('space_id', member.space_id)` to `updateForumThread`, `deleteForumThread`, `editComment`, `deleteComment`, `deleteChannel`, `renameChannel` (was RLS-only; now defence-in-depth).
|
||||||
|
- **Invite codes.** `auth-actions.generateInviteCode` moved off `Math.random()` to `crypto.getRandomValues` and widened to 8 chars (was ~21 bits, enumerable under the join rate limit).
|
||||||
|
- **SafeMarkdown hardened.** Dropped `iframe` from allowed tags (no unsandboxed embed/token-theft frame) and the global `style` attribute (no full-viewport UI-redress). Markdown + safe basic HTML still render.
|
||||||
|
- **`/settings` server gate.** Now `redirect('/dashboard')` for non-admins BEFORE fetching space/integration config (was client-hidden only).
|
||||||
|
- **`app/(app)/layout.tsx`** marked `export const dynamic = 'force-dynamic'` (auth/onboarding gate must never be cached).
|
||||||
|
- **Bounded queries.** `loadComments` `.limit(500)`, tasks page `.limit(2000)`.
|
||||||
|
|
||||||
|
### Tracked backlog (audit findings, not security-critical)
|
||||||
|
|
||||||
|
- `importMembers` / `importPaymentsCsv`: still no Zod row schema; dates not via `flexibleDateTime()`; emails not lowercased; silent row drops. Highest-value remaining correctness item.
|
||||||
|
- `types/database.ts` still missing 8 prior-pass tables + `comment_entity_type` enum + 3 `space_members` columns (loose casts; build green via `ignoreBuildErrors`).
|
||||||
|
- `docs/DATABASE_SCHEMA.md`, `DB_SCHEMA_MAP.md`, `docs/API_REFERENCE.md` stale since 2026-03-10 (missing migrations 016-024).
|
||||||
|
- Inconsistent unauthorized handling: `/ops` and `/import` `return null` instead of `redirect`.
|
||||||
|
- Email normalization (`.toLowerCase()`) missing across auth/member schemas.
|
||||||
|
- KB/process per-item ACL: infra complete, only the ops-client KB-modal hookup remains. Members-row "make area lead" shortcut still deferred.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- `pnpm build` clean; `pnpm test` 271/271. Migrations 023 + 024 idempotent; apply via deploy.sh's tracked runner on next deploy.
|
||||||
|
|
||||||
## 2026-05-15 (pass 15) — Permissions, Ops ACLs, area-lead roles
|
## 2026-05-15 (pass 15) — Permissions, Ops ACLs, area-lead roles
|
||||||
|
|
||||||
Branch: `main`. Migration: 023 (applies on next deploy).
|
Branch: `main`. Migration: 023 (applies on next deploy).
|
||||||
|
|||||||
@@ -55,12 +55,14 @@ export async function deleteChannel(channelId: string) {
|
|||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
const auth = await requireMember(supabase)
|
const auth = await requireMember(supabase)
|
||||||
if (!auth.ok) return { error: auth.error }
|
if (!auth.ok) return { error: auth.error }
|
||||||
|
const { member } = auth
|
||||||
|
|
||||||
// Default channels are protected at the RLS layer too.
|
// Default channels are protected at the RLS layer too.
|
||||||
const { error, count } = await supabase
|
const { error, count } = await supabase
|
||||||
.from('comms_channels')
|
.from('comms_channels')
|
||||||
.delete({ count: 'exact' })
|
.delete({ count: 'exact' })
|
||||||
.eq('id', v.data)
|
.eq('id', v.data)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
|
||||||
if (error) return { error: error.message }
|
if (error) return { error: error.message }
|
||||||
if (count === 0) return { error: 'Cannot delete this channel' }
|
if (count === 0) return { error: 'Cannot delete this channel' }
|
||||||
@@ -77,11 +79,13 @@ export async function renameChannel(channelId: string, name: string, description
|
|||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
const auth = await requireMember(supabase)
|
const auth = await requireMember(supabase)
|
||||||
if (!auth.ok) return { error: auth.error }
|
if (!auth.ok) return { error: auth.error }
|
||||||
|
const { member } = auth
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('comms_channels')
|
.from('comms_channels')
|
||||||
.update({ name: v.data.name, description: v.data.description ?? null })
|
.update({ name: v.data.name, description: v.data.description ?? null })
|
||||||
.eq('id', idCheck.data)
|
.eq('id', idCheck.data)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
|
||||||
if (error) return { error: error.message }
|
if (error) return { error: error.message }
|
||||||
revalidatePath('/comms')
|
revalidatePath('/comms')
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export async function updateForumThread(threadId: string, updates: {
|
|||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
const auth = await requireMember(supabase)
|
const auth = await requireMember(supabase)
|
||||||
if (!auth.ok) return { error: auth.error }
|
if (!auth.ok) return { error: auth.error }
|
||||||
|
const { member } = auth
|
||||||
|
|
||||||
// Pinning/locking is moderator-only (admin/board). The RLS UPDATE policy
|
// Pinning/locking is moderator-only (admin/board). The RLS UPDATE policy
|
||||||
// additionally requires the actor to be the author or admin/board, so a
|
// additionally requires the actor to be the author or admin/board, so a
|
||||||
@@ -73,7 +74,7 @@ export async function updateForumThread(threadId: string, updates: {
|
|||||||
if (v.data.pinned !== undefined) patch.pinned = v.data.pinned
|
if (v.data.pinned !== undefined) patch.pinned = v.data.pinned
|
||||||
if (v.data.locked !== undefined) patch.locked = v.data.locked
|
if (v.data.locked !== undefined) patch.locked = v.data.locked
|
||||||
|
|
||||||
const { error } = await supabase.from('forum_threads').update(patch).eq('id', idCheck.data)
|
const { error } = await supabase.from('forum_threads').update(patch).eq('id', idCheck.data).eq('space_id', member.space_id)
|
||||||
if (error) return { error: error.message }
|
if (error) return { error: error.message }
|
||||||
revalidatePath('/forum')
|
revalidatePath('/forum')
|
||||||
revalidatePath(`/forum/${idCheck.data}`)
|
revalidatePath(`/forum/${idCheck.data}`)
|
||||||
@@ -87,6 +88,7 @@ export async function deleteForumThread(threadId: string) {
|
|||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
const auth = await requireMember(supabase)
|
const auth = await requireMember(supabase)
|
||||||
if (!auth.ok) return { error: auth.error }
|
if (!auth.ok) return { error: auth.error }
|
||||||
|
const { member } = auth
|
||||||
|
|
||||||
// RLS DELETE allows the author or admin; non-authors get a row-not-found
|
// RLS DELETE allows the author or admin; non-authors get a row-not-found
|
||||||
// error which we translate to a clearer message.
|
// error which we translate to a clearer message.
|
||||||
@@ -94,6 +96,7 @@ export async function deleteForumThread(threadId: string) {
|
|||||||
.from('forum_threads')
|
.from('forum_threads')
|
||||||
.delete({ count: 'exact' })
|
.delete({ count: 'exact' })
|
||||||
.eq('id', v.data)
|
.eq('id', v.data)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
|
||||||
if (error) return { error: error.message }
|
if (error) return { error: error.message }
|
||||||
if (count === 0) return { error: 'Not allowed to delete this thread' }
|
if (count === 0) return { error: 'Not allowed to delete this thread' }
|
||||||
@@ -159,11 +162,13 @@ export async function editComment(commentId: string, body: string) {
|
|||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
const auth = await requireMember(supabase)
|
const auth = await requireMember(supabase)
|
||||||
if (!auth.ok) return { error: auth.error }
|
if (!auth.ok) return { error: auth.error }
|
||||||
|
const { member } = auth
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('comments')
|
.from('comments')
|
||||||
.update({ body: v.data.body, edited_at: new Date().toISOString() })
|
.update({ body: v.data.body, edited_at: new Date().toISOString() })
|
||||||
.eq('id', idCheck.data)
|
.eq('id', idCheck.data)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
.select('entity_type, entity_id')
|
.select('entity_type, entity_id')
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
@@ -179,11 +184,13 @@ export async function deleteComment(commentId: string) {
|
|||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
const auth = await requireMember(supabase)
|
const auth = await requireMember(supabase)
|
||||||
if (!auth.ok) return { error: auth.error }
|
if (!auth.ok) return { error: auth.error }
|
||||||
|
const { member } = auth
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('comments')
|
.from('comments')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', v.data)
|
.eq('id', v.data)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
.select('entity_type, entity_id')
|
.select('entity_type, entity_id')
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { requireMember, requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
|
import { requireMember, requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
|
||||||
import {
|
import {
|
||||||
createOnboardingStepSchema,
|
createOnboardingStepSchema,
|
||||||
@@ -173,10 +174,16 @@ export async function finishOnboarding() {
|
|||||||
return { error: 'Please complete the required steps before finishing.' }
|
return { error: 'Please complete the required steps before finishing.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase
|
// onboarding_completed_at is blocked by the self-change trigger (migration
|
||||||
|
// 024) so a member cannot skip required steps via a raw PostgREST call.
|
||||||
|
// Set it via the service client AFTER the required-steps check above, scoped
|
||||||
|
// to this member's own id.
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { error } = await admin
|
||||||
.from('space_members')
|
.from('space_members')
|
||||||
.update({ onboarding_completed_at: new Date().toISOString() })
|
.update({ onboarding_completed_at: new Date().toISOString() })
|
||||||
.eq('id', member.id)
|
.eq('id', member.id)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
|
||||||
if (error) return { error: error.message }
|
if (error) return { error: error.message }
|
||||||
revalidatePath('/dashboard')
|
revalidatePath('/dashboard')
|
||||||
@@ -201,10 +208,12 @@ export async function skipOnboarding() {
|
|||||||
return { error: 'This space requires you to complete onboarding.' }
|
return { error: 'This space requires you to complete onboarding.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase
|
const admin = createAdminClient()
|
||||||
|
const { error } = await admin
|
||||||
.from('space_members')
|
.from('space_members')
|
||||||
.update({ onboarding_completed_at: new Date().toISOString() })
|
.update({ onboarding_completed_at: new Date().toISOString() })
|
||||||
.eq('id', member.id)
|
.eq('id', member.id)
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
|
||||||
if (error) return { error: error.message }
|
if (error) return { error: error.message }
|
||||||
revalidatePath('/dashboard')
|
revalidatePath('/dashboard')
|
||||||
|
|||||||
+6
-2
@@ -6,11 +6,15 @@ import { redirect } from 'next/navigation'
|
|||||||
import { checkRateLimit, sanitizeString, sanitizeSlug } from '@/lib/security'
|
import { checkRateLimit, sanitizeString, sanitizeSlug } from '@/lib/security'
|
||||||
|
|
||||||
function generateInviteCode() {
|
function generateInviteCode() {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
// Cryptographically random, and wider (8 chars) so the code space is not
|
||||||
|
// brute-forceable under the joinSpace rate limit.
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||||
const prefix = 'HSL'
|
const prefix = 'HSL'
|
||||||
const year = new Date().getFullYear()
|
const year = new Date().getFullYear()
|
||||||
|
const bytes = new Uint8Array(8)
|
||||||
|
crypto.getRandomValues(bytes)
|
||||||
let code = ''
|
let code = ''
|
||||||
for (let i = 0; i < 4; i++) code += chars.charAt(Math.floor(Math.random() * chars.length))
|
for (let i = 0; i < 8; i++) code += chars[bytes[i] % chars.length]
|
||||||
return `${prefix}-${year}-${code}`
|
return `${prefix}-${year}-${code}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 024: Close member self-escalation gaps in prevent_member_self_role_change
|
||||||
|
-- =============================================================================
|
||||||
|
-- The members_update RLS policy intentionally lets a member update their own
|
||||||
|
-- row (display_name, handle, bio, skills, ...). The 015 trigger blocks the
|
||||||
|
-- privileged columns, but two columns added by later migrations were not in
|
||||||
|
-- its list:
|
||||||
|
--
|
||||||
|
-- tier_id (021) - a member could self-upgrade their tier
|
||||||
|
-- onboarding_completed_at (022) - a member could skip required onboarding
|
||||||
|
-- (e.g. the code-of-conduct acknowledgement)
|
||||||
|
-- by setting this directly via PostgREST
|
||||||
|
--
|
||||||
|
-- finishOnboarding()/skipOnboarding() now set onboarding_completed_at through
|
||||||
|
-- the service-role client AFTER server-side validation, so blocking it here
|
||||||
|
-- does not break the legitimate path.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.prevent_member_self_role_change()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_is_privileged boolean;
|
||||||
|
BEGIN
|
||||||
|
IF auth.uid() IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.user_id IS DISTINCT FROM auth.uid() THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT public.user_has_role_in_space(auth.uid(), NEW.space_id, ARRAY['admin','board','treasurer'])
|
||||||
|
INTO v_is_privileged;
|
||||||
|
|
||||||
|
IF v_is_privileged THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.role IS DISTINCT FROM OLD.role
|
||||||
|
OR NEW.tier IS DISTINCT FROM OLD.tier
|
||||||
|
OR NEW.tier_id IS DISTINCT FROM OLD.tier_id
|
||||||
|
OR NEW.status IS DISTINCT FROM OLD.status
|
||||||
|
OR NEW.approved IS DISTINCT FROM OLD.approved
|
||||||
|
OR NEW.has_card_access IS DISTINCT FROM OLD.has_card_access
|
||||||
|
OR NEW.onboarding_completed_at IS DISTINCT FROM OLD.onboarding_completed_at
|
||||||
|
OR NEW.space_id IS DISTINCT FROM OLD.space_id THEN
|
||||||
|
RAISE EXCEPTION 'Members cannot change their own role, tier, status, approval, card access, onboarding completion, or space.'
|
||||||
|
USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_member_self_role_change ON public.space_members;
|
||||||
|
CREATE TRIGGER trg_prevent_member_self_role_change
|
||||||
|
BEFORE UPDATE ON public.space_members
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.prevent_member_self_role_change();
|
||||||
@@ -1836,3 +1836,41 @@ DROP TRIGGER IF EXISTS trg_seed_default_role_permissions ON public.spaces;
|
|||||||
CREATE TRIGGER trg_seed_default_role_permissions
|
CREATE TRIGGER trg_seed_default_role_permissions
|
||||||
AFTER INSERT ON public.spaces
|
AFTER INSERT ON public.spaces
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.seed_default_role_permissions();
|
FOR EACH ROW EXECUTE FUNCTION public.seed_default_role_permissions();
|
||||||
|
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 16. Self-change hardening (equivalent to scripts/024_self_change_hardening.sql).
|
||||||
|
-- Adds tier_id and onboarding_completed_at to the columns a non-privileged
|
||||||
|
-- member cannot change on their own space_members row.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.prevent_member_self_role_change()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_is_privileged boolean;
|
||||||
|
BEGIN
|
||||||
|
IF auth.uid() IS NULL THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.user_id IS DISTINCT FROM auth.uid() THEN RETURN NEW; END IF;
|
||||||
|
SELECT public.user_has_role_in_space(auth.uid(), NEW.space_id, ARRAY['admin','board','treasurer'])
|
||||||
|
INTO v_is_privileged;
|
||||||
|
IF v_is_privileged THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.role IS DISTINCT FROM OLD.role
|
||||||
|
OR NEW.tier IS DISTINCT FROM OLD.tier
|
||||||
|
OR NEW.tier_id IS DISTINCT FROM OLD.tier_id
|
||||||
|
OR NEW.status IS DISTINCT FROM OLD.status
|
||||||
|
OR NEW.approved IS DISTINCT FROM OLD.approved
|
||||||
|
OR NEW.has_card_access IS DISTINCT FROM OLD.has_card_access
|
||||||
|
OR NEW.onboarding_completed_at IS DISTINCT FROM OLD.onboarding_completed_at
|
||||||
|
OR NEW.space_id IS DISTINCT FROM OLD.space_id THEN
|
||||||
|
RAISE EXCEPTION 'Members cannot change their own role, tier, status, approval, card access, onboarding completion, or space.'
|
||||||
|
USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_member_self_role_change ON public.space_members;
|
||||||
|
CREATE TRIGGER trg_prevent_member_self_role_change
|
||||||
|
BEFORE UPDATE ON public.space_members
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.prevent_member_self_role_change();
|
||||||
|
|||||||
Reference in New Issue
Block a user