fix(self-hosted): block access to tenants and openapi realtime api (#46856)

This commit is contained in:
Andrey A. authored and GitHub committed 2026-06-12 11:48:58 +02:00
1 parent 9bf5bbcc75
commit 098157eb90
7 files changed
+154 -44

No files matched your search

@@ -214,15 +214,17 @@ When new API keys have not been added yet, the `kong-entrypoint.sh` script remov
To assist with the authorization flows a specialized configuration in `kong.yml` substitutes internal, gateway-level-only pre-signed JWTs for `sb_publishable` and `sb_secret` API keys. These pre-signed JWTs are also auto-configured in `.env` but **should not** be used in any application code.
| Route | Service | API key required | Header substitution |
| -------------------- | -------------------- | ---------------- | ------------------- |
| `/auth/v1/*` | Auth | Yes | `Authorization` |
| `/rest/v1/*` | PostgREST | Yes | `Authorization` |
| `/graphql/v1` | PostgREST | Yes | `Authorization` |
| `/realtime/v1/api/*` | Realtime (REST) | Yes | `Authorization` |
| `/realtime/v1/*` | Realtime (WebSocket) | Yes | `x-api-key` |
| `/storage/v1/*` | Storage | No | `Authorization` |
| `/functions/v1/*` | Edge Functions | No | - |
| Route | Service | API key required | Header substitution |
| -------------------------- | -------------------- | ---------------- | ------------------- |
| `/auth/v1/*` | Auth | Yes | `Authorization` |
| `/rest/v1/*` | PostgREST | Yes | `Authorization` |
| `/graphql/v1` | PostgREST | Yes | `Authorization` |
| `/realtime/v1/api/tenants` | Realtime (REST) | Denied (blocked) | - |
| `/realtime/v1/api/openapi` | Realtime (REST) | Denied (blocked) | - |
| `/realtime/v1/api/*` | Realtime (REST) | Yes | `Authorization` |
| `/realtime/v1/*` | Realtime (WebSocket) | Yes | `x-api-key` |
| `/storage/v1/*` | Storage | No | `Authorization` |
| `/functions/v1/*` | Edge Functions | No | - |
### Request flows
@@ -60,13 +60,14 @@ HTTP filter chain
├─ CORS
├─ Basic Auth (dashboard only)
├─ Lua: copy ?apikey query to header
├─ Lua: 401 for missing/invalid API key
├─ Lua: translate opaque keys in query
├─ Lua: translate opaque keys in header
├─ Lua: mirror apikey to x-api-key (Realtime WS)
├─ Lua: synthesize Authorization header
├─ Lua: 401 for missing/invalid API key
├─ RBAC (global: service_role → /pg/, apikey → other API routes;
│ per-route DENY override on /mcp)
│ per-route DENY override on /mcp and Realtime
│ /api/tenants, /api/openapi)
└─ Router
│
▼
@@ -140,7 +141,9 @@ Routes are matched in the order declared. The first matching prefix wins. Protec
| `/auth/v1/` | auth | `/` | API key | Protected Auth endpoints |
| `/rest/v1/` | rest | `/` | API key | PostgREST |
| `/graphql/v1` | rest | `/rpc/graphql` | API key | pg_graphql (adds `Content-Profile: graphql_public`) |
| `/realtime/v1/api` | realtime | `/api` | API key | Realtime REST API |
| `/realtime/v1/api/tenants` | realtime | - | Denied | Realtime management API (blocked by default) |
| `/realtime/v1/api/openapi` | realtime | - | Denied | Realtime OpenAPI spec (blocked by default) |
| `/realtime/v1/api` | realtime | `/api` | API key | Realtime REST API (broadcast, channels, ping) |
| `/realtime/v1/` | realtime | `/socket/` | API key | Realtime WebSocket |
| `/pg/` | meta | `/` | Service role only | postgres-meta - used by Studio for database access |
| `/api/mcp` | studio | - | Denied | MCP endpoint (blocked by default via RBAC DENY) |
@@ -308,6 +311,7 @@ The access log format is a standard combined log with the request method, origin
- **`401 Unauthorized` on a protected route.** The `apikey` header is missing or does not match any configured key. Verify that the header value exactly matches one of `ANON_KEY`, `SERVICE_ROLE_KEY`, `SUPABASE_PUBLISHABLE_KEY`, or `SUPABASE_SECRET_KEY` in your `.env` file. Note that `SUPABASE_PUBLISHABLE_KEY` and `SUPABASE_SECRET_KEY` are only accepted when the new key configuration is fully set up - see [New API Keys and Asymmetric Authentication](/docs/guides/self-hosting/self-hosted-auth-keys).
- **`403 Forbidden` on `/pg/`.** The `/pg/` route requires a service_role key (`SUPABASE_SECRET_KEY` or legacy `SERVICE_ROLE_KEY`). Anon and publishable keys are rejected.
- **`403 Forbidden` on `/api/mcp` or `/mcp`.** These routes are blocked by default. See [Enabling MCP Server Access](/docs/guides/self-hosting/enable-mcp).
- **`403 Forbidden` on `/realtime/v1/api/tenants` or `/realtime/v1/api/openapi`.** These Realtime management endpoints are blocked at the gateway by design and are not reachable by external clients, even with a valid key.
- **`SignatureDoesNotMatch` on S3 requests to Storage.** Verify that the Storage service configuration in `docker-compose.yml` contains `REQUEST_ALLOW_X_FORWARDED_PATH=true` and `STORAGE_PUBLIC_URL`. Storage uses the `X-Forwarded-Prefix` header the gateway sends to reconstruct the original request path for SigV4 verification.
- **`400 Bad Request` with underscore headers.** `headers_with_underscores_action: REJECT_REQUEST` is enabled. Some clients send headers like `X_Forwarded_For` with underscores; these are rejected. Use hyphens in header names.
+12 -6
View File
@@ -144,17 +144,23 @@ check "No key -> 401" "401" \
echo ""
echo "--- Realtime REST (/realtime/v1/api/) ---"
# Realtime REST API - expect 200 or other non-401 response with valid key
check "Legacy ANON_KEY -> not 401" "true" \
"$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $ANON_KEY")" != "401" ] && echo true || echo false)"
# Realtime REST API - use /api/ping to verify key auth (expect 200 with a valid key)
check "Legacy ANON_KEY -> 200" "200" \
"$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $ANON_KEY")"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY -> not 401" "true" \
"$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" != "401" ] && echo true || echo false)"
check "New PUBLISHABLE_KEY -> 200" "200" \
"$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/realtime/v1/api/tenants")"
"$(http_status "$BASE_URL/realtime/v1/api/ping")"
# Management endpoints must be blocked at the gateway (even with a valid key)
check "/api/tenants blocked -> 403" "403" \
"$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $ANON_KEY")"
check "/api/openapi blocked -> 403" "403" \
"$(http_status "$BASE_URL/realtime/v1/api/openapi" -H "apikey: $ANON_KEY")"
echo ""
echo "--- supabase-js style requests (apikey + Authorization) ---"
+11 -3
View File
@@ -444,9 +444,17 @@ check "/mcp blocked" "403" \
echo ""
echo "--- Realtime ---"
check "Realtime health" "true" \
"$([ "$(http_status "$BASE_URL/realtime/v1/api/tenants" \
-H "apikey: $ANON_KEY")" != "401" ] && echo true || echo false)"
check "Realtime health (ping)" "200" \
"$(http_status "$BASE_URL/realtime/v1/api/ping" \
-H "apikey: $ANON_KEY")"
# Management endpoints must be blocked at the gateway (even with a valid key)
check "Realtime /api/tenants blocked" "403" \
"$(http_status "$BASE_URL/realtime/v1/api/tenants" \
-H "apikey: $ANON_KEY")"
check "Realtime /api/openapi blocked" "403" \
"$(http_status "$BASE_URL/realtime/v1/api/openapi" \
-H "apikey: $ANON_KEY")"
# ---------------------------------------------
# Summary
+3 -3
View File
@@ -7,11 +7,11 @@ dynamic_resources:
path_config_source:
path: /etc/envoy/lds.yaml
resource_api_version: V3
node:
cluster: supabase_cluster
id: supabase_node
overload_manager:
resource_monitors:
- name: envoy.resource_monitors.global_downstream_max_connections
@@ -19,7 +19,7 @@ overload_manager:
'@type': >-
type.googleapis.com/envoy.extensions.resource_monitors.downstream_connections.v3.DownstreamConnectionsConfig
max_active_downstream_connections: 30000
admin:
address:
socket_address:
+77 -19
View File
@@ -2,12 +2,12 @@ resources:
- '@type': type.googleapis.com/envoy.config.listener.v3.Listener
name: supabase
per_connection_buffer_limit_bytes: 32768 # 32 KiB
address:
socket_address:
address: 0.0.0.0
port_value: 8000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
@@ -31,7 +31,7 @@ resources:
log_format:
text_format_source:
inline_string: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT% - - [%START_TIME(%d/%b/%Y:%H:%M:%S %z)%] \"%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%\" %RESPONSE_CODE% %BYTES_SENT% \"%REQ(REFERER)%\" \"%REQ(USER-AGENT)%\"\n"
route_config:
name: supabase_route
virtual_hosts:
@@ -84,7 +84,7 @@ resources:
- any: true
principals:
- any: true
- match:
prefix: /auth/v1/callback
route:
@@ -113,7 +113,7 @@ resources:
- any: true
principals:
- any: true
- match:
prefix: /auth/v1/authorize
route:
@@ -255,7 +255,7 @@ resources:
- any: true
principals:
- any: true
- name: functions-v1-all
match:
prefix: /functions/v1/
@@ -285,7 +285,7 @@ resources:
- any: true
principals:
- any: true
- match:
prefix: /storage/v1/
route:
@@ -314,7 +314,7 @@ resources:
- any: true
principals:
- any: true
- name: auth-v1-protected
match:
prefix: /auth/v1/
@@ -332,7 +332,7 @@ resources:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
- name: rest-v1-protected
match:
prefix: /rest/v1/
@@ -350,7 +350,7 @@ resources:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
- name: graphql-v1-protected
match:
prefix: /graphql/v1
@@ -372,7 +372,65 @@ resources:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
- name: realtime-v1-api-openapi-blocked
match:
prefix: /realtime/v1/api/openapi
route:
cluster: realtime
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /realtime/v1/api/openapi
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: DENY
policies:
deny_all:
permissions:
- any: true
principals:
- any: true
- name: realtime-v1-api-tenants-blocked
match:
prefix: /realtime/v1/api/tenants
route:
cluster: realtime
timeout: 30s
request_headers_to_add:
- header:
key: X-Forwarded-Prefix
value: /realtime/v1/api/tenants
append_action: ADD_IF_ABSENT
typed_per_filter_config:
envoy.filters.http.basic_auth:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
envoy.filters.http.rbac:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBACPerRoute
rbac:
rules:
action: DENY
policies:
deny_all:
permissions:
- any: true
principals:
- any: true
- name: realtime-v1-api-protected
match:
prefix: /realtime/v1/api
@@ -391,7 +449,7 @@ resources:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
- name: realtime-v1-ws-protected
match:
prefix: /realtime/v1/
@@ -410,7 +468,7 @@ resources:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
- name: pg-protected
match:
prefix: /pg/
@@ -428,7 +486,7 @@ resources:
'@type': >-
type.googleapis.com/envoy.config.route.v3.FilterConfig
disabled: true
- match:
prefix: /api/mcp
route:
@@ -456,7 +514,7 @@ resources:
- any: true
principals:
- any: true
- match:
prefix: /mcp
route:
@@ -504,7 +562,7 @@ resources:
# - direct_remote_ip:
# address_prefix: ::1
# prefix_len: 128
- match:
prefix: /
route:
@@ -536,14 +594,14 @@ resources:
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.basic_auth
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.filters.http.basic_auth.v3.BasicAuth
users:
inline_string: '${DASHBOARD_BASIC_AUTH}'
# Copies ?apikey=... from the URL into the apikey header when clients omit the header.
- name: envoy.filters.http.lua
typed_config:
@@ -590,7 +648,7 @@ resources:
end
end
end
# Returns 401 for missing/invalid API keys on protected API routes.
- name: envoy.filters.http.lua
typed_config:
+33 -1
View File
@@ -87,7 +87,7 @@ services:
- /sso/saml/acs
plugins:
- name: cors
- name: auth-v1-open-sso-metadata
url: "http://auth:9999/sso/saml/metadata"
routes:
@@ -216,6 +216,38 @@ services:
- admin
- anon
# Block access to /realtime/v1/api/openapi
- name: realtime-v1-rest-openapi
_comment: 'Realtime: /realtime/v1/api/openapi/* -> http://realtime:4000/api/openapi/* (blocked)'
url: http://realtime-dev.supabase-realtime:4000/api/openapi
protocol: http
routes:
- name: realtime-v1-rest-openapi
strip_path: true
paths:
- /realtime/v1/api/openapi
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
# Block access to /realtime/v1/api/tenants
- name: realtime-v1-rest-tenants
_comment: 'Realtime: /realtime/v1/api/tenants/* -> http://realtime:4000/api/tenants/* (blocked)'
url: http://realtime-dev.supabase-realtime:4000/api/tenants
protocol: http
routes:
- name: realtime-v1-rest-tenants
strip_path: true
paths:
- /realtime/v1/api/tenants
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
- name: realtime-v1-rest
_comment: 'Realtime: /realtime/v1/api/* -> http://realtime:4000/api/*'
url: http://realtime-dev.supabase-realtime:4000/api