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:
2026-05-30 10:45:07 +02:00
parent a8593afa61
commit 50ab46dbe1
12 changed files with 259 additions and 33 deletions
+33 -9
View File
@@ -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
}
+2
View File
@@ -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"