security updates

This commit is contained in:
2026-05-31 10:18:50 +02:00
parent 3b3f589d64
commit f313701e5e
14 changed files with 327 additions and 34 deletions
+174 -15
View File
@@ -20,11 +20,104 @@
Referrer-Policy "no-referrer"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
Cross-Origin-Opener-Policy "same-origin"
# SECURITY (R6/W6): ENFORCING Content-Security-Policy for the prod Next.js
# dashboard (app.linumiq.net) and the apex redirect. 'unsafe-inline' is
# required for Next.js App-Router inline flight-data <script> tags (no nonce)
# and React/styled inline styles; the prod bundle uses no eval so
# 'unsafe-eval' is intentionally omitted. connect-src is pinned to the
# prod Supabase API host (REST/auth/storage over https + realtime over wss).
Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; img-src 'self' data: https:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' https://api.linumiq.net wss://api.linumiq.net; worker-src 'self' blob:; object-src 'none'"
-Server
-X-Powered-By
}
}
# API hosts (api / api-dev) serve only JSON - no HTML/UI - so CSP is set to an
# ENFORCING, maximally-restrictive policy. The Next.js app/app-dev hosts keep
# Content-Security-Policy-Report-Only (above) until the real UI report stream
# is validated.
(security_headers_api) {
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"
Content-Security-Policy "default-src 'none'; frame-ancestors 'none'"
-Server
-X-Powered-By
}
}
# Dev dashboard CSP: identical to (security_headers) but connect-src is pinned to
# the DEV Supabase API host (api-dev.linumiq.net) over https + wss.
(security_headers_app_dev) {
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"
Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; img-src 'self' data: https:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' https://api-dev.linumiq.net wss://api-dev.linumiq.net; worker-src 'self' blob:; object-src 'none'"
-Server
-X-Powered-By
}
}
# SECURITY (R6): reject machine-only webhook functions (auth-webhook /
# stripe-webhook) and encoded-traversal evasion at the public edge.
#
# Robustness goal: Caddy and the Kong upstream normalize URIs differently
# (Kong collapses duplicate slashes and decodes %2f/%2e; Caddy's raw matcher
# does not). A path-literal, single-slash guard is therefore bypassable with
# extra slashes (e.g. //functions//v1//auth-webhook , /functions/v1//auth-webhook)
# or with percent-encoding. We close every normalization gap by matching on
# BOTH the raw, un-normalized request target ({http.request.orig_uri}, for the
# encoded tricks) AND the Caddy-decoded path ({http.request.uri.path}, which
# neutralises letter-encoding and slash collapsing). The function names are
# internal-only and are never legitimately referenced from the public edge, so
# ANY request mentioning them - at any slash count, case, or encoding - is
# rejected (403). We additionally reject any /functions/ request whose raw
# target carries encoded dot/slash/backslash sequences (%2e %2f %5c, their
# double-encoded forms %252e %252f, or a literal ..).
# get-node intentionally stays public for the Home Assistant add-on.
(block_internal_functions) {
@block_functions expression `{http.request.orig_uri}.matches("(?i)(auth-webhook|stripe-webhook)") || {http.request.uri.path}.matches("(?i)(auth-webhook|stripe-webhook)") || {http.request.orig_uri}.matches("(?i)^/+functions/+v1/+(auth-webhook|stripe-webhook)([/?]|$)") || {http.request.orig_uri}.matches("(?i)/+functions.*(%252e|%252f|%2e|%2f|%5c|\\.\\.)")`
respond @block_functions 403
}
# SECURITY (edge rate limiting): per-client-IP request throttling for the
# first-party app/api surfaces. The stock caddy:2.10.2-alpine image ships no
# rate limiter, so this config is served by a custom build that adds
# github.com/mholt/caddy-ratelimit. Two zones are evaluated per request and the
# tighter matching zone wins:
# - sensitive_per_ip: low ceiling for the machine/auth endpoints
# (/functions/* and /auth/*) that get probed (webhooks, token issuance).
# - api_per_ip: a more generous ceiling for ordinary app/api traffic.
# Keyed on {remote_host} (the real client IP - Caddy is the internet-facing
# edge). Over-limit requests receive 429 with an automatic Retry-After header.
# Deliberately NOT imported into the tunnel vhosts (*.linumiq.net /
# *.dev.linumiq.net) so Home Assistant streaming/websocket traffic is untouched.
(rate_limit_api) {
rate_limit {
zone sensitive_per_ip {
match {
path /functions/* /auth/*
}
key {remote_host}
events 8
window 1s
}
zone api_per_ip {
key {remote_host}
events 20
window 1s
}
}
}
# Apex -> dashboard redirect
linumiq.net {
tls {
@@ -40,7 +133,8 @@ app.linumiq.net {
on_demand
}
import security_headers
reverse_proxy web:3000
import rate_limit_api
reverse_proxy web-prod:3000
}
# Reserved hostname: Supabase API (Kong)
@@ -48,14 +142,10 @@ 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
import security_headers_api
import block_internal_functions
import rate_limit_api
reverse_proxy kong-prod:8000
}
# Wildcard tunnel subdomains -> frps vhost HTTP. Per-name HTTP-01 issued on first hit.
@@ -66,7 +156,42 @@ api.linumiq.net {
on_demand
}
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
reverse_proxy frps:7080
# SECURITY (C2): live quota / active-state enforcement at the edge. frps OSS
# reuses an established work connection and never re-consults the auth webhook
# mid-stream, so a still-connected, over-quota tunnel would otherwise keep
# serving 200 indefinitely. We gate every tunnel request through the
# tunnel-active edge function (an O(1) redis lookup of tunnel:active:<sub>),
# which returns 200 while active+under-quota and 403 once the bandwidth worker
# deactivates the tunnel. The worker flips the redis key on deactivation, so a
# live tunnel stops serving within ~1 request without the client reconnecting.
#
# FAIL OPEN: forward_auth only blocks on a definite 403. If the auth endpoint
# is unreachable/times out (dial error, 5xx) the request is allowed through by
# the handle_errors block below (re-proxied straight to frps) so a backend
# outage never takes healthy tunnels (incl. Home Assistant) offline. This hop
# is NOT added to app/api/apex (their own vhost blocks). forward_auth gates
# every normal HTTP request; the upgrade hop is exempted just below.
# Websocket/upgrade requests skip the auth subrequest: Caddy's forward_auth
# proxies the original Upgrade headers to the auth backend and tries to do
# the websocket handshake against it (502). The HA dashboard HTML/API still
# loads over plain HTTP (fully gated), and frps re-validates every
# NewWorkConn, so exempting only the upgrade hop is safe and keeps HA
# websockets working.
@httponly not header Upgrade *
forward_auth @httponly supabase-edge-functions:9000 {
uri /tunnel-active
transport http {
dial_timeout 2s
response_header_timeout 2s
}
}
reverse_proxy frps-prod:7080
handle_errors {
@authdown expression `{http.error.status_code} in [502, 503, 504]`
reverse_proxy @authdown frps-prod:7080
}
}
# ============================================================================
@@ -81,7 +206,8 @@ app-dev.linumiq.net {
tls {
on_demand
}
import security_headers
import security_headers_app_dev
import rate_limit_api
reverse_proxy web-dev:3000
}
@@ -90,10 +216,10 @@ api-dev.linumiq.net {
tls {
on_demand
}
import security_headers
@blocked_webhooks path /functions/v1/auth-webhook* /functions/v1/stripe-webhook*
respond @blocked_webhooks 403
reverse_proxy supabase-dev-kong:8000
import security_headers_api
import block_internal_functions
import rate_limit_api
reverse_proxy kong-dev:8000
}
# Dev wildcard tunnel subdomains -> dev frps vhost HTTP. More specific than
@@ -104,5 +230,38 @@ api-dev.linumiq.net {
on_demand
}
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
# SECURITY (C2): live quota / active-state enforcement at the edge - dev
# mirror of the *.linumiq.net gate. Gates every tunnel request through the
# dev tunnel-active edge function (O(1) redis-dev lookup of
# tunnel:active:<sub>) so a still-connected, over-quota dev tunnel is denied
# within ~1 request without the client reconnecting. FAIL OPEN: a backend
# outage re-proxies straight to frps-dev (handle_errors) so it never takes
# healthy dev tunnels offline. Websocket/upgrade requests skip the auth
# subrequest (HA websockets); frps-dev re-validates every NewWorkConn.
@httponly not header Upgrade *
forward_auth @httponly supabase-dev-edge-functions:9000 {
uri /tunnel-active
transport http {
dial_timeout 2s
response_header_timeout 2s
}
}
reverse_proxy frps-dev:7080
handle_errors {
@authdown expression `{http.error.status_code} in [502, 503, 504]`
reverse_proxy @authdown frps-dev:7080
}
}
# SECURITY (R6): exact-vhost enforcement / catch-all. Any HTTPS request whose
# Host header does not match one of the explicit site blocks above - including
# FQDN trailing-dot evasion like "api.linumiq.net." - is rejected here with 403
# instead of falling through to Caddy's default empty 200 response. This ensures
# only known vhosts are served and closes the trailing-dot Host bypass.
:443 {
respond 403 {
close
}
}