Reverse proxy
Headers the proxy owns
Section titled “Headers the proxy owns”Parley sends Content-Security-Policy, X-Content-Type-Options,
X-Frame-Options and Referrer-Policy itself — see hardening checklist step
3. The five headers it
does not send, and that belong here instead, are Strict-Transport-Security,
Permissions-Policy, Cross-Origin-Opener-Policy,
Cross-Origin-Embedder-Policy and Cross-Origin-Resource-Policy. Both blocks
below set all five. Do not add one Parley already sends: a proxy add_header
or header block wins over the app’s value, so duplicating one quietly changes
the policy instead of confirming it.
Cross-Origin-Embedder-Policy: credentialless rather than require-corp is
deliberate — see the plugin-iframe note under hardening checklist step
3 before changing it.
Caddy handles WebSockets and TLS automatically; add the five headers with
header. The handle /metrics* block keeps /metrics off the public
hostname if you later turn METRICS_ENABLED on — scrape the process from
inside the network instead:
parley.example.com { handle /metrics* { respond 404 }
reverse_proxy 127.0.0.1:8080
header { Strict-Transport-Security "max-age=63072000; includeSubDomains" Permissions-Policy "camera=(), microphone=(), geolocation=()" Cross-Origin-Opener-Policy "same-origin" Cross-Origin-Embedder-Policy "credentialless" Cross-Origin-Resource-Policy "same-origin" }}Verify it landed:
curl -sI https://parley.example.com | grep -Ei 'strict-transport|permissions-policy|cross-origin'Needs the upgrade headers and a read timeout longer than a quiet standup speaker.
Parley pings every 25s, so 75s is safe. This is a full server context because
a nested add_header replaces every header inherited from an outer
context rather than adding to it — put these in a location block instead and
they silently drop whatever was set on server. Each uses always, or nginx
omits it on any response that is not a plain 2xx/3xx — exactly the responses
you most want a security header on. location /metrics returns 404 so the
unauthenticated exposition never reaches the public hostname:
server { listen 443 ssl; server_name parley.example.com;
ssl_certificate /etc/letsencrypt/live/parley.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/parley.example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Cross-Origin-Resource-Policy "same-origin" always;
location /metrics { return 404; }
location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_read_timeout 75s; proxy_send_timeout 75s; }}Verify it landed:
curl -sI https://parley.example.com | grep -Ei 'strict-transport|permissions-policy|cross-origin'Then set two variables
Section titled “Then set two variables”BASE_URL=https://parley.example.comTRUST_PROXY_HEADERS=trueTRUSTED_PROXY_CIDRS=127.0.0.1/32BASE_URL must match the address in the browser’s bar exactly — scheme, host
and port — or the WebSocket origin check rejects every connection.
TRUST_PROXY_HEADERS=true lets the room-code and identity-creation throttles
count real clients instead of seeing every request as coming from the proxy.
It requires TRUSTED_PROXY_CIDRS: use 127.0.0.1/32 only for a same-host proxy,
or the actual Ingress/load-balancer networks for another topology. List every
trusted hop and never a client-reachable network. Parley ignores forwarded
headers from an untrusted immediate peer. See
Configuration.
Two hops: nginx in front of an ingress controller
Section titled “Two hops: nginx in front of an ingress controller”A common self-hosted shape is TLS terminated by nginx on a separate host, which forwards to a Kubernetes ingress controller, which forwards to Parley. Every hop in that chain has to cooperate or the client address does not survive it.
The outermost proxy appends. The nginx block above uses
X-Forwarded-For $remote_addr, which sets the header. When nginx is the first
hop that is the same thing, but the appending form is what you want in a chain:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;The middle hop must be told to trust the outer one, or it throws the chain
away. This is the step that is easy to read as optional hardening and is not.
Traefik does not forward an incoming X-Forwarded-For unless the peer that sent
it is listed in forwardedHeaders.trustedIPs — an untrusted peer’s forwarded
headers are dropped and replaced. So omitting it does not merely widen trust; it
truncates the chain, Parley sees only the controller, and the room-code throttle
collapses to a single bucket for the whole internet. Set it:
ports: websecure: forwardedHeaders: trustedIPs: - 203.0.113.10/32 # the nginx host, as Traefik actually sees itingress-nginx is the same idea under a different name: proxy-real-ip-cidr in
its ConfigMap.
Find out what address that hop really is, empirically. Do not assume it is
the nginx host’s public address. With k3s servicelb and the default
externalTrafficPolicy: Cluster, traffic entering a node is SNAT’d before it
reaches the controller, so Traefik may well see a node address instead. Which
one it is depends on your load balancer, your service configuration and your
CNI, and getting it wrong fails in the quiet direction — the trust list simply
never matches. Read it off a request rather than deriving it:
kubectl --namespace kube-system logs deploy/traefik | tailwith the access log on, or set LOG_LEVEL=debug on Parley and read the
peer and forwarded_for fields it logs at the trust decision. See
Observability.
Then set Parley’s own list to the controller’s network, which on Kubernetes means the pod CIDR — and read the trap that comes with it before you do. Parley walks the chain right-to-left past every trusted hop, so with all three configured the address it resolves is the real client’s.
Public deployments
Section titled “Public deployments”TLS and correct proxy addresses are necessary but are not access control.
AUTH_MODE=open is trusted-network-only. Before exposing Parley publicly,
require a space passcode or put an external SSO/authentication proxy in front,
and configure request, connection, and bandwidth limits at this layer.
/version needs no auth to answer and is handy for a health check, but it also
tells an internet scanner exactly which CVEs apply to you — restrict it to
your monitoring network at the proxy, for example
location = /version { allow 10.0.0.0/8; deny all; proxy_pass http://127.0.0.1:8080; }
in nginx.
/metrics is the same shape, and louder: with METRICS_ENABLED=true it is an
unauthenticated Prometheus exposition on the app port, and the chart’s Ingress
routes path: / Prefix, so it is published unless something in front denies
it. The Caddy handle /metrics* and nginx location /metrics blocks above
return 404 for that path. On ingress-nginx, a server-snippet does the same
job:
nginx.ingress.kubernetes.io/server-snippet: | location /metrics { return 404; }Scrape the Service from inside the cluster instead. See Observability.