| Version | Supported |
|---|---|
main (latest) |
✅ |
| All prior tags | ❌ — portfolio project, no backports |
Do not open a public GitHub issue for security vulnerabilities.
Email: owenquintenta@gmail.com
Include:
- Description of the vulnerability
- Steps to reproduce
- Impact assessment (which tenants, which data, what operations)
- Any proof-of-concept code (responsible disclosure only)
Acknowledgement within 48 hours, resolution timeline within 7 days.
Every table has ENABLE and FORCE ROW LEVEL SECURITY. Posture is deny by default — no policy means no access.
Policies use the is_organization_member(uuid) SECURITY DEFINER helper, which performs an EXISTS (SELECT 1 FROM organization_members WHERE user_id = auth.uid() AND organization_id = target_org) check. This pattern was a Gemini Phase 0 audit HIGH constraint — it replaces the more common scalar app_metadata.org_id JWT claim, which would otherwise let up-to-1-hour-stale tokens retain access after a user was evicted from an org.
pgTAP coverage (supabase/tests/rls/):
cross-tenant-denial.test.sql— 34 assertions across 3 tenants coveringorganizations,organization_members,organization_invites,workflows,workflow_versions,executions,execution_events,webhook_inbox,billing_subscriptions,webhook_events,audit_events. Cross-tenant reads return 0 rows; cross-tenant writes raise SQLSTATE 42501.append-only-audit.test.sql— 11 assertions provingUPDATE,DELETE,TRUNCATE,INSERT ... ON CONFLICT DO UPDATE, andMERGE ... WHEN MATCHED THEN UPDATE/DELETEall raise SQLSTATE 42501 with the audit-trigger's exact error message.
The Phase 2 Codex gate found four privilege paths that the initial schema permitted. All were fixed before Phase 2 closed:
- Admin → owner self-promotion (CRITICAL) —
organization_membersUPDATE policy split: admins manage non-owner rows only; owner transitions require the actor to already holdowner. - Last owner orphaning (HIGH) —
BEFORE DELETEandBEFORE UPDATEtriggers reject the final owner's removal or demotion (SQLSTATE 23001). - Cross-tenant
workflow_versions(HIGH) — Composite FK(workflow_id, organization_id) → workflows(id, organization_id)rejects rows whose two tenant references disagree. - Audit diff leak to all members (HIGH) — SELECT policy tightened to
has_organization_role(...,'admin'). Phase 5+ redacted member-safe feed is tracked.
Full gate report: docs/phase-2-council-gate.md.
Both /api/webhooks/n8n and /api/webhooks/stripe follow the same pipeline:
- Size guard. Content-Length and raw-body length both capped (5 MB n8n, 1 MB Stripe) before any parsing.
- Required headers. Signature header and (n8n only) idempotency key must be present and well-formed.
- HMAC-SHA256 verification.
timingSafeEqualfrom Nodecrypto. Header format ist=<unix_ts>,v1=<64 hex>— identical to Stripe's own convention. - Replay window. Timestamp must be within ±300s of now; stale = reject.
- Shape validation. JSON body must be a non-null, non-array object (Stripe also requires
id:string+type:string). - Idempotency dedup. Live mode: UNIQUE on
(source, lower(idempotency_key))(n8n) or(provider, event_id)(Stripe). Fixture synthesizes duplicates via well-known prefixes for tests. - No oracle. All auth failures return an identical
{received:false, error:"unauthorized"}shape with status 401 — the response never reveals which control tripped (bad signature / stale timestamp / wrong secret).
Vitest coverage: src/tests/webhooks/ — 41 assertions (n8n-signature: 12, n8n-route: 15, stripe-route: 14).
audit_events is append-only at the trigger layer:
BEFORE UPDATEtrigger → raiseinsufficient_privilege(42501)BEFORE DELETEtrigger → raiseinsufficient_privilege(42501)BEFORE TRUNCATEtrigger → raiseinsufficient_privilege(42501) — Gemini HIGH addition; naive patterns only protect UPDATE + DELETE
Plus REVOKE update, delete, truncate ON audit_events FROM public, authenticated, anon as belt-and-braces against grant-table bypasses.
Documented DBA-tier caveat. A Postgres DBA with table ownership can still ALTER TABLE audit_events DISABLE TRIGGER ALL or SET session_replication_role = replica. True tamper-evident audit requires ownership lockdown (migrator role only) + WAL archiving + offline hash-chain verification — tracked for Phase 8+ ops doc.
App-layer diff redaction. emitAuditEvent() recursively redacts fields whose keys match /token|password|secret|key|api.?key|authorization/i before persisting, as defense in depth against callers that forget to strip sensitive data.
- Edge proxy (
src/proxy.ts) matches session cookies against two patterns:sb-<ref>-auth-token(Supabase SSR) and the legacysb-access-token. Any othersb-*cookie (e.g., locale) is rejected — no prefix abuse. - No header-smuggling path: the proxy checks only
request.cookies, never any other header value. - Fixture mode auto-issues a session via
FixtureAuthAdapter; the proxy passes through. requireSession()in server components redirects unauthenticated users to/auth/sign-in?redirect=<path>.
Vitest coverage: src/tests/auth/bypass-attempt.test.ts — 22 assertions covering protected-path redirects, cookie spec strictness, public-path allowance, fixture bypass.
Features are gated at three layers:
- Database. RLS policies grant access based on role (e.g.,
has_organization_role(..., 'owner')for billing subscriptions, SSO tables). - Application.
planAllowsFeature(plan, feature)fromsrc/lib/billing/plans.tsis the single source of truth. Server actions check both role and plan before any mutation. - UI. Upgrade prompts and disabled controls inform users but are never the security boundary.
From next.config.ts:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preloadX-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=()Content-Security-Policy— per-route;'unsafe-inline'currently permitted for Next.js RSC inline scripts (tighten in a follow-up with nonces)
- No secrets in the repo under any circumstances.
.env.exampledocuments every required env var with empty values.gitleaks protect --stagedruns in the pre-commit hook (install instruction inCONTRIBUTING.md);gitleaks detectruns in CI on every push.npm audit --audit-level=moderatefails CI on moderate+ advisories (tightened from the defaulthighper Gemini Phase 0).- Service-role Supabase client is only used by webhook handlers and background jobs. User-context reads use
createServerClient(cookies)with the user's session JWT (Gemini Phase 0 CRITICAL constraint).
- Every server action starts with
requireSession()+z.object({...}).safeParse(formData). - Every route handler validates headers, content-length, body shape before any business logic.
- Template config validation strips
__proto__,constructor,prototypekeys and rejects unknown fields (src/lib/templates/validator.ts+ 30 fuzz assertions insrc/tests/templates/schema-fuzz.test.ts). - CSV exports prefix cells starting with
= + - @ TAB CRwith a single quote to defuse Excel/Sheets formula injection (OWASP CSV Injection guidance).
The following paths require explicit review from a security-aware reviewer:
supabase/migrations/ RLS changes
src/proxy.ts Edge session guard
src/app/api/webhooks/ Webhook receivers
src/app/api/audit/export/ Audit export (CSV/JSON)
src/app/api/sso/ SCIM token actions
src/lib/auth/ Auth utilities
src/lib/audit/ Audit emitter + serializer
src/lib/billing/ Plan definitions
src/lib/stripe/ Stripe adapter + signature
src/lib/sso/ WorkOS adapter
.github/workflows/ CI + security workflows
See CODEOWNERS for the canonical list.
Full STRIDE analysis lives in docs/threat-model.md.
The following Gemini reviews are queued against Owen's personal AI Studio account (currently out of credit):
- Phase 2 RLS second opinion
- Phase 5 webhook × RLS diff review
- Phase 7 whole-repo audit
- Phase 8 final adversarial pre-push
All four roll into one comprehensive Gemini pass before any public release. Until then, Codex gpt-5.4 has covered the adversarial surface — Phase 2 council-gate report documents 11 findings triaged and fixed.