Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/installcheck
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ else
fi

# Execute the test fixtures
psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -f lints/0025*.sql -f lints/0026*.sql -d contrib_regression
psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -f lints/0025*.sql -f lints/0026*.sql -f lints/0027*.sql -f lints/0028*.sql -f lints/0029*.sql -d contrib_regression

# Run tests
${REGRESS} --use-existing --dbname=contrib_regression --inputdir=${TESTDIR} ${TESTS}
Expand Down
40 changes: 32 additions & 8 deletions docs/0026_pg_graphql_anon_table_exposed.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,35 @@

**Level:** WARN

**Summary:** Tables and views readable by the `anon` role have their schema (names, columns, relationships) made visible through `pg_graphql` introspection.
**Summary:** Tables, views, materialized views, and foreign tables readable by the `anon` role have their schema (names, columns, relationships) made visible through `pg_graphql` introspection.

**Ramification:** Anyone with your public anon key can enumerate every table and view the `anon` role can SELECT — even when RLS is enabled. RLS hides rows; it does not hide the schema. Both forms of introspection (PostgREST and `pg_graphql`) are intentional behavior, but you may not realize how much of your schema is reachable without authentication: every table name, column name, type, relationship, and mutation endpoint is publicly discoverable.
**Ramification:** Anyone with your public anon key can enumerate every relation the `anon` role can SELECT — even when RLS is enabled. RLS hides rows; it does not hide the schema. Both forms of introspection (PostgREST and `pg_graphql`) are intentional behavior, but you may not realize how much of your schema is reachable without authentication: every table name, column name, type, relationship, and mutation endpoint is publicly discoverable.

> **See also: lint [0027_pg_graphql_authenticated_table_exposed](0027_pg_graphql_authenticated_table_exposed.md).** In default Supabase projects `anon` and `authenticated` start with identical default-privilege grants, so revoking from `anon` alone often leaves the same introspection response served to any signed-up user. Address findings from both lints together.

---

### If you are not using `pg_graphql`, disable it

The simplest mitigation — and the right one if your app does not use the GraphQL endpoint — is to drop the extension. With `pg_graphql` not installed, this lint and 0027 stop firing entirely and the `/graphql/v1` endpoint returns nothing exposing your schema.

In the Supabase SQL Editor:

```sql
drop extension pg_graphql;
```

Or in the dashboard: **Database → Extensions**, search for `pg_graphql`, and toggle it off.

If your project does use `pg_graphql`, leave it installed and follow the remediation below.

---

### Rationale

`pg_graphql` introspection is by design: the GraphQL schema reflects the Postgres privileges of the calling role. The Supabase anon key maps to the `anon` Postgres role, so any table or view `anon` can `SELECT` is visible in the GraphQL introspection response from `/graphql/v1`, regardless of RLS. Visibility through introspection is governed entirely by `GRANT` / `REVOKE`. This lint flags the objects that are currently discoverable so you can confirm each one is intentionally public.
`pg_graphql` introspection is by design: the GraphQL schema reflects the Postgres privileges of the calling role. The Supabase anon key maps to the `anon` Postgres role, so any relation `anon` can `SELECT` is visible in the GraphQL introspection response from `/graphql/v1`, regardless of RLS. Visibility through introspection is governed entirely by `GRANT` / `REVOKE`. This lint flags the objects currently discoverable through the public anon key so you can confirm each one is intentionally public.

The relkinds covered match `pg_graphql`'s own filter (`load_sql_context.sql:395-400`): regular tables (`r`), views (`v`), materialized views (`m`), and foreign tables (`f`). Partitioned table roots (`relkind='p'`) are not covered because `pg_graphql` does not expose them via introspection; their leaf partitions (`relkind='r'`) are still picked up individually.

You can confirm what is visible using only the public anon key:

