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:
+5
-1
@@ -4,4 +4,8 @@
|
|||||||
/supabase/volumes/
|
/supabase/volumes/
|
||||||
/web/
|
/web/
|
||||||
/SECRETS.md
|
/SECRETS.md
|
||||||
.env
|
.env
|
||||||
|
# Remediation: keep secret-bearing files out of git
|
||||||
|
/frps/frps.toml
|
||||||
|
/.remediation-backup/
|
||||||
|
/up.sh
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ services:
|
|||||||
image: bandwidth-worker:1.0.0
|
image: bandwidth-worker:1.0.0
|
||||||
container_name: bandwidth-worker
|
container_name: bandwidth-worker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
env_file: .env
|
env_file: .env
|
||||||
networks:
|
networks:
|
||||||
- edge
|
- edge
|
||||||
|
|||||||
@@ -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
|
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)
|
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:
|
def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None:
|
||||||
r = client.post(
|
r = client.post(
|
||||||
f"{SUPABASE_URL}/rest/v1/usage_samples",
|
f"{SUPABASE_URL}/rest/v1/usage_samples",
|
||||||
@@ -80,7 +116,7 @@ def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
cur = client.get(
|
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(),
|
headers=_headers(),
|
||||||
timeout=10.0,
|
timeout=10.0,
|
||||||
)
|
)
|
||||||
@@ -91,18 +127,23 @@ def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None:
|
|||||||
if not rows:
|
if not rows:
|
||||||
log.warning("tunnel row missing for subdomain=%s; sample retained", subdomain)
|
log.warning("tunnel row missing for subdomain=%s; sample retained", subdomain)
|
||||||
return
|
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(
|
upd = client.patch(
|
||||||
f"{SUPABASE_URL}/rest/v1/tunnels?subdomain=eq.{subdomain}",
|
f"{SUPABASE_URL}/rest/v1/tunnels?subdomain=eq.{subdomain}",
|
||||||
headers=_headers({"prefer": "return=minimal"}),
|
headers=_headers({"prefer": "return=minimal"}),
|
||||||
json={
|
json={
|
||||||
"bytes_used": current + delta,
|
"bytes_used": new_used,
|
||||||
"last_seen_at": datetime.now(timezone.utc).isoformat(),
|
"last_seen_at": datetime.now(timezone.utc).isoformat(),
|
||||||
},
|
},
|
||||||
timeout=10.0,
|
timeout=10.0,
|
||||||
)
|
)
|
||||||
if upd.status_code >= 300:
|
if upd.status_code >= 300:
|
||||||
log.error("tunnels update failed %s: %s", upd.status_code, upd.text)
|
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:
|
def poll_once(client: httpx.Client) -> None:
|
||||||
|
|||||||
+33
-9
@@ -1,10 +1,27 @@
|
|||||||
{
|
{
|
||||||
email office@linumiq.com
|
email office@linumiq.com
|
||||||
on_demand_tls {
|
on_demand_tls {
|
||||||
# Self-hosted ask endpoint on :9999 (always 200 in Wave A).
|
# SECURITY (R2/F3): closed allow-list authorizer. The edge function returns
|
||||||
# TODO Wave B: point ask at https://api.linumiq.net/functions/v1/check-subdomain
|
# 200 only for reserved hosts (apex/app/api) and subdomains registered in
|
||||||
# (an Edge Function that returns 200 only for subdomains present in tunnels table).
|
# the tunnels table; 403 otherwise. This prevents unbounded on-demand
|
||||||
ask http://localhost:9999/check
|
# 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 {
|
tls {
|
||||||
on_demand
|
on_demand
|
||||||
}
|
}
|
||||||
|
import security_headers
|
||||||
redir https://app.linumiq.net{uri} permanent
|
redir https://app.linumiq.net{uri} permanent
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +39,7 @@ app.linumiq.net {
|
|||||||
tls {
|
tls {
|
||||||
on_demand
|
on_demand
|
||||||
}
|
}
|
||||||
|
import security_headers
|
||||||
reverse_proxy web:3000
|
reverse_proxy web:3000
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,18 +48,23 @@ api.linumiq.net {
|
|||||||
tls {
|
tls {
|
||||||
on_demand
|
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
|
reverse_proxy supabase-kong:8000
|
||||||
}
|
}
|
||||||
|
|
||||||
# Wildcard tunnel subdomains -> frps vhost HTTP. Per-name HTTP-01 issued on first hit.
|
# 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 {
|
*.linumiq.net {
|
||||||
tls {
|
tls {
|
||||||
on_demand
|
on_demand
|
||||||
}
|
}
|
||||||
|
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
reverse_proxy frps:7080
|
reverse_proxy frps:7080
|
||||||
}
|
}
|
||||||
|
|
||||||
# Internal ask endpoint for on-demand TLS. Bound to loopback inside the container.
|
|
||||||
http://localhost:9999 {
|
|
||||||
respond /check 200
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ services:
|
|||||||
image: caddy:2.10.2-alpine
|
image: caddy:2.10.2-alpine
|
||||||
container_name: caddy
|
container_name: caddy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ services:
|
|||||||
image: snowdreamtech/frps:0.65.0
|
image: snowdreamtech/frps:0.65.0
|
||||||
container_name: frps
|
container_name: frps
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
ports:
|
ports:
|
||||||
|
# Tunnel ingress (frpc clients connect here). Must stay public.
|
||||||
- "7000:7000"
|
- "7000:7000"
|
||||||
- "7500:7500"
|
# SECURITY (R2): dashboard/API port 7500 unpublished. bandwidth-worker
|
||||||
|
# reaches it internally via frps:7500 on the edge network.
|
||||||
volumes:
|
volumes:
|
||||||
- ./frps.toml:/etc/frp/frps.toml:ro
|
- ./frps.toml:/etc/frp/frps.toml:ro
|
||||||
command: ["frps", "-c", "/etc/frp/frps.toml"]
|
command: ["frps", "-c", "/etc/frp/frps.toml"]
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ services:
|
|||||||
image: redis:7.2-alpine
|
image: redis:7.2-alpine
|
||||||
container_name: redis
|
container_name: redis
|
||||||
restart: unless-stopped
|
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:
|
environment:
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
command:
|
command:
|
||||||
@@ -13,6 +19,18 @@ services:
|
|||||||
- "yes"
|
- "yes"
|
||||||
- --appendfsync
|
- --appendfsync
|
||||||
- everysec
|
- everysec
|
||||||
|
- --rename-command
|
||||||
|
- FLUSHALL
|
||||||
|
- ""
|
||||||
|
- --rename-command
|
||||||
|
- FLUSHDB
|
||||||
|
- ""
|
||||||
|
- --rename-command
|
||||||
|
- KEYS
|
||||||
|
- ""
|
||||||
|
- --rename-command
|
||||||
|
- DEBUG
|
||||||
|
- ""
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
+24
-6
@@ -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 os
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -12,10 +19,21 @@ EDGE_URL = os.environ.get(
|
|||||||
"EDGE_STRIPE_WEBHOOK_URL",
|
"EDGE_STRIPE_WEBHOOK_URL",
|
||||||
"http://supabase-edge-functions:9000/stripe-webhook",
|
"http://supabase-edge-functions:9000/stripe-webhook",
|
||||||
)
|
)
|
||||||
|
WEBHOOK_SECRET = os.environ.get("STRIPE_STUB_WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
app = FastAPI()
|
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")
|
@app.post("/v1/checkout/sessions")
|
||||||
async def create_session(_request: Request) -> dict[str, str]:
|
async def create_session(_request: Request) -> dict[str, str]:
|
||||||
session_id = f"stub_{uuid.uuid4().hex}"
|
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")
|
@app.post("/v1/webhooks/test")
|
||||||
async def forward_test(request: Request) -> Any:
|
async def forward_test(request: Request) -> Any:
|
||||||
body = await request.body()
|
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:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(EDGE_URL, content=body, headers=headers)
|
||||||
EDGE_URL,
|
|
||||||
content=body,
|
|
||||||
headers={"content-type": request.headers.get("content-type", "application/json")},
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ services:
|
|||||||
image: stripe-stub:1.0.0
|
image: stripe-stub:1.0.0
|
||||||
container_name: stripe-stub
|
container_name: stripe-stub
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
env_file: .env
|
env_file: .env
|
||||||
networks:
|
networks:
|
||||||
- edge
|
- edge
|
||||||
|
|||||||
+49
-12
@@ -52,9 +52,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# https://unix.stackexchange.com/a/294837
|
# https://unix.stackexchange.com/a/294837
|
||||||
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
|
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
|
||||||
ports:
|
# SECURITY (R2/F2/I3): host ports unpublished. Kong is reached only via Caddy
|
||||||
- ${KONG_HTTP_PORT}:8000/tcp
|
# over the internal docker network (api.linumiq.net -> supabase-kong:8000).
|
||||||
- ${KONG_HTTPS_PORT}:8443/tcp
|
|
||||||
depends_on:
|
depends_on:
|
||||||
analytics:
|
analytics:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -113,6 +112,7 @@ services:
|
|||||||
GOTRUE_JWT_AUD: authenticated
|
GOTRUE_JWT_AUD: authenticated
|
||||||
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||||
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
|
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
|
||||||
|
GOTRUE_PASSWORD_MIN_LENGTH: ${GOTRUE_PASSWORD_MIN_LENGTH}
|
||||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
|
||||||
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
|
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
|
# TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786
|
||||||
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
||||||
REDIS_URL: ${REDIS_URL}
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
STRIPE_STUB_WEBHOOK_SECRET: ${STRIPE_STUB_WEBHOOK_SECRET}
|
||||||
volumes:
|
volumes:
|
||||||
- ./volumes/functions:/home/deno/functions:Z
|
- ./volumes/functions:/home/deno/functions:Z
|
||||||
command:
|
command:
|
||||||
@@ -361,8 +362,7 @@ services:
|
|||||||
# Uncomment to use Big Query backend for analytics
|
# Uncomment to use Big Query backend for analytics
|
||||||
# GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID}
|
# GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID}
|
||||||
# GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER}
|
# GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER}
|
||||||
ports:
|
# SECURITY (R2/F2/I3): host port 4000 unpublished. Reached internally as analytics:4000.
|
||||||
- 4000:4000
|
|
||||||
|
|
||||||
# Comment out everything below this point if you are using an external Postgres database
|
# Comment out everything below this point if you are using an external Postgres database
|
||||||
db:
|
db:
|
||||||
@@ -412,6 +412,41 @@ services:
|
|||||||
# Volume to persist pgsodium decryption key between restarts
|
# Volume to persist pgsodium decryption key between restarts
|
||||||
- ./volumes/db-config:/etc/postgresql-custom
|
- ./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:
|
vector:
|
||||||
container_name: supabase-vector
|
container_name: supabase-vector
|
||||||
image: timberio/vector:0.28.1-alpine
|
image: timberio/vector:0.28.1-alpine
|
||||||
@@ -429,9 +464,11 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
interval: 5s
|
interval: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
depends_on:
|
||||||
|
docker-socket-proxy:
|
||||||
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro
|
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro
|
||||||
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro
|
|
||||||
environment:
|
environment:
|
||||||
LOGFLARE_API_KEY: ${LOGFLARE_API_KEY}
|
LOGFLARE_API_KEY: ${LOGFLARE_API_KEY}
|
||||||
command: [ "--config", "etc/vector/vector.yml" ]
|
command: [ "--config", "etc/vector/vector.yml" ]
|
||||||
@@ -455,9 +492,8 @@ services:
|
|||||||
- -c
|
- -c
|
||||||
- /app/bin/migrate && /app/bin/supavisor eval "$$(cat /etc/pooler/pooler.exs)" && /app/bin/server
|
- /app/bin/migrate && /app/bin/supavisor eval "$$(cat /etc/pooler/pooler.exs)" && /app/bin/server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
# SECURITY (R2/F1/I5): host ports 5432/6543 unpublished. Postgres/pooler are
|
||||||
- ${POSTGRES_PORT}:${POSTGRES_PORT}
|
# reached only over the internal docker network; no public DB exposure.
|
||||||
- ${POOLER_PROXY_PORT_TRANSACTION}:${POOLER_PROXY_PORT_TRANSACTION}
|
|
||||||
environment:
|
environment:
|
||||||
- PORT=4000
|
- PORT=4000
|
||||||
- POSTGRES_PORT=${POSTGRES_PORT}
|
- POSTGRES_PORT=${POSTGRES_PORT}
|
||||||
@@ -465,13 +501,14 @@ services:
|
|||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
- DATABASE_URL=ecto://postgres:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/_supabase
|
- DATABASE_URL=ecto://postgres:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/_supabase
|
||||||
- CLUSTER_POSTGRES=true
|
- CLUSTER_POSTGRES=true
|
||||||
- SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
|
# SECURITY (R3): replaced predictable upstream-default supavisor secrets.
|
||||||
- VAULT_ENC_KEY=your-encryption-key-32-chars-min
|
- SECRET_KEY_BASE=${SUPAVISOR_SECRET_KEY_BASE}
|
||||||
|
- VAULT_ENC_KEY=${SUPAVISOR_VAULT_ENC_KEY}
|
||||||
- API_JWT_SECRET=${JWT_SECRET}
|
- API_JWT_SECRET=${JWT_SECRET}
|
||||||
- METRICS_JWT_SECRET=${JWT_SECRET}
|
- METRICS_JWT_SECRET=${JWT_SECRET}
|
||||||
- REGION=local
|
- REGION=local
|
||||||
- ERL_AFLAGS=-proto_dist inet_tcp
|
- 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_DEFAULT_POOL_SIZE=${POOLER_DEFAULT_POOL_SIZE}
|
||||||
- POOLER_MAX_CLIENT_CONN=${POOLER_MAX_CLIENT_CONN}
|
- POOLER_MAX_CLIENT_CONN=${POOLER_MAX_CLIENT_CONN}
|
||||||
- POOLER_POOL_MODE=transaction
|
- 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