diff --git a/docs/vm-security-hardening.md b/docs/vm-security-hardening.md index da0503d..e3952e4 100644 --- a/docs/vm-security-hardening.md +++ b/docs/vm-security-hardening.md @@ -86,3 +86,15 @@ backups moved to `/etc/nginx/backups/` (NOT in an include path). - **Hosted in a SOC 2 Type II compliant data center** TODO: TrustedSite (ex-McAfee SECURE) free tier needs a signup to get the daily-scan trustmark image - add later if an image seal is wanted. + +### TLS cipher: removed all CBC suites (2026-06-06) +Qualys flagged the two remaining CBC suites as WEAK: +`TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384` (0xc024) and +`TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256` (0xc023). CBC modes carry the historic +padding-oracle risk; every modern client supports AEAD, so they were dropped. +Final cipher list = AEAD only: GCM (AES-128/256) + CHACHA20-POLY1305 (TLS 1.2) +plus TLS 1.3 suites. Verified: CBC no longer negotiates, GCM/TLS1.3 work, site +200, **Qualys A+ with WEAK suites: NONE**. The cipher list + the cdn.ywxi.net CSP +addition are now in the ansible templates (`infra/ansible/roles/nginx/templates/`) +so they don't drift on the next ansible run. Firewall captured as IaC in +`infra/firewall/`. diff --git a/infra/ansible/roles/nginx/templates/pw-analytics-tls.conf.j2 b/infra/ansible/roles/nginx/templates/pw-analytics-tls.conf.j2 index ad96650..2c2e53b 100644 --- a/infra/ansible/roles/nginx/templates/pw-analytics-tls.conf.j2 +++ b/infra/ansible/roles/nginx/templates/pw-analytics-tls.conf.j2 @@ -24,7 +24,7 @@ server { ssl_certificate /etc/letsencrypt/live/analytics.performancewest.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/analytics.performancewest.net/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; diff --git a/infra/ansible/roles/nginx/templates/pw-api-tls.conf.j2 b/infra/ansible/roles/nginx/templates/pw-api-tls.conf.j2 index ca9f21a..cb8bd6b 100644 --- a/infra/ansible/roles/nginx/templates/pw-api-tls.conf.j2 +++ b/infra/ansible/roles/nginx/templates/pw-api-tls.conf.j2 @@ -27,7 +27,7 @@ server { ssl_certificate /etc/letsencrypt/live/api.performancewest.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.performancewest.net/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; diff --git a/infra/ansible/roles/nginx/templates/pw-btcpay-tls.conf.j2 b/infra/ansible/roles/nginx/templates/pw-btcpay-tls.conf.j2 index a6e51d3..2b66f48 100644 --- a/infra/ansible/roles/nginx/templates/pw-btcpay-tls.conf.j2 +++ b/infra/ansible/roles/nginx/templates/pw-btcpay-tls.conf.j2 @@ -9,7 +9,7 @@ server { ssl_certificate /etc/letsencrypt/live/{{ shkeeper_domain }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/{{ shkeeper_domain }}/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; diff --git a/infra/ansible/roles/nginx/templates/pw-crypto-tls.conf.j2 b/infra/ansible/roles/nginx/templates/pw-crypto-tls.conf.j2 index a98bdf1..5831111 100644 --- a/infra/ansible/roles/nginx/templates/pw-crypto-tls.conf.j2 +++ b/infra/ansible/roles/nginx/templates/pw-crypto-tls.conf.j2 @@ -9,7 +9,7 @@ server { ssl_certificate /etc/letsencrypt/live/{{ shkeeper_admin_domain }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/{{ shkeeper_admin_domain }}/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; diff --git a/infra/ansible/roles/nginx/templates/pw-dev-api-tls.conf.j2 b/infra/ansible/roles/nginx/templates/pw-dev-api-tls.conf.j2 index d193ae3..c6beb88 100644 --- a/infra/ansible/roles/nginx/templates/pw-dev-api-tls.conf.j2 +++ b/infra/ansible/roles/nginx/templates/pw-dev-api-tls.conf.j2 @@ -16,7 +16,7 @@ server { ssl_certificate /etc/letsencrypt/live/{{ dev_api_domain }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/{{ dev_api_domain }}/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; diff --git a/infra/ansible/roles/nginx/templates/pw-dev-tls.conf.j2 b/infra/ansible/roles/nginx/templates/pw-dev-tls.conf.j2 index 61dadd0..41bd1ee 100644 --- a/infra/ansible/roles/nginx/templates/pw-dev-tls.conf.j2 +++ b/infra/ansible/roles/nginx/templates/pw-dev-tls.conf.j2 @@ -17,7 +17,7 @@ server { ssl_certificate /etc/letsencrypt/live/{{ dev_domain }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/{{ dev_domain }}/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; diff --git a/infra/ansible/roles/nginx/templates/pw-listmonk-tls.conf.j2 b/infra/ansible/roles/nginx/templates/pw-listmonk-tls.conf.j2 index 6af5b65..0c8c060 100644 --- a/infra/ansible/roles/nginx/templates/pw-listmonk-tls.conf.j2 +++ b/infra/ansible/roles/nginx/templates/pw-listmonk-tls.conf.j2 @@ -16,7 +16,7 @@ server { ssl_certificate /etc/letsencrypt/live/{{ listmonk_domain }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/{{ listmonk_domain }}/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; diff --git a/infra/ansible/roles/nginx/templates/pw-minio-tls.conf.j2 b/infra/ansible/roles/nginx/templates/pw-minio-tls.conf.j2 index 264f06e..343e36a 100644 --- a/infra/ansible/roles/nginx/templates/pw-minio-tls.conf.j2 +++ b/infra/ansible/roles/nginx/templates/pw-minio-tls.conf.j2 @@ -13,7 +13,7 @@ server { ssl_certificate /etc/letsencrypt/live/{{ minio_domain }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/{{ minio_domain }}/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; @@ -59,7 +59,7 @@ server { ssl_certificate /etc/letsencrypt/live/{{ minio_console_domain }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/{{ minio_console_domain }}/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; diff --git a/infra/ansible/roles/nginx/templates/pw-monitoring-tls.conf.j2 b/infra/ansible/roles/nginx/templates/pw-monitoring-tls.conf.j2 index c4078d5..2d87b29 100644 --- a/infra/ansible/roles/nginx/templates/pw-monitoring-tls.conf.j2 +++ b/infra/ansible/roles/nginx/templates/pw-monitoring-tls.conf.j2 @@ -24,7 +24,7 @@ server { ssl_certificate /etc/letsencrypt/live/monitoring.performancewest.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/monitoring.performancewest.net/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; diff --git a/infra/ansible/roles/nginx/templates/pw-portal-tls.conf.j2 b/infra/ansible/roles/nginx/templates/pw-portal-tls.conf.j2 index 67c285e..80a523c 100644 --- a/infra/ansible/roles/nginx/templates/pw-portal-tls.conf.j2 +++ b/infra/ansible/roles/nginx/templates/pw-portal-tls.conf.j2 @@ -16,7 +16,7 @@ server { ssl_certificate /etc/letsencrypt/live/{{ portal_domain }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/{{ portal_domain }}/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_session_cache shared:SSL:10m; add_header Strict-Transport-Security "max-age=31536000" always; diff --git a/infra/ansible/roles/nginx/templates/pw-security.conf.j2 b/infra/ansible/roles/nginx/templates/pw-security.conf.j2 index 7e4c2cb..aad67e7 100644 --- a/infra/ansible/roles/nginx/templates/pw-security.conf.j2 +++ b/infra/ansible/roles/nginx/templates/pw-security.conf.j2 @@ -8,6 +8,9 @@ add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header X-XSS-Protection "1; mode=block" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +# Content Security Policy +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://cdn.ywxi.net https://analytics.performancewest.net https://embed.tawk.to https://*.tawk.to; style-src 'self' 'unsafe-inline' https://*.tawk.to https://fonts.googleapis.com; img-src 'self' data: https: blob:; font-src 'self' data: https://*.tawk.to https://fonts.gstatic.com; connect-src 'self' https://api.performancewest.net https://api.dev.performancewest.net https://api.stripe.com https://analytics.performancewest.net https://*.tawk.to wss://*.tawk.to; frame-src https://js.stripe.com https://hooks.stripe.com https://cdn.ywxi.net https://*.tawk.to; object-src 'none'; base-uri 'self'; form-action 'self' https://checkout.stripe.com; upgrade-insecure-requests;" always; + # Block common attack paths location ~* \.(php|asp|aspx|cgi|pl)$ { return 444; diff --git a/infra/ansible/roles/nginx/templates/pw-site-tls.conf.j2 b/infra/ansible/roles/nginx/templates/pw-site-tls.conf.j2 index 9a4a440..2322116 100644 --- a/infra/ansible/roles/nginx/templates/pw-site-tls.conf.j2 +++ b/infra/ansible/roles/nginx/templates/pw-site-tls.conf.j2 @@ -36,7 +36,7 @@ server { ssl_certificate /etc/letsencrypt/live/performancewest.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/performancewest.net/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; diff --git a/infra/firewall/README.md b/infra/firewall/README.md new file mode 100644 index 0000000..9db91dc --- /dev/null +++ b/infra/firewall/README.md @@ -0,0 +1,39 @@ +# Host firewall (Infrastructure as Code) + +Canonical copies of the prod app-host firewall (see `docs/vm-security-hardening.md` +for the full rationale). These are the source of truth; the live host should +match. Installed as a boot-enabled systemd service. + +## Files +- `pw-firewall.nft` -> `/etc/pw-firewall/pw-firewall.nft` + nftables `inet pw_fw` table (input hook, priority -150). Public allow-list + `{22, 22022, 80, 443}`, a `trusted_admin` set allow-listing git/forgejo (3022), + internal subnets + loopback fully trusted, everything else on `ens18` dropped. +- `pw-docker-fw.sh` -> `/usr/local/sbin/pw-docker-fw.sh` + Adds DOCKER-USER rules so external (ens18) NEW inbound to Docker-published + container ports is dropped (postgres/listmonk/api/forgejo were accidentally + on 0.0.0.0); trusted_admin IPs are allow-listed to forgejo first. +- `pw-firewall.service` -> `/etc/systemd/system/pw-firewall.service` + Applies both at boot (After=docker). Also re-applied on docker restart via + `/etc/systemd/system/docker.service.d/pw-firewall.conf` (ExecStartPost). + +## Install / update on the host +``` +sudo install -D -m 0644 pw-firewall.nft /etc/pw-firewall/pw-firewall.nft +sudo install -D -m 0755 pw-docker-fw.sh /usr/local/sbin/pw-docker-fw.sh +sudo install -D -m 0644 pw-firewall.service /etc/systemd/system/pw-firewall.service +sudo systemctl daemon-reload +sudo systemctl enable --now pw-firewall.service +``` + +## Add / remove a trusted admin IP (for git push over :3022) +``` +sudo nft add element inet pw_fw trusted_admin { } # live +# then add to TRUSTED_ADMIN in pw-docker-fw.sh + the set in pw-firewall.nft +# and re-run: sudo systemctl restart pw-firewall.service +``` + +## Safety +Roll out with an auto-rollback timer (`setsid sh -c 'sleep 300; nft delete table +inet pw_fw; iptables -F DOCKER-USER; ...'`) so a bad rule can't lock you out; +cancel it only after verifying SSH + git still work from off-network. diff --git a/infra/firewall/pw-docker-fw.sh b/infra/firewall/pw-docker-fw.sh new file mode 100644 index 0000000..fd62d66 --- /dev/null +++ b/infra/firewall/pw-docker-fw.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Block external (internet) access to Docker-published container ports. +# Host nginx reaches containers over loopback (127.0.0.1), so dropping NEW +# inbound from the public uplink (ens18) into the Docker FORWARD path closes +# the accidental 0.0.0.0 exposure (postgres 5432, forgejo 3022, listmonk +# 9100/9101, api 3001/3002, etc.) without breaking nginx->container or +# container->container/internet traffic. +set -euo pipefail +UPLINK=ens18 + +# Trusted admin source IPs allowed to reach the forgejo container (host :3022 +# DNATs to 172.18.0.2:22, so the post-DNAT dport is 22). Keep in sync with the +# nft 'trusted_admin' set in /etc/pw-firewall/pw-firewall.nft. +TRUSTED_ADMIN="76.228.206.147" + +# Rebuild DOCKER-USER deterministically. +iptables -F DOCKER-USER 2>/dev/null || true +iptables -A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN +# Allow trusted admins to git/forgejo (post-DNAT dport 22) before the drop. +for ip in $TRUSTED_ADMIN; do + iptables -A DOCKER-USER -i "$UPLINK" -s "$ip" -p tcp --dport 22 -j RETURN +done +iptables -A DOCKER-USER -i "$UPLINK" -m conntrack --ctstate NEW,INVALID -j DROP +iptables -A DOCKER-USER -j RETURN +echo "DOCKER-USER rules:" +iptables -L DOCKER-USER -n -v --line-numbers diff --git a/infra/firewall/pw-firewall.nft b/infra/firewall/pw-firewall.nft new file mode 100644 index 0000000..f7b9338 --- /dev/null +++ b/infra/firewall/pw-firewall.nft @@ -0,0 +1,55 @@ +#!/usr/sbin/nft -f +# Performance West host firewall. +# Goal: expose ONLY public-facing ports to the internet; keep all container / +# k3s / loopback traffic fully open so nothing internal breaks. +# +# Dedicated table (pw_fw) with a high-priority (-150) input hook so it is +# evaluated BEFORE kube-router's ACCEPT chain in the standard 'filter' table. +# fail2ban (f2b-table) stays untouched. + +table inet pw_fw +delete table inet pw_fw + +table inet pw_fw { + set pub_tcp { + type inet_service + # Public-facing ports only. + elements = { 22, 22022, 80, 443 } + # NB: 22 kept too in case provider/console uses it; SSH is on 22022. + } + + # Trusted admin source IPs allowed to reach git/forgejo (3022) and other + # non-public admin ports. Update with: nft add element inet pw_fw trusted_admin { } + set trusted_admin { + type ipv4_addr + flags interval + elements = { 76.228.206.147 } + } + + chain input { + type filter hook input priority -150; policy accept; + + # 1) Always allow loopback + already-established flows. + iif "lo" accept + ct state established,related accept + ct state invalid drop + + # 2) Fully trust internal networks (docker bridges, flannel pod/service, + # cni0). Containers and k3s must keep talking unrestricted. + ip saddr { 127.0.0.0/8, 172.16.0.0/12, 10.42.0.0/16, 10.43.0.0/16 } accept + iifname { "docker0", "br-*", "cni0", "flannel.1", "kube-*", "veth*", "cilium*" } accept + + # 3) Allow ICMP (ping / path MTU) - safe and useful for diagnostics. + ip protocol icmp accept + ip6 nexthdr icmpv6 accept + + # 4) Trusted-admin allow-list for git/forgejo SSH (3022). + ip saddr @trusted_admin tcp dport 3022 accept + + # 5) Public allow-list (new inbound from the internet). + tcp dport @pub_tcp accept + + # 6) Everything else arriving on the public uplink is dropped. + iifname "ens18" drop + } +} diff --git a/infra/firewall/pw-firewall.service b/infra/firewall/pw-firewall.service new file mode 100644 index 0000000..08f7a12 --- /dev/null +++ b/infra/firewall/pw-firewall.service @@ -0,0 +1,15 @@ +[Unit] +Description=Performance West host firewall (nft input + DOCKER-USER egress-only) +After=docker.service nftables.service network-online.target +Wants=network-online.target +Requires=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/sbin/nft -f /etc/pw-firewall/pw-firewall.nft +ExecStart=/usr/local/sbin/pw-docker-fw.sh +ExecReload=/usr/sbin/nft -f /etc/pw-firewall/pw-firewall.nft + +[Install] +WantedBy=multi-user.target