security: harden remote-access stack (pentest remediation R1-R4)
App layer (R1): bind frps NewProxy to token-owned subdomain (anti-hijack), default-deny unknown webhook ops, HMAC-verify stripe-stub billing webhook, enforce bandwidth quota kill-switch (Ping op), least-privilege table grants (migrations 0002/0003), GOTRUE_PASSWORD_MIN_LENGTH=12. Infra/net (R2): unpublish internal host ports (kong/pooler/analytics/frps-dash), read-only docker-socket-proxy for vector (no host breakout), on-demand-TLS allow-list authorizer, edge-block machine-only webhooks, no-new-privileges on custom containers. Secrets (R3): rotate Postgres password (all roles) + frps dashboard; replace predictable supavisor defaults; secrets externalized to gitignored .env. Med/Low (R4): security response headers (HSTS/XCTO/XFO/Referrer/Permissions/COOP), restrict frp proxy_type to http (no open relay), disable destructive redis commands, tighten frps.toml perms. No secrets committed; rotated values live only in gitignored .env files.
This commit is contained in:
+49
-12
@@ -52,9 +52,8 @@ services:
|
||||
restart: unless-stopped
|
||||
# https://unix.stackexchange.com/a/294837
|
||||
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
|
||||
ports:
|
||||
- ${KONG_HTTP_PORT}:8000/tcp
|
||||
- ${KONG_HTTPS_PORT}:8443/tcp
|
||||
# SECURITY (R2/F2/I3): host ports unpublished. Kong is reached only via Caddy
|
||||
# over the internal docker network (api.linumiq.net -> supabase-kong:8000).
|
||||
depends_on:
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
@@ -113,6 +112,7 @@ services:
|
||||
GOTRUE_JWT_AUD: authenticated
|
||||
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
|
||||
GOTRUE_PASSWORD_MIN_LENGTH: ${GOTRUE_PASSWORD_MIN_LENGTH}
|
||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||
|
||||
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
|
||||
@@ -315,6 +315,7 @@ services:
|
||||
# TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786
|
||||
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
STRIPE_STUB_WEBHOOK_SECRET: ${STRIPE_STUB_WEBHOOK_SECRET}
|
||||
volumes:
|
||||
- ./volumes/functions:/home/deno/functions:Z
|
||||
command:
|
||||
@@ -361,8 +362,7 @@ services:
|
||||
# Uncomment to use Big Query backend for analytics
|
||||
# GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID}
|
||||
# GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER}
|
||||
ports:
|
||||
- 4000:4000
|
||||
# SECURITY (R2/F2/I3): host port 4000 unpublished. Reached internally as analytics:4000.
|
||||
|
||||
# Comment out everything below this point if you are using an external Postgres database
|
||||
db:
|
||||
@@ -412,6 +412,41 @@ services:
|
||||
# Volume to persist pgsodium decryption key between restarts
|
||||
- ./volumes/db-config:/etc/postgresql-custom
|
||||
|
||||
# SECURITY (R2/I1): read-only Docker API gateway. Vector no longer mounts the
|
||||
# raw docker.sock (a `:ro` bind on a unix socket does NOT restrict the Docker
|
||||
# API, so a compromised vector could create privileged containers = host
|
||||
# escape). This haproxy-based proxy exposes ONLY read endpoints (GET
|
||||
# containers/events/ping); all POST/exec/create/start are denied.
|
||||
docker-socket-proxy:
|
||||
container_name: supabase-docker-socket-proxy
|
||||
image: tecnativa/docker-socket-proxy:0.3.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CONTAINERS: 1
|
||||
EVENTS: 1
|
||||
PING: 1
|
||||
INFO: 0
|
||||
POST: 0
|
||||
EXEC: 0
|
||||
IMAGES: 0
|
||||
NETWORKS: 0
|
||||
VOLUMES: 0
|
||||
BUILD: 0
|
||||
COMMIT: 0
|
||||
CONFIGS: 0
|
||||
DISTRIBUTION: 0
|
||||
NODES: 0
|
||||
PLUGINS: 0
|
||||
SECRETS: 0
|
||||
SERVICES: 0
|
||||
SESSION: 0
|
||||
SWARM: 0
|
||||
SYSTEM: 0
|
||||
TASKS: 0
|
||||
AUTH: 0
|
||||
volumes:
|
||||
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro
|
||||
|
||||
vector:
|
||||
container_name: supabase-vector
|
||||
image: timberio/vector:0.28.1-alpine
|
||||
@@ -429,9 +464,11 @@ services:
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
docker-socket-proxy:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro
|
||||
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro
|
||||
environment:
|
||||
LOGFLARE_API_KEY: ${LOGFLARE_API_KEY}
|
||||
command: [ "--config", "etc/vector/vector.yml" ]
|
||||
@@ -455,9 +492,8 @@ services:
|
||||
- -c
|
||||
- /app/bin/migrate && /app/bin/supavisor eval "$$(cat /etc/pooler/pooler.exs)" && /app/bin/server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${POSTGRES_PORT}:${POSTGRES_PORT}
|
||||
- ${POOLER_PROXY_PORT_TRANSACTION}:${POOLER_PROXY_PORT_TRANSACTION}
|
||||
# SECURITY (R2/F1/I5): host ports 5432/6543 unpublished. Postgres/pooler are
|
||||
# reached only over the internal docker network; no public DB exposure.
|
||||
environment:
|
||||
- PORT=4000
|
||||
- POSTGRES_PORT=${POSTGRES_PORT}
|
||||
@@ -465,13 +501,14 @@ services:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- DATABASE_URL=ecto://postgres:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/_supabase
|
||||
- CLUSTER_POSTGRES=true
|
||||
- SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
|
||||
- VAULT_ENC_KEY=your-encryption-key-32-chars-min
|
||||
# SECURITY (R3): replaced predictable upstream-default supavisor secrets.
|
||||
- SECRET_KEY_BASE=${SUPAVISOR_SECRET_KEY_BASE}
|
||||
- VAULT_ENC_KEY=${SUPAVISOR_VAULT_ENC_KEY}
|
||||
- API_JWT_SECRET=${JWT_SECRET}
|
||||
- METRICS_JWT_SECRET=${JWT_SECRET}
|
||||
- REGION=local
|
||||
- ERL_AFLAGS=-proto_dist inet_tcp
|
||||
- POOLER_TENANT_ID=your-tenant-id
|
||||
- POOLER_TENANT_ID=${POOLER_TENANT_ID}
|
||||
- POOLER_DEFAULT_POOL_SIZE=${POOLER_DEFAULT_POOL_SIZE}
|
||||
- POOLER_MAX_CLIENT_CONN=${POOLER_MAX_CLIENT_CONN}
|
||||
- POOLER_POOL_MODE=transaction
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- 0002: pentest remediation (A4/W3 privilege reduction, A3 quota default, W1 subscription rows)
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- A4 / W3: authenticated users must NOT be able to UPDATE tunnels directly.
|
||||
-- All legitimate writes (claim, token rotation, quota/usage, activation) go
|
||||
-- through service-role server routes / workers. A direct grant let an owner
|
||||
-- tamper with bytes_used, quota_bytes, is_active, token and subdomain.
|
||||
-- ---------------------------------------------------------------------------
|
||||
REVOKE UPDATE ON public.tunnels FROM authenticated;
|
||||
DROP POLICY IF EXISTS tunnels_update_own ON public.tunnels;
|
||||
-- SELECT (read-only dashboard) stays intact via tunnels_select_own.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- A3: free-tier quota default 1 TiB -> 2 GiB, and shrink existing rows that
|
||||
-- still carry the old default so the kill-switch is meaningful.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE public.tunnels ALTER COLUMN quota_bytes SET DEFAULT 2147483648;
|
||||
UPDATE public.tunnels
|
||||
SET quota_bytes = 2147483648
|
||||
WHERE quota_bytes = 1099511627776;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- W1 (enablement): ensure every user has a subscriptions row so the billing
|
||||
-- webhook activation (PATCH by user) actually targets a row, and backfill
|
||||
-- existing users.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.users_profile (user_id, email)
|
||||
VALUES (NEW.id, NEW.email)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
INSERT INTO public.subscriptions (user_id, plan, status)
|
||||
VALUES (NEW.id, 'free', 'active')
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.handle_new_user() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.handle_new_user() TO supabase_auth_admin;
|
||||
|
||||
INSERT INTO public.subscriptions (user_id, plan, status)
|
||||
SELECT id, 'free', 'active' FROM auth.users
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 0003: least-privilege table grants (A4/W3 hardening).
|
||||
-- Supabase's default privileges grant ALL on public tables to anon &
|
||||
-- authenticated. RLS gates DML, but TRUNCATE bypasses RLS and unauthenticated
|
||||
-- (anon) should have no direct table rights at all. Reduce to the minimum the
|
||||
-- app actually needs; service_role (which bypasses RLS) keeps full access.
|
||||
BEGIN;
|
||||
|
||||
REVOKE ALL ON public.tunnels FROM anon, authenticated;
|
||||
REVOKE ALL ON public.subscriptions FROM anon, authenticated;
|
||||
REVOKE ALL ON public.usage_samples FROM anon, authenticated;
|
||||
REVOKE ALL ON public.users_profile FROM anon, authenticated;
|
||||
|
||||
-- Authenticated users get read-only dashboard access (still gated by RLS
|
||||
-- owner policies). users_profile also needs UPDATE (it has an owner policy).
|
||||
GRANT SELECT ON public.tunnels TO authenticated;
|
||||
GRANT SELECT ON public.subscriptions TO authenticated;
|
||||
GRANT SELECT, UPDATE ON public.users_profile TO authenticated;
|
||||
-- usage_samples: service_role only (no anon/authenticated access).
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user