diff --git a/.gitignore b/.gitignore index 463fdd5..3f5609d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,8 @@ /supabase/volumes/ /web/ /SECRETS.md -.env \ No newline at end of file +.env +# Remediation: keep secret-bearing files out of git +/frps/frps.toml +/.remediation-backup/ +/up.sh diff --git a/bandwidth-worker/docker-compose.yml b/bandwidth-worker/docker-compose.yml index ae20451..61fc5f0 100644 --- a/bandwidth-worker/docker-compose.yml +++ b/bandwidth-worker/docker-compose.yml @@ -4,6 +4,8 @@ services: image: bandwidth-worker:1.0.0 container_name: bandwidth-worker restart: unless-stopped + security_opt: + - no-new-privileges:true env_file: .env networks: - edge diff --git a/bandwidth-worker/worker.py b/bandwidth-worker/worker.py index db1739c..8cde8b7 100644 --- a/bandwidth-worker/worker.py +++ b/bandwidth-worker/worker.py @@ -1,4 +1,9 @@ -"""bandwidth-worker: polls frps dashboard, writes deltas to Postgres via PostgREST.""" +"""bandwidth-worker: polls frps dashboard, writes deltas to Postgres via PostgREST. + +Also enforces per-tunnel quota: when a tunnel crosses its quota it is marked +inactive and its auth-cache entry is invalidated so the next frps Ping/NewProxy +is rejected, terminating the live session (frps OSS exposes no kill endpoint). +""" from __future__ import annotations @@ -68,6 +73,37 @@ def persist_total(subdomain: str, total: int) -> None: log.warning("redis set failed for %s: %s", subdomain, e) +def enforce_quota(client: httpx.Client, subdomain: str, row: dict[str, Any], new_used: int) -> None: + """Deactivate a tunnel that has exhausted its quota and invalidate the + auth cache so the next frps Ping/NewProxy is rejected.""" + quota = int(row.get("quota_bytes") or 0) + is_active = bool(row.get("is_active")) + token = row.get("token") or "" + if quota <= 0 or not is_active or new_used < quota: + return + + upd = client.patch( + f"{SUPABASE_URL}/rest/v1/tunnels?subdomain=eq.{subdomain}", + headers=_headers({"prefer": "return=minimal"}), + json={"is_active": False}, + timeout=10.0, + ) + if upd.status_code >= 300: + log.error("quota deactivate failed %s: %s", upd.status_code, upd.text) + return + + if token: + try: + rds.delete(f"tunnel:token:{token}") + except Exception as e: + log.warning("redis auth-cache invalidate failed for %s: %s", subdomain, e) + + log.warning( + "QUOTA EXCEEDED: subdomain=%s used=%d quota=%d -> deactivated", + subdomain, new_used, quota, + ) + + def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None: r = client.post( f"{SUPABASE_URL}/rest/v1/usage_samples", @@ -80,7 +116,7 @@ def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None: return cur = client.get( - f"{SUPABASE_URL}/rest/v1/tunnels?select=bytes_used&subdomain=eq.{subdomain}", + f"{SUPABASE_URL}/rest/v1/tunnels?select=bytes_used,quota_bytes,is_active,token&subdomain=eq.{subdomain}", headers=_headers(), timeout=10.0, ) @@ -91,18 +127,23 @@ def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None: if not rows: log.warning("tunnel row missing for subdomain=%s; sample retained", subdomain) return - current = int(rows[0].get("bytes_used") or 0) + row = rows[0] + current = int(row.get("bytes_used") or 0) + new_used = current + delta upd = client.patch( f"{SUPABASE_URL}/rest/v1/tunnels?subdomain=eq.{subdomain}", headers=_headers({"prefer": "return=minimal"}), json={ - "bytes_used": current + delta, + "bytes_used": new_used, "last_seen_at": datetime.now(timezone.utc).isoformat(), }, timeout=10.0, ) if upd.status_code >= 300: log.error("tunnels update failed %s: %s", upd.status_code, upd.text) + return + + enforce_quota(client, subdomain, row, new_used) def poll_once(client: httpx.Client) -> None: diff --git a/caddy/Caddyfile b/caddy/Caddyfile index c9ece24..ba59a26 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -1,10 +1,27 @@ { email office@linumiq.com on_demand_tls { - # Self-hosted ask endpoint on :9999 (always 200 in Wave A). - # TODO Wave B: point ask at https://api.linumiq.net/functions/v1/check-subdomain - # (an Edge Function that returns 200 only for subdomains present in tunnels table). - ask http://localhost:9999/check + # SECURITY (R2/F3): closed allow-list authorizer. The edge function returns + # 200 only for reserved hosts (apex/app/api) and subdomains registered in + # the tunnels table; 403 otherwise. This prevents unbounded on-demand + # certificate issuance for arbitrary hostnames. + ask http://supabase-edge-functions:9000/check-subdomain + } +} + +# SECURITY (R4/F10/W5): baseline response-hardening headers applied to the +# LinumIQ-controlled surfaces (apex/app/api). HSTS forces HTTPS for a year and +# is safe for first-party hostnames we fully control. +(security_headers) { + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "no-referrer" + Permissions-Policy "geolocation=(), microphone=(), camera=()" + Cross-Origin-Opener-Policy "same-origin" + -Server + -X-Powered-By } } @@ -13,6 +30,7 @@ linumiq.net { tls { on_demand } + import security_headers redir https://app.linumiq.net{uri} permanent } @@ -21,6 +39,7 @@ app.linumiq.net { tls { on_demand } + import security_headers reverse_proxy web:3000 } @@ -29,18 +48,23 @@ api.linumiq.net { tls { on_demand } + import security_headers + # SECURITY (R2): block machine-only webhook functions from the public edge. + # auth-webhook and stripe-webhook are invoked internally over the docker + # network (supabase-edge-functions:9000) and must never be callable from the + # internet. get-node stays public for the Home Assistant add-on. + @blocked_webhooks path /functions/v1/auth-webhook* /functions/v1/stripe-webhook* + respond @blocked_webhooks 403 reverse_proxy supabase-kong:8000 } # Wildcard tunnel subdomains -> frps vhost HTTP. Per-name HTTP-01 issued on first hit. +# NOTE: only HSTS is injected here; Home Assistant sets its own security headers +# (X-Frame-Options, etc.) and we must not override its CSP / framing behaviour. *.linumiq.net { tls { on_demand } + header Strict-Transport-Security "max-age=31536000; includeSubDomains" reverse_proxy frps:7080 } - -# Internal ask endpoint for on-demand TLS. Bound to loopback inside the container. -http://localhost:9999 { - respond /check 200 -} diff --git a/caddy/docker-compose.yml b/caddy/docker-compose.yml index 7107db5..0592307 100644 --- a/caddy/docker-compose.yml +++ b/caddy/docker-compose.yml @@ -3,6 +3,8 @@ services: image: caddy:2.10.2-alpine container_name: caddy restart: unless-stopped + security_opt: + - no-new-privileges:true ports: - "80:80" - "443:443" diff --git a/frps/docker-compose.yml b/frps/docker-compose.yml index d91c8b6..dbab784 100644 --- a/frps/docker-compose.yml +++ b/frps/docker-compose.yml @@ -3,9 +3,13 @@ services: image: snowdreamtech/frps:0.65.0 container_name: frps restart: unless-stopped + security_opt: + - no-new-privileges:true ports: + # Tunnel ingress (frpc clients connect here). Must stay public. - "7000:7000" - - "7500:7500" + # SECURITY (R2): dashboard/API port 7500 unpublished. bandwidth-worker + # reaches it internally via frps:7500 on the edge network. volumes: - ./frps.toml:/etc/frp/frps.toml:ro command: ["frps", "-c", "/etc/frp/frps.toml"] diff --git a/redis/docker-compose.yml b/redis/docker-compose.yml index 2be93c5..30db813 100644 --- a/redis/docker-compose.yml +++ b/redis/docker-compose.yml @@ -3,6 +3,12 @@ services: image: redis:7.2-alpine container_name: redis restart: unless-stopped + # SECURITY (R4/I7): drop privileges and disable destructive admin commands. + # The only Redis consumers (auth-webhook, bandwidth-worker) use GET/SET/DEL + # exclusively, so disabling FLUSHALL/FLUSHDB/KEYS/DEBUG is safe and limits + # blast radius if the edge network is ever abused. + security_opt: + - no-new-privileges:true environment: REDIS_PASSWORD: ${REDIS_PASSWORD} command: @@ -13,6 +19,18 @@ services: - "yes" - --appendfsync - everysec + - --rename-command + - FLUSHALL + - "" + - --rename-command + - FLUSHDB + - "" + - --rename-command + - KEYS + - "" + - --rename-command + - DEBUG + - "" volumes: - ./data:/data healthcheck: diff --git a/stripe-stub/app.py b/stripe-stub/app.py index 94217ed..e731b81 100644 --- a/stripe-stub/app.py +++ b/stripe-stub/app.py @@ -1,6 +1,13 @@ -"""stripe-stub: pretends every checkout succeeds and forwards test webhooks.""" +"""stripe-stub: pretends every checkout succeeds and forwards test webhooks. +Forwarded webhooks are signed with an HMAC-SHA256 signature so the edge +function can reject unsigned/forged/replayed events. +""" + +import hashlib +import hmac import os +import time import uuid from typing import Any @@ -12,10 +19,21 @@ EDGE_URL = os.environ.get( "EDGE_STRIPE_WEBHOOK_URL", "http://supabase-edge-functions:9000/stripe-webhook", ) +WEBHOOK_SECRET = os.environ.get("STRIPE_STUB_WEBHOOK_SECRET", "") app = FastAPI() +def sign(body: bytes) -> str: + ts = int(time.time()) + mac = hmac.new( + WEBHOOK_SECRET.encode(), + f"{ts}.".encode() + body, + hashlib.sha256, + ).hexdigest() + return f"t={ts},v1={mac}" + + @app.post("/v1/checkout/sessions") async def create_session(_request: Request) -> dict[str, str]: session_id = f"stub_{uuid.uuid4().hex}" @@ -28,12 +46,12 @@ async def create_session(_request: Request) -> dict[str, str]: @app.post("/v1/webhooks/test") async def forward_test(request: Request) -> Any: body = await request.body() + headers = { + "content-type": request.headers.get("content-type", "application/json"), + "x-stub-signature": sign(body), + } async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.post( - EDGE_URL, - content=body, - headers={"content-type": request.headers.get("content-type", "application/json")}, - ) + resp = await client.post(EDGE_URL, content=body, headers=headers) try: return resp.json() except Exception: diff --git a/stripe-stub/docker-compose.yml b/stripe-stub/docker-compose.yml index 4f913e4..d9ca7e0 100644 --- a/stripe-stub/docker-compose.yml +++ b/stripe-stub/docker-compose.yml @@ -4,6 +4,8 @@ services: image: stripe-stub:1.0.0 container_name: stripe-stub restart: unless-stopped + security_opt: + - no-new-privileges:true env_file: .env networks: - edge diff --git a/supabase/docker-compose.yml b/supabase/docker-compose.yml index 0d45d6d..513a567 100644 --- a/supabase/docker-compose.yml +++ b/supabase/docker-compose.yml @@ -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 diff --git a/supabase/migrations/0002_hardening.sql b/supabase/migrations/0002_hardening.sql new file mode 100644 index 0000000..dfca002 --- /dev/null +++ b/supabase/migrations/0002_hardening.sql @@ -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; diff --git a/supabase/migrations/0003_least_privilege.sql b/supabase/migrations/0003_least_privilege.sql new file mode 100644 index 0000000..3485bf6 --- /dev/null +++ b/supabase/migrations/0003_least_privilege.sql @@ -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;