Skip to content

Commit ed9cb2f

Browse files
enriquephlclaude
andcommitted
fix(security): Supabase lockdown migration — REVOKE engine.* from anon/auth + RLS (fixes #23)
When eros-engine is deployed on Supabase with `engine` in the project's Exposed Schemas list (a common path so a web app can read engine.* via @supabase/supabase-js), any anon/authenticated grants that an operator ever clicked into Studio's Permissions panel become world-readable via the publishable anon key over /rest/v1/. Issue #23 documents an audit that found chat_messages, chat_sessions, and persona_instances exposed on a live deployment. Migration 0013_supabase_lockdown.sql closes the hole defensively: 1. REVOKE ALL on every engine.* table from anon / authenticated 2. REVOKE USAGE on schema engine from anon / authenticated 3. ENABLE ROW LEVEL SECURITY on every engine.* table (no policies; the postgres owner and service_role bypass RLS, so the engine binary and any server-side Supabase client keep working) The REVOKEs are guarded by `IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon')` so non-Supabase Postgres deployments (Neon, RDS, plain self-hosted) skip them silently — the RLS enable is harmless on any Postgres for those clients. Re-running is a no-op: REVOKE on a non-existent grant succeeds and ENABLE ROW LEVEL SECURITY is idempotent. docs/deploying.{md,zh.md} gain a "Supabase deployments — schema-exposure footgun" section with audit SQL operators can run to verify their own projects. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b0709b8 commit ed9cb2f

3 files changed

Lines changed: 169 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
-- SPDX-License-Identifier: AGPL-3.0-only
2+
--
3+
-- Supabase lockdown. Defense-in-depth migration for deployments that put
4+
-- eros-engine's schema on a Supabase Postgres project. Fixes issue #23.
5+
--
6+
-- Why this exists. Supabase exposes any schema added to Studio's "Exposed
7+
-- schemas" list over the PostgREST API at /rest/v1/. To let a co-deployed
8+
-- web app (e.g. eros-engine-web) read engine.* through @supabase/supabase-js,
9+
-- operators commonly add `engine` to that list. The hazard: if the
10+
-- `anon` / `authenticated` roles ever picked up SELECT / INSERT / UPDATE /
11+
-- DELETE grants on engine.* tables — either via the Studio "Permissions"
12+
-- panel or a stock template — every holder of the publishable anon key
13+
-- (which by design ships in every browser bundle) can hit the raw rows
14+
-- through PostgREST, no auth required.
15+
--
16+
-- This migration's three steps neutralise that even if the operator
17+
-- accidentally toggled the boxes:
18+
--
19+
-- 1. REVOKE ALL on every engine.* table from anon, authenticated.
20+
-- 2. REVOKE USAGE on schema engine from anon, authenticated. PostgREST
21+
-- needs schema USAGE to enumerate tables; pulling it makes the schema
22+
-- effectively invisible to those roles regardless of object-level
23+
-- grants picked up later.
24+
-- 3. ENABLE ROW LEVEL SECURITY on every engine.* table. Pure defense in
25+
-- depth — there are NO policies attached, so the only access paths are
26+
-- (a) `postgres` owner connections (sqlx migration / engine app), and
27+
-- (b) `service_role` connections from the engine's server side. Both
28+
-- bypass RLS.
29+
--
30+
-- The whole thing is wrapped in `pg_roles` existence checks so non-Supabase
31+
-- Postgres deployments (where anon / authenticated don't exist) skip the
32+
-- REVOKEs silently. The RLS enable runs unconditionally — it's safe on any
33+
-- Postgres and only changes behaviour for clients that aren't the owner or
34+
-- service_role.
35+
--
36+
-- Re-running this migration is a no-op: REVOKE on a non-existent grant
37+
-- succeeds, and `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` is idempotent.
38+
39+
DO $$
40+
BEGIN
41+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
42+
REVOKE ALL ON engine.chat_messages FROM anon;
43+
REVOKE ALL ON engine.chat_sessions FROM anon;
44+
REVOKE ALL ON engine.persona_instances FROM anon;
45+
REVOKE ALL ON engine.companion_affinity FROM anon;
46+
REVOKE ALL ON engine.companion_affinity_events FROM anon;
47+
REVOKE ALL ON engine.companion_insights FROM anon;
48+
REVOKE ALL ON engine.companion_memories FROM anon;
49+
REVOKE ALL ON engine.persona_genomes FROM anon;
50+
REVOKE ALL ON engine.persona_ownership FROM anon;
51+
REVOKE ALL ON engine.sync_cursors FROM anon;
52+
REVOKE ALL ON engine.wallet_links FROM anon;
53+
REVOKE USAGE ON SCHEMA engine FROM anon;
54+
END IF;
55+
56+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
57+
REVOKE ALL ON engine.chat_messages FROM authenticated;
58+
REVOKE ALL ON engine.chat_sessions FROM authenticated;
59+
REVOKE ALL ON engine.persona_instances FROM authenticated;
60+
REVOKE ALL ON engine.companion_affinity FROM authenticated;
61+
REVOKE ALL ON engine.companion_affinity_events FROM authenticated;
62+
REVOKE ALL ON engine.companion_insights FROM authenticated;
63+
REVOKE ALL ON engine.companion_memories FROM authenticated;
64+
REVOKE ALL ON engine.persona_genomes FROM authenticated;
65+
REVOKE ALL ON engine.persona_ownership FROM authenticated;
66+
REVOKE ALL ON engine.sync_cursors FROM authenticated;
67+
REVOKE ALL ON engine.wallet_links FROM authenticated;
68+
REVOKE USAGE ON SCHEMA engine FROM authenticated;
69+
END IF;
70+
END
71+
$$;
72+
73+
-- Defense-in-depth RLS. No policies attached — owner (postgres) and
74+
-- service_role bypass RLS, which covers every legitimate access path:
75+
-- sqlx migration runs, the engine binary's pool, and any server-side
76+
-- Supabase client that uses the service-role key. Browser-side clients
77+
-- using the anon key would be blocked here even if the REVOKEs above
78+
-- somehow drifted.
79+
ALTER TABLE engine.chat_messages ENABLE ROW LEVEL SECURITY;
80+
ALTER TABLE engine.chat_sessions ENABLE ROW LEVEL SECURITY;
81+
ALTER TABLE engine.persona_instances ENABLE ROW LEVEL SECURITY;
82+
ALTER TABLE engine.companion_affinity ENABLE ROW LEVEL SECURITY;
83+
ALTER TABLE engine.companion_affinity_events ENABLE ROW LEVEL SECURITY;
84+
ALTER TABLE engine.companion_insights ENABLE ROW LEVEL SECURITY;
85+
ALTER TABLE engine.companion_memories ENABLE ROW LEVEL SECURITY;
86+
ALTER TABLE engine.persona_genomes ENABLE ROW LEVEL SECURITY;
87+
ALTER TABLE engine.persona_ownership ENABLE ROW LEVEL SECURITY;
88+
ALTER TABLE engine.sync_cursors ENABLE ROW LEVEL SECURITY;
89+
ALTER TABLE engine.wallet_links ENABLE ROW LEVEL SECURITY;

docs/deploying.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,46 @@ Anything compatible with the sqlx Postgres driver works — Supabase, Neon, RDS,
159159

160160
If you're sharing a database with another service, the engine's tables stay in `engine.*` and never write to `public.*` — collision-free.
161161

162+
### Supabase deployments — schema-exposure footgun
163+
164+
If your Postgres provider is Supabase **and** you've added `engine` to the project's Exposed Schemas list (Studio → Settings → API → Exposed schemas) so a co-deployed web app can read `engine.*` through `@supabase/supabase-js`, you've also potentially exposed every `engine.*` table to the publishable `anon` key — depending on which roles Studio's Permissions panel granted SELECT/INSERT/etc to.
165+
166+
The hazard: a holder of the publishable anon key (which ships in every browser bundle by design) can issue:
167+
168+
```bash
169+
curl "https://<project>.supabase.co/rest/v1/chat_messages?select=*&limit=5" \
170+
-H "apikey: <publishable-anon-key>"
171+
```
172+
173+
…and read every user's chat history if `anon` was ever granted SELECT on `engine.chat_messages`.
174+
175+
Migration `0013_supabase_lockdown.sql` (shipped with eros-engine 0.2+) closes this by:
176+
177+
1. `REVOKE ALL` on every `engine.*` table from `anon` and `authenticated`
178+
2. `REVOKE USAGE ON SCHEMA engine` from `anon` and `authenticated`
179+
3. `ENABLE ROW LEVEL SECURITY` on every `engine.*` table (no policies — defense in depth; the `postgres` owner and `service_role` bypass RLS, which covers the engine binary and any server-side Supabase client)
180+
181+
The migration is guarded by `IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon')`, so non-Supabase Postgres deployments (Neon, RDS, plain self-hosted) skip the REVOKEs silently and only inherit the harmless RLS enable.
182+
183+
**If you upgraded from a pre-0.2 release on Supabase, run `eros-engine migrate` once to apply this — it's idempotent.**
184+
185+
To audit your project independently of this migration, run as the `postgres` role:
186+
187+
```sql
188+
-- Which tables in engine.* are missing RLS?
189+
SELECT relname FROM pg_class
190+
WHERE relnamespace = 'engine'::regnamespace
191+
AND relkind = 'r' AND NOT relrowsecurity;
192+
193+
-- Which engine.* tables expose anything to anon / authenticated?
194+
SELECT grantee, table_name, privilege_type
195+
FROM information_schema.role_table_grants
196+
WHERE table_schema = 'engine'
197+
AND grantee IN ('anon', 'authenticated');
198+
```
199+
200+
Both queries should return zero rows after the migration applies.
201+
162202
## Operational notes
163203

164204
- **Health probe:** `GET /healthz` returns 200 with `{ status: "ok", service, version, timestamp }`. Wire this into your platform's health check.

docs/deploying.zh.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,46 @@ impl AuthValidator for MyValidator {
159159

160160
如果跟另一個服務共用一個數據庫,引擎的表都在 `engine.*` 下、永不寫 `public.*`——零衝突。
161161

162+
### Supabase 部署——schema 暴露地雷
163+
164+
如果你的 Postgres 是 Supabase,**而且**把 `engine` 加進了項目的 Exposed Schemas 列表(Studio → Settings → API → Exposed schemas)——通常是為了讓同部署的 web 端能用 `@supabase/supabase-js` 讀 `engine.*`——那你可能同時把每張 `engine.*` 表都暴露給了可公開的 `anon` key,取決於 Studio Permissions 面板給了哪些角色什麼授權。
165+
166+
風險:拿到 publishable anon key 的人(這個 key 按設計就會出現在每個瀏覽器 bundle 裡)只要:
167+
168+
```bash
169+
curl "https://<project>.supabase.co/rest/v1/chat_messages?select=*&limit=5" \
170+
-H "apikey: <publishable-anon-key>"
171+
```
172+
173+
就能讀所有用戶的聊天記錄——如果 `anon` 曾經被授權對 `engine.chat_messages` 的 SELECT 的話。
174+
175+
遷移 `0013_supabase_lockdown.sql`(eros-engine 0.2+ 起內建)通過三步堵這個洞:
176+
177+
1. 對每張 `engine.*` 表執行 `REVOKE ALL FROM anon, authenticated`
178+
2. 對 schema 本身執行 `REVOKE USAGE ON SCHEMA engine FROM anon, authenticated`
179+
3. 對每張 `engine.*` 表執行 `ENABLE ROW LEVEL SECURITY`(無策略——縱深防禦;`postgres` 用戶和 `service_role` 都繞過 RLS,所以引擎本體和任何服務端的 Supabase client 都不受影響)
180+
181+
遷移外面包了 `IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon')`,所以非 Supabase 的 Postgres 部署(Neon、RDS、自托管)會靜默跳過 REVOKE,只繼承無害的 RLS enable。
182+
183+
**如果你是從 0.2 之前的版本升上來、又跑在 Supabase 上,跑一次 `eros-engine migrate` 應用它就行——這個遷移是冪等的。**
184+
185+
要獨立審計你的項目(與本遷移無關),以 `postgres` 角色執行:
186+
187+
```sql
188+
-- engine.* 裡哪些表沒開 RLS?
189+
SELECT relname FROM pg_class
190+
WHERE relnamespace = 'engine'::regnamespace
191+
AND relkind = 'r' AND NOT relrowsecurity;
192+
193+
-- engine.* 裡哪些表給 anon / authenticated 開了權限?
194+
SELECT grantee, table_name, privilege_type
195+
FROM information_schema.role_table_grants
196+
WHERE table_schema = 'engine'
197+
AND grantee IN ('anon', 'authenticated');
198+
```
199+
200+
應用遷移後,兩個查詢都應返回零行。
201+
162202
## 運維注意事項
163203

164204
- **健康探針:** `GET /healthz` 返 200,響應 `{ status: "ok", service, version, timestamp }`。把這個接到平台的健康檢查上。

0 commit comments

Comments
 (0)