From fe0aa89ffad58f004a9be87f759f1c378347fe81 Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Tue, 7 Jul 2026 22:58:24 +0200 Subject: [PATCH] feat(security): durcissement edge funklab.online (vraie IP client + rate-limit + TLS/headers) (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Traefik externalTrafficPolicy: Local → IP client réelle (fin du SNAT kube-proxy) - middleware ratelimit par IP (100/min, burst 200) sur les 6 routes publiques - middleware security-headers (HSTS, nosniff, X-Frame SAMEORIGIN, Referrer-Policy) - TLSOption globale : plancher TLS 1.2 + ciphers modernes - Guacamole REMOTE_IP_VALVE → ban anti-bruteforce par IP (fin du piège n°5) - nftables : fermeture des ports Internet legacy 9080/9081 (HTTP clair redondant) - allowCrossNamespace pour mutualiser les middlewares depuis infra - docs : section Durcissement (public-domain.md) + CAA OVH + pièges à jour Co-authored-by: Claude Opus 4.8 --- admin/k8s/auth-portal.md | 16 ++++-- admin/k8s/public-domain.md | 51 ++++++++++++++++++- .../roles/gateway/templates/nftables.conf.j2 | 20 +++----- k8s/apps/authentik/ingress-public.yaml | 5 ++ k8s/apps/guacamole/deployment.yaml | 8 +++ k8s/apps/guacamole/ingress-public.yaml | 5 ++ k8s/apps/n8n/ingress-public.yaml | 5 ++ k8s/apps/open-webui/ingress-public.yaml | 5 ++ k8s/argocd-bootstrap/ingressroute-public.yaml | 5 ++ k8s/infra/monitoring/ingressroute-public.yaml | 5 ++ k8s/infra/traefik/middlewares.yaml | 42 +++++++++++++++ k8s/infra/traefik/tls-options.yaml | 19 +++++++ k8s/infra/traefik/values.yaml | 11 ++++ 13 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 k8s/infra/traefik/middlewares.yaml create mode 100644 k8s/infra/traefik/tls-options.yaml diff --git a/admin/k8s/auth-portal.md b/admin/k8s/auth-portal.md index f016ba3..28783d5 100644 --- a/admin/k8s/auth-portal.md +++ b/admin/k8s/auth-portal.md @@ -117,8 +117,10 @@ Config côté Authentik (UI) — Redirect URI par app : voir le tableau de ## Pièges / notes -1. **Tout est en HTTP** (pas de TLS) — accepté y compris pour l'accès Internet - (cf. section ci-dessus), avec garde-fous réseau + mots de passe dédiés. +1. **HTTPS sur Internet, HTTP en interne** : l'accès public passe par + `*.funklab.online` en **TLS Let's Encrypt** (durcissement 2026-07-07) ; les routes + internes `*.lab.local` restent en HTTP clair (LAN de confiance). L'issuer OIDC est + donc le domaine HTTPS (cf. piège n°2). 2. **JWKS via le Service interne** (`authentik.auth.svc.cluster.local:9000`) : l'appel serveur→serveur de Guacamole ne dépend pas de la résolution `lab.local` des pods. L'issuer, lui, reste `http://auth.lab.local/...` (doit matcher le claim `iss` des @@ -132,9 +134,13 @@ Config côté Authentik (UI) — Redirect URI par app : voir le tableau de le génère depuis le nom (« Portail consoles » → `portail-consoles`) et l'issuer/JWKS deviennent faux → log Guacamole `Non 200 status code (404) ... /o/guacamole/jwks/` et token rejeté. Corriger : Applications → Edit → Slug. -5. **Anti-bruteforce intégré** (extension `ban`) : 5 échecs → IP bloquée ~5 min. Derrière - Traefik, l'IP vue est celle du proxy → **le blocage touche tout le monde**. Patienter - ou redémarrer le pod guacamole pour purger. +5. **Anti-bruteforce intégré** (extension `ban`) : 5 échecs → IP bloquée ~5 min. + ✅ **Corrigé (2026-07-07)** : `REMOTE_IP_VALVE_ENABLED=true` (deployment) + Traefik en + `externalTrafficPolicy: Local` → Guacamole lit l'IP client réelle (X-Forwarded-For), le + ban est **par IP** et non plus global. Avant ce fix, l'IP vue était celle du pod Traefik + → le blocage touchait tout le monde. Si un ban global réapparaît : vérifier que la valve + est active et que Traefik n'est pas repassé en `Cluster`. Purge immédiate : redémarrer le + pod guacamole. 6. **User `console` sans sudo** (volontaire) : le portail sert à observer/opérer léger. L'administration passe par les comptes admin habituels, pas par Guacamole. 7. **Enregistrement des sessions** : possible (guacd + volume partagé) mais non configuré diff --git a/admin/k8s/public-domain.md b/admin/k8s/public-domain.md index 78e7a8b..3aabc41 100644 --- a/admin/k8s/public-domain.md +++ b/admin/k8s/public-domain.md @@ -20,8 +20,10 @@ Internet ─▶ grafana.funklab.online ─┐ DNS OVH : *.funklab.online → 82 - **Split-horizon** : dnsmasq résout `*.funklab.online → Traefik (192.168.10.200)` en interne → le LAN utilise les **mêmes URLs** sans sortir sur Internet. -- **Ports historiques conservés** : le hub reste aussi joignable en `kaya.freeboxos.fr:9081/9080` - (bascule non destructive). +- **Ports historiques `9080/9081` fermés côté Internet** (durcissement 2026-07-07) : + l'accès direct HTTP clair à Authentik/Guacamole n'existe plus depuis le WAN — tout + passe par le domaine en HTTPS. Les VIPs `.201/.202` restent joignables en LAN. + (Penser à retirer aussi les redirections Freebox `9080/9081`.) ## Prérequis (une fois, côté fournisseurs) @@ -119,6 +121,51 @@ Manifests : `k8s/apps/*/ingress-public.yaml`, `k8s/infra/monitoring/ingressroute Détail de l'ajout d'une app au hub : `admin/k8s/auth-portal-admin.md`. +## Durcissement (edge) — état 2026-07-07 + +Couche de sécurité appliquée à toutes les routes `funklab.online`. Manifests : +`k8s/infra/traefik/{values.yaml,middlewares.yaml,tls-options.yaml}` (Traefik **hors +ArgoCD** → `kubectl apply` + `helm upgrade`). + +| Mesure | Où | Effet | +|---|---|---| +| **IP client réelle** | `service.spec.externalTrafficPolicy: Local` (values.yaml) | Traefik voit l'IP source réelle (sinon SNAT kube-proxy → IP d'un nœud). Prérequis du rate-limit par IP et du ban Guacamole par IP. | +| **Rate-limit par IP** | Middleware `ratelimit` (infra) sur chaque route | ~100 req/min soutenu, burst 200, **par IP client**. Anti-scan/bruteforce volumétrique. | +| **En-têtes sécurité** | Middleware `security-headers` (infra) | HSTS 1 an+subdomains, `nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy`. | +| **TLS ≥ 1.2** | `TLSOption` globale `default/default` | Plancher TLS 1.2 + ciphers modernes (TLS 1.3 auto). | +| **Ban Guacamole par IP** | `REMOTE_IP_VALVE_ENABLED` (deployment guacamole) | Le ban 5-échecs vise l'IP réelle, plus tout le monde (fin du piège n°5). | +| **Ports 9080/9081 fermés (WAN)** | rôle `gateway` (nftables) | Supprime le chemin HTTP clair direct vers Authentik/Guacamole. | + +> Les middlewares du namespace `infra` sont référencés cross-namespace → +> `providers.kubernetesCRD.allowCrossNamespace: true` requis (values.yaml). + +**Cross-check DNS (à faire côté OVH)** : ajouter un enregistrement **CAA** pour +restreindre l'émission de certificats à Let's Encrypt uniquement : +``` +funklab.online. CAA 0 issue "letsencrypt.org" +funklab.online. CAA 0 iodef "mailto:aliyesilkaya93@gmail.com" +``` + +**Application** (Traefik est hors ArgoCD) : +```bash +helm upgrade traefik traefik/traefik -n infra --version 40.1.0 \ + -f k8s/infra/traefik/values.yaml # externalTrafficPolicy + allowCrossNamespace +kubectl apply -f k8s/infra/traefik/middlewares.yaml +kubectl apply -f k8s/infra/traefik/tls-options.yaml +kubectl apply -f k8s/argocd-bootstrap/ingressroute-public.yaml # ArgoCD non auto-sync +ansible-playbook -i inventory.yml playbooks/storage-01.yml --tags gateway # ports legacy +``` +Les IngressRoutes des apps `ai/auth/monitoring` sont synchronisées par ArgoCD (merge main). + +**Vérifier** : +```bash +curl -sI https://auth.funklab.online | grep -i 'strict-transport\|x-frame' +nmap --script ssl-enum-ciphers -p 443 auth.funklab.online | grep -i tlsv1 +``` + +**Pistes suivantes** (non faites) : MFA/TOTP Authentik (protège tout le hub), +CrowdSec bouncer Traefik (réputation IP), restreindre ArgoCD au LAN+VPN (IPAllowList). + ## Pièges 1. **Cert bloqué en « TRAEFIK DEFAULT CERT »** : DNS pas encore propagé ou Freebox diff --git a/ansible/roles/gateway/templates/nftables.conf.j2 b/ansible/roles/gateway/templates/nftables.conf.j2 index 457fa0c..949275a 100644 --- a/ansible/roles/gateway/templates/nftables.conf.j2 +++ b/ansible/roles/gateway/templates/nftables.conf.j2 @@ -77,14 +77,12 @@ table inet filter { ip daddr 192.168.10.20 tcp dport { 7777, 8888 } ct state new,established,related accept ip daddr 192.168.10.20 udp dport 7777 ct state new,established,related accept - # Hub public Authentik (:9081) + portail Guacamole (:9080) — trafic Internet - # DNATé vers les VIPs MetalLB dédiées. Rate-limit sur les connexions neuves : - # anti-bruteforce/scan réseau, en plus des protections applicatives. Le LAN - # domestique n'est pas concerné (règle 192.168.1.0/24 plus haut, hairpin - # Freebox inclus). - ip daddr 192.168.10.201 tcp dport 9000 ct state new limit rate 30/minute burst 15 packets accept - ip daddr 192.168.10.202 tcp dport 8080 ct state new limit rate 30/minute burst 15 packets accept - ip daddr { 192.168.10.201, 192.168.10.202 } ct state established,related accept + # NOTE : les anciens chemins Internet :9081 (Authentik direct) / :9080 (Guacamole + # direct), en HTTP clair vers les VIPs .201/.202, ont été FERMÉS au durcissement + # (2026-07-07). Tout passe désormais par funklab.online en HTTPS (ci-dessous). + # Les VIPs restent joignables en interne (LAN) ; seul l'accès Internet est retiré. + # Penser à supprimer aussi les redirections Freebox 9080/9081 (elles pointent ici + # dans le vide). Rétablir l'accès : re-DNAT ci-dessous + accept forward. # Domaine public funklab.online — Internet DNATé (80/443) → Traefik (.200). # Routage par sous-domaine + TLS Let's Encrypt côté Traefik. Rate-limit idem. @@ -118,10 +116,8 @@ table ip nat { iif {{ wan_interface }} tcp dport { 7777, 8888 } dnat to 192.168.10.20 iif {{ wan_interface }} udp dport 7777 dnat to 192.168.10.20:7777 - # Hub Authentik + portail Guacamole publics (redirections Freebox → s01) - # kaya.freeboxos.fr:9081 → Authentik (VIP .201) ; :9080 → Guacamole (VIP .202) - iif {{ wan_interface }} tcp dport 9081 dnat to 192.168.10.201:9000 - iif {{ wan_interface }} tcp dport 9080 dnat to 192.168.10.202:8080 + # (Anciens DNAT Internet :9081→Authentik / :9080→Guacamole retirés au + # durcissement 2026-07-07 — tout passe par funklab.online HTTPS ci-dessous.) # funklab.online — Freebox 80/443 → Traefik (routage par sous-domaine + TLS) iif {{ wan_interface }} tcp dport { 80, 443 } dnat to 192.168.10.200 diff --git a/k8s/apps/authentik/ingress-public.yaml b/k8s/apps/authentik/ingress-public.yaml index 232ef89..c129726 100644 --- a/k8s/apps/authentik/ingress-public.yaml +++ b/k8s/apps/authentik/ingress-public.yaml @@ -11,6 +11,11 @@ spec: routes: - match: Host(`auth.funklab.online`) kind: Rule + middlewares: + - name: security-headers + namespace: infra + - name: ratelimit + namespace: infra services: - name: authentik port: 9000 diff --git a/k8s/apps/guacamole/deployment.yaml b/k8s/apps/guacamole/deployment.yaml index c99364e..e39347d 100644 --- a/k8s/apps/guacamole/deployment.yaml +++ b/k8s/apps/guacamole/deployment.yaml @@ -65,6 +65,14 @@ spec: # system » (Alkatrazz). Dépannage sans Authentik : "*, openid" + re-sync. - name: EXTENSION_PRIORITY value: "openid, *" + # Derrière Traefik : lire l'IP client réelle dans X-Forwarded-For (Traefik + # la connaît grâce à externalTrafficPolicy: Local). Sans ça, l'anti-bruteforce + # voit l'IP du pod Traefik → un ban bloque TOUT LE MONDE (piège n°5). Ici le + # ban devient par IP. PROXY_ALLOWED_IPS = CIDR pods k8s (source = Traefik). + - name: REMOTE_IP_VALVE_ENABLED + value: "true" + - name: PROXY_ALLOWED_IPS + value: "10.42.0.0/16" - name: TZ value: "Europe/Paris" resources: diff --git a/k8s/apps/guacamole/ingress-public.yaml b/k8s/apps/guacamole/ingress-public.yaml index b017719..6927cfb 100644 --- a/k8s/apps/guacamole/ingress-public.yaml +++ b/k8s/apps/guacamole/ingress-public.yaml @@ -11,6 +11,11 @@ spec: routes: - match: Host(`portail.funklab.online`) kind: Rule + middlewares: + - name: security-headers + namespace: infra + - name: ratelimit + namespace: infra services: - name: guacamole port: 8080 diff --git a/k8s/apps/n8n/ingress-public.yaml b/k8s/apps/n8n/ingress-public.yaml index a563558..14dcbf7 100644 --- a/k8s/apps/n8n/ingress-public.yaml +++ b/k8s/apps/n8n/ingress-public.yaml @@ -12,6 +12,11 @@ spec: routes: - match: Host(`n8n.funklab.online`) kind: Rule + middlewares: + - name: security-headers + namespace: infra + - name: ratelimit + namespace: infra services: - name: n8n port: 5678 diff --git a/k8s/apps/open-webui/ingress-public.yaml b/k8s/apps/open-webui/ingress-public.yaml index c4bad99..6e7f9e3 100644 --- a/k8s/apps/open-webui/ingress-public.yaml +++ b/k8s/apps/open-webui/ingress-public.yaml @@ -11,6 +11,11 @@ spec: routes: - match: Host(`openwebui.funklab.online`) kind: Rule + middlewares: + - name: security-headers + namespace: infra + - name: ratelimit + namespace: infra services: - name: open-webui port: 8080 diff --git a/k8s/argocd-bootstrap/ingressroute-public.yaml b/k8s/argocd-bootstrap/ingressroute-public.yaml index a5b44e1..319071c 100644 --- a/k8s/argocd-bootstrap/ingressroute-public.yaml +++ b/k8s/argocd-bootstrap/ingressroute-public.yaml @@ -13,6 +13,11 @@ spec: routes: - match: Host(`argocd.funklab.online`) kind: Rule + middlewares: + - name: security-headers + namespace: infra + - name: ratelimit + namespace: infra services: - name: argocd-server port: 80 diff --git a/k8s/infra/monitoring/ingressroute-public.yaml b/k8s/infra/monitoring/ingressroute-public.yaml index af098e4..d351cd6 100644 --- a/k8s/infra/monitoring/ingressroute-public.yaml +++ b/k8s/infra/monitoring/ingressroute-public.yaml @@ -11,6 +11,11 @@ spec: routes: - match: Host(`grafana.funklab.online`) kind: Rule + middlewares: + - name: security-headers + namespace: infra + - name: ratelimit + namespace: infra services: - name: kube-prometheus-stack-grafana port: 80 diff --git a/k8s/infra/traefik/middlewares.yaml b/k8s/infra/traefik/middlewares.yaml new file mode 100644 index 0000000..a050c65 --- /dev/null +++ b/k8s/infra/traefik/middlewares.yaml @@ -0,0 +1,42 @@ +# Middlewares mutualisés pour les routes publiques funklab.online. +# Traefik est HORS ArgoCD → appliquer à la main (comme redirect.yaml) : +# kubectl apply -f k8s/infra/traefik/middlewares.yaml +# Référencés depuis les IngressRoutes des apps via { name: , namespace: infra } +# (nécessite providers.kubernetesCRD.allowCrossNamespace: true dans values.yaml). +--- +# Rate-limit par IP client réelle (dépend de externalTrafficPolicy: Local sur le +# service Traefik). Bucket à jetons : ~100 req/min soutenu, pointe (1er chargement +# d'assets) jusqu'à 200. Anti-scan / anti-bruteforce volumétrique en bord ; la +# protection fine du login reste côté Authentik/Guacamole. Ajustable si 429 gênants. +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: ratelimit + namespace: infra +spec: + rateLimit: + average: 100 + period: 1m + burst: 200 + # sourceCriterion par défaut = IP distante (remoteAddr) = IP client réelle + # grâce à externalTrafficPolicy: Local. Pas de depth XFF (aucun proxy amont + # de confiance entre Internet et Traefik : Freebox + DNAT sont en L4). +--- +# En-têtes de sécurité HTTP pour les apps publiques. Attaché uniquement aux routes +# funklab.online (pas aux routes internes *.lab.local en HTTP). +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: security-headers + namespace: infra +spec: + headers: + # HSTS 1 an + sous-domaines (tout *.funklab.online est en HTTPS). Pas de preload + # (n'engage pas l'apex sur les listes HSTS des navigateurs, réversible). + stsSeconds: 31536000 + stsIncludeSubdomains: true + contentTypeNosniff: true + browserXssFilter: true + referrerPolicy: strict-origin-when-cross-origin + # SAMEORIGIN (pas DENY) : Grafana embarque ses propres panels en iframe. + customFrameOptionsValue: SAMEORIGIN diff --git a/k8s/infra/traefik/tls-options.yaml b/k8s/infra/traefik/tls-options.yaml new file mode 100644 index 0000000..f80a1de --- /dev/null +++ b/k8s/infra/traefik/tls-options.yaml @@ -0,0 +1,19 @@ +# TLSOption globale (nom `default` dans le namespace `default` = appliquée à toutes +# les routes TLS qui n'en spécifient pas). Traefik est HORS ArgoCD → à la main : +# kubectl apply -f k8s/infra/traefik/tls-options.yaml +# Élève le plancher à TLS 1.2 et fixe des ciphers modernes (TLS 1.3 auto, non +# configurable). Vérifier ensuite : nmap --script ssl-enum-ciphers -p 443 . +apiVersion: traefik.io/v1alpha1 +kind: TLSOption +metadata: + name: default + namespace: default +spec: + minVersion: VersionTLS12 + cipherSuites: + - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 + - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 diff --git a/k8s/infra/traefik/values.yaml b/k8s/infra/traefik/values.yaml index 64cd3b4..1ef4698 100644 --- a/k8s/infra/traefik/values.yaml +++ b/k8s/infra/traefik/values.yaml @@ -8,9 +8,20 @@ service: type: LoadBalancer loadBalancerIP: 192.168.10.200 # VIP MetalLB — NE PAS changer + # Préserve l'IP client réelle jusqu'à Traefik (sinon kube-proxy SNAT en Cluster → + # Traefik voit l'IP d'un nœud). Requis pour le rate-limit par IP (middleware + # ratelimit) et le X-Forwarded-For propagé aux apps (ban Guacamole par IP). + # MetalLB L2 : la VIP n'est annoncée que depuis un nœud portant un pod Traefik prêt. + spec: + externalTrafficPolicy: Local ingressRoute: dashboard: enabled: true +# Autorise les IngressRoutes (auth/ai/monitoring/argocd) à référencer les middlewares +# mutualisés du namespace infra (ratelimit, security-headers). Défaut Traefik v3 = false. +providers: + kubernetesCRD: + allowCrossNamespace: true logs: general: level: INFO