Expand All @@ -27,6 +47,8 @@ The response includes one entry per exposed table (e.g. `internal_api_keysCollec

The fix is always a standard Postgres `GRANT` / `REVOKE` run in the SQL Editor. No support ticket, no config file, no extension toggle.

**Important:** revoking from `anon` does not, on its own, hide the relation from `pg_graphql` introspection — `authenticated` is checked separately by lint 0027 and typically has the same default grants. Address both lints' findings together (see "Hide all tables from both roles" in 0027).

**Option 1: Hide every table from `anon` (most thorough)**

```sql
Expand All @@ -46,7 +68,9 @@ Re-grant access to `authenticated` for tables your app needs after login:
grant select on public.profiles to authenticated;
grant select on public.products to authenticated;
grant select, insert on public.orders to authenticated;
-- Sensitive tables receive no grant and remain invisible to introspection.
-- Sensitive tables receive no grant from anon and remain invisible to
-- the public introspection endpoint. Make sure to also handle 0027 for
-- the authenticated-side exposure.
```

**Option 2: Hide a specific sensitive table or view only**
Expand All @@ -55,7 +79,7 @@ grant select, insert on public.orders to authenticated;
revoke all on public.internal_api_keys from anon;
```

`anon` continues to see other objects, but `internal_api_keys` is no longer visible in introspection. Use the same `revoke all on <object>` pattern for views and materialized views.
`anon` continues to see other objects, but `internal_api_keys` is no longer visible in the unauthenticated introspection response. Use the same `revoke all on <object>` pattern for views, materialized views, and foreign tables.

**Option 3: Block the entire GraphQL endpoint for `anon`**

Expand Down Expand Up @@ -91,7 +115,7 @@ Fix:
revoke all on public.internal_api_keys from anon;
```

Re-running the introspection query no longer returns this table.
Re-running the introspection query with the anon key no longer returns this table. (The same call repeated with a signed-up user's JWT still returns it until you also revoke from `authenticated` — see 0027.)

### Verifying the Fix

Expand All @@ -117,6 +141,6 @@ curl -X POST https://<PROJECT_REF>.supabase.co/graphql/v1 \

### False Positives

This lint flags every `anon`-readable table when `pg_graphql` is installed. Some of these are intentional — public catalog tables (blog posts, product listings, public FAQs) are meant to be readable without authentication, and exposing their column names is acceptable.
This lint flags every `anon`-readable relation when `pg_graphql` is installed. Some of these are intentional — public catalog tables (blog posts, product listings, public FAQs) are meant to be readable without authentication, and exposing their column names is acceptable.

If introspection visibility is intentional for a table or view, the lint can be safely ignored for that object. The lint is informational rather than a hard misconfiguration: it surfaces what your project makes visible so you can decide which tables and views are actually meant to be public.
If introspection visibility is intentional for a relation, the lint can be safely ignored for that object. The lint is informational rather than a hard misconfiguration: it surfaces what your project makes visible so you can decide which relations are actually meant to be public.
138 changes: 138 additions & 0 deletions docs/0027_pg_graphql_authenticated_table_exposed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@

**Level:** WARN

**Summary:** Tables, views, materialized views, and foreign tables readable by the `authenticated` role have their schema (names, columns, relationships) made visible through `pg_graphql` introspection to any signed-up user.

**Ramification:** Every relation the `authenticated` role can SELECT is enumerable by anyone with a valid Supabase user JWT — even when RLS is enabled. RLS hides rows; it does not hide the schema. In default Supabase projects the `authenticated` role starts with the same default-privilege grants as `anon` (both come from `ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin … TO anon, authenticated, service_role`), and under open or auto-confirm signup, "authenticated" is in practice anyone with a throwaway email rather than a meaningfully smaller audience.

> **See also: lint [0026_pg_graphql_anon_table_exposed](0026_pg_graphql_anon_table_exposed.md).** The two checks are paired — revoking from one role alone usually leaves the other side of the introspection response unchanged. Address findings from both lints together.

---

### If you are not using `pg_graphql`, disable it

The simplest mitigation — and the right one if your app does not use the GraphQL endpoint — is to drop the extension. With `pg_graphql` not installed, this lint and 0026 stop firing entirely and the `/graphql/v1` endpoint returns nothing exposing your schema.

In the Supabase SQL Editor:

```sql
drop extension pg_graphql;
```

Or in the dashboard: **Database → Extensions**, search for `pg_graphql`, and toggle it off.

If your project does use `pg_graphql`, leave it installed and follow the remediation below.

---

### Rationale

`pg_graphql` introspection runs under whichever role the caller's JWT claims, not specifically `anon`. A request with the public anon key runs as `anon`; a request with a real user JWT runs as `authenticated`. The introspection response reflects the privileges of that role.

That makes the documented remediation for 0026 — "revoke from `anon`, grant to `authenticated`" — incomplete on its own. Because the two roles share identical default-privilege grants, an operator who follows the 0026 doc verbatim can clear that lint and still see the `/graphql/v1` introspection response served to any signed-up user remain byte-for-byte unchanged. Lint 0027 catches that case: it fires whenever `authenticated` has `SELECT` on a relation that `pg_graphql` would expose.

The relkinds covered match `pg_graphql`'s own filter (`load_sql_context.sql:395-400`): regular tables (`r`), views (`v`), materialized views (`m`), and foreign tables (`f`). Partitioned table roots (`relkind='p'`) are not covered because `pg_graphql` does not expose them via introspection; their leaf partitions (`relkind='r'`) are still picked up individually.

You can confirm what is visible to authenticated users by repeating the introspection request with a real user JWT in the `Authorization` header:

```bash
curl -X POST https://<PROJECT_REF>.supabase.co/graphql/v1 \
-H 'apiKey: <ANON_KEY>' \
-H 'Authorization: Bearer <USER_JWT>' \
-H 'Content-Type: application/json' \
--data-raw '{"query": "{ __schema { types { name fields { name } } } }"}'
```

### How to Resolve

The fix is a standard Postgres `GRANT` / `REVOKE`. Unlike 0026, you cannot simply revoke from `authenticated` — your app probably needs `authenticated` to read most tables. The right move is per-relation: keep grants on the tables signed-up users genuinely need, and revoke from the rest.

**Option 1: Audit and revoke per-relation (recommended)**

```sql
-- A relation that should never be visible to signed-up users:
revoke all on public.internal_api_keys from authenticated, anon, public;

-- A relation that signed-up users do need; introspection visibility is
-- intentional and the lint can be ignored for this object:
grant select on public.profiles to authenticated;
```

Walk the 0027 findings list; for each relation, decide whether `authenticated` visibility is intentional. If it is, suppress the finding for that object. If it is not, revoke.

**Option 2: Hide every table from both roles, re-grant only what is needed**

```sql
revoke all on all tables in schema public from anon, authenticated;

alter default privileges in schema public
revoke select on tables from anon, authenticated;

-- Re-grant per-relation only where genuinely required:
grant select on public.profiles to authenticated;
grant select on public.products to authenticated;
grant select, insert on public.orders to authenticated;
```

This pairs cleanly with 0026's Option 1 and is the cleanest end state for projects that want introspection to expose only an explicit allowlist.

**Option 3: Block the entire GraphQL endpoint for both roles**

```sql
revoke all on function graphql.resolve from anon, authenticated;
```

This rejects every GraphQL request, not just introspection. Use only if you do not use the `/graphql/v1` endpoint at all. The table-level revokes above are usually preferable because they keep the endpoint alive while returning an empty schema.

### Example

Given a table that signed-up users should not be able to see in introspection:

```sql
create table public.internal_api_keys(
id uuid primary key,
service text,
key_hash text,
permissions jsonb
);

alter table public.internal_api_keys enable row level security;
-- No policies, but `authenticated` still inherits the default SELECT
-- grant — every signed-up user sees the column list via introspection.
```

Lint 0027 fires for `public.internal_api_keys`. Fix:

```sql
revoke all on public.internal_api_keys from authenticated, anon, public;
```

The introspection query no longer returns this table for any role. (If 0026 was also firing for this table, the same revoke clears it.)

### Verifying the Fix

After applying the revoke, repeat the introspection query with a real user JWT and confirm the relation is no longer in the response:

```bash
curl -X POST https://<PROJECT_REF>.supabase.co/graphql/v1 \
-H 'apiKey: <ANON_KEY>' \
-H 'Authorization: Bearer <USER_JWT>' \
-H 'Content-Type: application/json' \
--data-raw '{"query": "{ __schema { types { name fields { name } } } }"}'
```

### Quick Reference

| Goal | SQL |
|------|-----|
| Hide one table from `authenticated` | `revoke all on public.secret_table from authenticated, public;` |
| Hide all tables from `authenticated` | `revoke all on all tables in schema public from authenticated;` |
| Prevent future auto-grants | `alter default privileges in schema public revoke select on tables from authenticated;` |
| Hide one table from both roles | `revoke all on public.secret_table from anon, authenticated, public;` |
| Kill GraphQL endpoint for both roles | `revoke all on function graphql.resolve from anon, authenticated;` |

### False Positives

This lint flags every `authenticated`-readable relation when `pg_graphql` is installed. The majority of findings are usually intentional — most app-facing tables genuinely need to be readable by signed-up users, and exposing their column names through introspection is acceptable.

If introspection visibility is intentional for a relation, the lint can be safely ignored for that object. The lint is informational: it surfaces what your project makes visible to authenticated users so you can decide which relations are actually meant to be discoverable by every account holder, including throwaway accounts created via open signup.
Loading
Loading