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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user