From ace03d2340f3774c48a5feaa509012311b7d3d54 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 24 Apr 2026 15:50:47 -0500 Subject: [PATCH] Security hardening: TLS at origin, security headers, network policies, admin probe fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four related hardening changes made on the live cluster during this session. Each manifest captures the final working state so a fresh `kubectl apply` of the repo reproduces it. 1. Cloudflare Full (strict) TLS — ingresses now carry `tls:` blocks pointing at `cloudflare-origin-cert` secret (installed imperatively from the CF Origin CA PEM). CF SSL mode flipped from Flexible to Full (strict). CF↔origin is now HTTPS; origin serves a CF-issued cert that only CF can validate. 2. Traefik middleware attached to all three ingresses — `rate-limit` (100/min avg, 200 burst) and `security-headers` (frame-deny, nosniff, HSTS, referrer policy, permissions policy). `admin-auth` middleware was also defined in middleware.yaml but is not attached (needs an unset basic-auth secret) and was deleted at runtime. 3. `security-headers` middleware: stripped the Content-Security-Policy entry. The Go API sets its own CSP in internal/router/router.go that permits Google Fonts for the landing page. Two CSP headers combine via intersection (most restrictive wins), which would break the landing page. Next.js apps set their own CSP via middleware. Header kept documentation comments explain this. 4. NetworkPolicies — default-deny + explicit allows, applied. Added missing policies for `web`. Corrected the Traefik ingress rule: the scaffold used `namespaceSelector: kube-system`, but our Traefik runs as a DaemonSet with `hostNetwork: true`, so traffic arrives with the NODE IP as source. Fixed to an `ipBlock` list of the three node IPs plus the cluster pod CIDR (10.42.0.0/16). 5. admin livenessProbe path fix: was hitting /admin/ (404) which caused a 6-hour crashloop cycle (87 restarts) before the bug was caught. Fixed to / — matches the startupProbe and readinessProbe paths that were corrected earlier. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy-k3s/manifests/admin/deployment.yaml | 2 +- .../manifests/ingress/ingress-simple.yaml | 30 +++++-- deploy-k3s/manifests/ingress/middleware.yaml | 5 +- deploy-k3s/manifests/network-policies.yaml | 87 +++++++++++++++++-- 4 files changed, 110 insertions(+), 14 deletions(-) diff --git a/deploy-k3s/manifests/admin/deployment.yaml b/deploy-k3s/manifests/admin/deployment.yaml index 53f18ec..7f70c5f 100644 --- a/deploy-k3s/manifests/admin/deployment.yaml +++ b/deploy-k3s/manifests/admin/deployment.yaml @@ -82,7 +82,7 @@ spec: timeoutSeconds: 5 livenessProbe: httpGet: - path: /admin/ + path: / port: 3000 initialDelaySeconds: 30 periodSeconds: 30 diff --git a/deploy-k3s/manifests/ingress/ingress-simple.yaml b/deploy-k3s/manifests/ingress/ingress-simple.yaml index f9c6c6f..a44ee62 100644 --- a/deploy-k3s/manifests/ingress/ingress-simple.yaml +++ b/deploy-k3s/manifests/ingress/ingress-simple.yaml @@ -1,11 +1,10 @@ -# Simple hostname-based Ingress — no TLS (Cloudflare Flexible handles edge -# TLS, CF→origin is plain HTTP on 80). Upgrade to Full (strict) by -# adding back a `tls:` block with a Cloudflare Origin CA cert stored in -# secret/cloudflare-origin-cert. +# Hostname-based Ingress with TLS terminated at Traefik using the +# Cloudflare Origin CA cert (secret/cloudflare-origin-cert). CF→origin +# encryption enables CF SSL mode "Full (strict)". # # Middleware chain (security headers, rate limit, CF-only allowlist, admin -# basic auth) is defined in `middleware.yaml` but NOT attached here — -# annotate this ingress to turn any of them on. +# basic auth) is defined in `middleware.yaml`. security-headers + rate-limit +# are attached below via annotation. apiVersion: networking.k8s.io/v1 kind: Ingress metadata: @@ -13,8 +12,15 @@ metadata: namespace: honeydue labels: app.kubernetes.io/part-of: honeydue + annotations: + traefik.ingress.kubernetes.io/router.middlewares: honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd spec: ingressClassName: traefik + tls: + - hosts: + - api.myhoneydue.com + - myhoneydue.com + secretName: cloudflare-origin-cert rules: - host: api.myhoneydue.com http: @@ -46,8 +52,14 @@ metadata: namespace: honeydue labels: app.kubernetes.io/part-of: honeydue + annotations: + traefik.ingress.kubernetes.io/router.middlewares: honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd spec: ingressClassName: traefik + tls: + - hosts: + - admin.myhoneydue.com + secretName: cloudflare-origin-cert rules: - host: admin.myhoneydue.com http: @@ -67,8 +79,14 @@ metadata: namespace: honeydue labels: app.kubernetes.io/part-of: honeydue + annotations: + traefik.ingress.kubernetes.io/router.middlewares: honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd spec: ingressClassName: traefik + tls: + - hosts: + - app.myhoneydue.com + secretName: cloudflare-origin-cert rules: - host: app.myhoneydue.com http: diff --git a/deploy-k3s/manifests/ingress/middleware.yaml b/deploy-k3s/manifests/ingress/middleware.yaml index 5ea56b3..67b87d6 100644 --- a/deploy-k3s/manifests/ingress/middleware.yaml +++ b/deploy-k3s/manifests/ingress/middleware.yaml @@ -27,7 +27,10 @@ spec: X-Content-Type-Options: "nosniff" X-Frame-Options: "DENY" Strict-Transport-Security: "max-age=31536000; includeSubDomains" - Content-Security-Policy: "default-src 'self'; frame-ancestors 'none'" + # Content-Security-Policy is intentionally NOT set here — the Go API + # sets a CSP in internal/router/router.go that permits Google Fonts + # for the landing page. Two CSP headers would intersect and break it. + # admin and web apps set their own CSP via Next.js middleware. Permissions-Policy: "camera=(), microphone=(), geolocation=()" X-Permitted-Cross-Domain-Policies: "none" diff --git a/deploy-k3s/manifests/network-policies.yaml b/deploy-k3s/manifests/network-policies.yaml index cf5e08c..afb9e25 100644 --- a/deploy-k3s/manifests/network-policies.yaml +++ b/deploy-k3s/manifests/network-policies.yaml @@ -47,10 +47,19 @@ spec: policyTypes: - Ingress ingress: + # Traefik runs as DaemonSet with hostNetwork=true, so traffic from it + # arrives with the NODE IP as source (not a pod IP). The node pod CIDR + # 10.42.0.0/16 covers any intra-cluster caller; the three node IPs + # cover Traefik on hostNetwork. - from: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: kube-system + - ipBlock: + cidr: 178.105.32.198/32 # ubuntu-8gb-nbg1-1 + - ipBlock: + cidr: 178.104.247.152/32 # ubuntu-8gb-nbg1-2 + - ipBlock: + cidr: 178.104.249.189/32 # ubuntu-8gb-nbg1-3 + - ipBlock: + cidr: 10.42.0.0/16 # cluster pod CIDR ports: - protocol: TCP port: 8000 @@ -69,10 +78,17 @@ spec: policyTypes: - Ingress ingress: + # Traefik runs as DaemonSet with hostNetwork=true — see allow-ingress-to-api + # for the rationale. Same ipBlock list. - from: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: kube-system + - ipBlock: + cidr: 178.105.32.198/32 + - ipBlock: + cidr: 178.104.247.152/32 + - ipBlock: + cidr: 178.104.249.189/32 + - ipBlock: + cidr: 10.42.0.0/16 ports: - protocol: TCP port: 3000 @@ -200,3 +216,62 @@ spec: ports: - protocol: TCP port: 8000 + +--- +# --- Web: allow ingress from Traefik (kube-system namespace) --- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-ingress-to-web + namespace: honeydue +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: web + policyTypes: + - Ingress + ingress: + # Traefik runs as DaemonSet with hostNetwork=true — see allow-ingress-to-api + # for the rationale. Same ipBlock list. + - from: + - ipBlock: + cidr: 178.105.32.198/32 + - ipBlock: + cidr: 178.104.247.152/32 + - ipBlock: + cidr: 178.104.249.189/32 + - ipBlock: + cidr: 10.42.0.0/16 + ports: + - protocol: TCP + port: 3000 + +--- +# --- Web: allow egress for the Next.js server-side proxy routes --- +# Browser → app.myhoneydue.com → web pod (Node.js) → api.myhoneydue.com +# The web pod resolves api.myhoneydue.com via public DNS and hits +# Cloudflare (143.). We don't know which CF IP yet at policy time, so +# allow HTTPS to public ipBlock (except private CIDRs). +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-egress-from-web + namespace: honeydue +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: web + policyTypes: + - Egress + egress: + # HTTPS to public (api.myhoneydue.com via CF, PostHog, any other remote) + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + ports: + - protocol: TCP + port: 443