diff --git a/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx b/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx index 08b87dac9ab..63552870758 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx @@ -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 diff --git a/apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx b/apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx index 49a6fadbc61..294b3dca21c 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx @@ -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. diff --git a/docker/tests/test-auth-keys.sh b/docker/tests/test-auth-keys.sh index 03a90f979c8..2b1ad360934 100644 --- a/docker/tests/test-auth-keys.sh +++ b/docker/tests/test-auth-keys.sh @@ -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) ---" diff --git a/docker/tests/test-self-hosted.sh b/docker/tests/test-self-hosted.sh index f1dc0f7f811..7035b3bc29d 100644 --- a/docker/tests/test-self-hosted.sh +++ b/docker/tests/test-self-hosted.sh @@ -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 diff --git a/docker/volumes/api/envoy/envoy.yaml b/docker/volumes/api/envoy/envoy.yaml index bf3dd4ebf73..def443b6881 100644 --- a/docker/volumes/api/envoy/envoy.yaml +++ b/docker/volumes/api/envoy/envoy.yaml @@ -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: diff --git a/docker/volumes/api/envoy/lds.template.yaml b/docker/volumes/api/envoy/lds.template.yaml index 62580b1249f..beb7fc3321c 100644 --- a/docker/volumes/api/envoy/lds.template.yaml +++ b/docker/volumes/api/envoy/lds.template.yaml @@ -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: diff --git a/docker/volumes/api/kong.yml b/docker/volumes/api/kong.yml index b89e868f557..d315f361aa2 100644 --- a/docker/volumes/api/kong.yml +++ b/docker/volumes/api/kong.yml @@ -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