From 798e748a1fff3f00221978d0295520b6e8d807ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=2E=20Nam=C3=A8che?= Date: Mon, 2 Feb 2026 22:06:28 +0300 Subject: [PATCH 1/5] README: WIP --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index dae6133..04a20d2 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,6 @@ A WAF _à la carte_. +**WARNING: This is still a work in process** + See [seb4itik/byow/README.md](seb4itik/byow/README.md) From cbef8530f00080941450ea2e3969f38f664fd2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=2E=20Nam=C3=A8che?= Date: Mon, 2 Feb 2026 22:06:56 +0300 Subject: [PATCH 2/5] example: fix bug LDP --- example/ansible/play-main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/ansible/play-main.yml b/example/ansible/play-main.yml index 73d02dc..a934828 100644 --- a/example/ansible/play-main.yml +++ b/example/ansible/play-main.yml @@ -100,7 +100,7 @@ stub_to: "127.0.0.1@8600" caddy_sites: "{{ common.sites }}" ovh_ldp_cluster: "gra2.logs.ovh.com" - ovh_ldp_token: ¨{{ secrets.ovh_ldp.token }} + ovh_ldp_token: "{{ secrets.ovh_ldp.token }}" roles: - "local_ca_certs" - "consul" From ccdabff0bec7b71e80965094f6e9a180e6cfeebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=2E=20Nam=C3=A8che?= Date: Mon, 2 Feb 2026 22:07:56 +0300 Subject: [PATCH 3/5] README --- seb4itik/byow/README.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/seb4itik/byow/README.md b/seb4itik/byow/README.md index 76287e9..019dbf3 100644 --- a/seb4itik/byow/README.md +++ b/seb4itik/byow/README.md @@ -69,7 +69,7 @@ Voir: https://github.com/coreruleset/plugin-registry Fonctionnalités déjà implémentées: -- Déploiement et configuration avec Ansible +- Déploiement et configuration avec une collection Ansible - Serveurs transient - VIP (avec Keepalived) - DNS dynamique (via Nomad) @@ -105,18 +105,19 @@ Fonctionnalités déjà implémentées: - Collecte des logs via OVH LDP - Whitelist - Static files et FastCGI pour PHP (NON, par design !) +- Rate limit (non distribué) Urgemment: - Connexion à consul, ACL consul -- Collection Ansible -- Revérifier gestion CORS - Active backend checks - Correction de la collecte des logs via OVH LDP +- Faire fonctionner le filtre dict2str via la collection Ansible -Fomctionnalités à ajouter: +Fonctionnalités à ajouter: - Rate limiting distribué +- Revérifier gestion CORS - Antibot par: - Cookie - Captcha @@ -156,10 +157,6 @@ Fomctionnalités à ajouter: - [Documentation AWOSP CRS](https://coreruleset.org/docs/) - https://github.com/owasp-modsecurity/ModSecurity/wiki/Reference-Manual-(v3.x) -Modules -- []() -- []() - ### Installation d'Ansible avec pipx From 7e4857f762e6abb63cf3041508071febf1407bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=2E=20Nam=C3=A8che?= Date: Mon, 2 Feb 2026 22:08:27 +0300 Subject: [PATCH 4/5] dic2str: doc --- seb4itik/byow/plugins/filter/dic2str.py | 90 ++++++++++++++----------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/seb4itik/byow/plugins/filter/dic2str.py b/seb4itik/byow/plugins/filter/dic2str.py index 05e1e2c..70b35bc 100644 --- a/seb4itik/byow/plugins/filter/dic2str.py +++ b/seb4itik/byow/plugins/filter/dic2str.py @@ -1,51 +1,59 @@ #!/usr/bin/python + +DOCUMENTATION = r""" + name: dict2str + short_description: This filter transforms a flat dict into a string using 4 separators. + version_added: "0.1.1" + author: Sébastien Namèche (@seb4itik) + description: + - If my_dict is {one: "un", two: "deux", three: "trois"}, then: + - {{ my_dict | dict2str(sep1=' is ', sep2=', ', sep3='.') }} + - will produce: + - "one is un, two is deux, three is trois." + options: + _input: + description: A flat dict. + type: dict + elements: str, int, bool + required: true + sep0: + description: Unique initial separator at the beginning of string. + type: str + default: "" + sep1: + description: Separator between each key and value pairs of the dict. + type: str + default: "=" + sep2: + description: Separator between key/value pairs of the dict that has been combined with sep1. + type: str + default: ";" + sep3: + description: Unique final separator at the end of string. + type: str + default: "" + notes: + - If a value associated to a key of the dict is None or an empty string, + - sep2 will not be used. +""" + + +RETURN = r""" + _value: + type: str + description: + - String representating the dict using provided separators. +""" + + class FilterModule(object): def filters(self): return { 'dict2str': self.dict2str } - def dict2str(self, d, sep1='=', sep2=';', sep3=''): - return sep2.join( + def dict2str(self, d, sep0='', sep1='=', sep2=';', sep3=''): + return sep0 + sep2.join( [ str(k) + (sep1+str(v) if v!=None and v!='' else '') for k, v in d.items() ] ) + sep3 - - -DOCUMENTATION = """ - name: dict2str - author: Sébastien Namèche (@seb4itik) - version_added: "0.1.1" - short_description: This filter transforms a flat dict into a string using 3 separators. - description: - - If my_dict is {one: "un", two: "deux", three: "trois"}, then: - - {{ my_dict | dict2str(sep1=' is ', sep2=', ', sep3='.') }} - - will produce: - - "one is un, two is deux, three is trois." - options: - sep1: - description: - - separator between each key and value pairs of the dict - type: str - default: "=" - sep2: - description: - - separator between key/value pairs of the dict that has been combined with sep1 - type: str - default: ";" - sep3: - description: - - unique final separator at the end of string - type: str - default: "" - notes: - - If a value associated to a key of the dict is None or an empty string, - - sep2 will not be used. -""" - -RETURN = """ - data: - type: str - description: - - String representating the dict using provided separators. -""" \ No newline at end of file From d7b6e468d722941c4260756f10c16d1a640f6b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=2E=20Nam=C3=A8che?= Date: Mon, 2 Feb 2026 22:09:18 +0300 Subject: [PATCH 5/5] caddy: CORS --- seb4itik/byow/roles/caddy/templates/Caddyfile | 131 ++++++++++++------ seb4itik/byow/roles/caddy/vars/main.yml | 34 +++-- 2 files changed, 115 insertions(+), 50 deletions(-) diff --git a/seb4itik/byow/roles/caddy/templates/Caddyfile b/seb4itik/byow/roles/caddy/templates/Caddyfile index 8102edd..674224a 100644 --- a/seb4itik/byow/roles/caddy/templates/Caddyfile +++ b/seb4itik/byow/roles/caddy/templates/Caddyfile @@ -1,7 +1,9 @@ { http_port {{ caddy_http_port }} https_port {{ caddy_https_port }} +{% if caddy_default_bind != "" %} default_bind {{ caddy_default_bind }} +{% endif %} order geoip2_vars first order coraza_waf first admin off @@ -44,6 +46,43 @@ } } +# 0=origin, 1=methods, 2=headers, 3=allow credentials, 4=max age, 5=vary, 6=expose headers +(cors) { + @match-cors-preflight-{args.0} { + header Origin {args.0} + method OPTIONS + } + header @match-cors-preflight-{args.0} { + Access-Control-Allow-Origin "{args.0}" + Access-Control-Allow-Methods "{args.1}" + Access-Control-Allow-Headers "{args.2}" + Access-Control-Allow-Credentials "{args.3}" + Access-Control-Max-Age "{args.4}" + Vary "{args.5}" + } + handle @match-cors-preflight-{args.0} { + respond "" 204 + } + + @match-cors-request-{args.0} { + header Origin {args.0} + not method OPTIONS + } + header @match-cors-request-{args.0} { + >Access-Control-Allow-Origin "{args.0}" + >Access-Control-Allow-Credentials "{args.3}" + >Vary "{args.5}" + >Access-Control-Expose-Headers "{args.6}" + } +} + +(cors-deny) { + @cors-preflight-deny method OPTIONS + handle @cors-preflight-deny { + respond "" 403 + } +} + {% for site in caddy_sites %} {% if (site.site_redirect_from_aliases | default(caddy_sites_defaults.site_redirect_from_aliases)) and (site.site_aliases | default([]) | length) > 0 %} @@ -103,33 +142,33 @@ respond @geofilter "Access Denied" 403 {% if site.geoip_debug | default(caddy_sites_defaults.geoip_debug) %} - header geoip-is_anonymous "{geoip2.is_anonymous}" - header geoip-is_anonymous_vpn "{geoip2.is_anonymous_vpn}" - header geoip-is_hosting_provider "{geoip2.is_hosting_provider}" - header geoip-is_public_proxy "{geoip2.is_public_proxy}" - header geoip-is_residential_proxy "{geoip2.is_residential_proxy}" - header geoip-is_tor_exit_node "{geoip2.is_tor_exit_node}" - header geoip-connection_type "{geoip2.connection_type}" - header geoip-domain "{geoip2.domain}" - header geoip-country_code "{geoip2.country_code}" - header geoip-country_confidence "{geoip2.country_confidence}" - header geoip-country_eu "{geoip2.country_eu}" - header geoip-country_geoname_id "{geoip2.country_geoname_id}" - header geoip-country_name "{geoip2.country_name}" - header geoip-continent_code "{geoip2.continent_code}" - header geoip-continent_geoname_id "{geoip2.continent_geoname_id}" - header geoip-continent_name "{geoip2.continent_name}" - header geoip-city_confidence "{geoip2.city_confidence}" - header geoip-city_geoname_id "{geoip2.city_geoname_id}" - header geoip-city_name "{geoip2.city_name}" - header geoip-location_time_zone "{geoip2.location_time_zone}" - header geoip-autonomous_system_number "{geoip2.autonomous_system_number}" - header geoip-autonomous_system_organization "{geoip2.autonomous_system_organization}" - header geoip-isp "{geoip2.isp}" - header geoip-mobile_country_code "{geoip2.mobile_country_code}" - header geoip-mobile_network_code "{geoip2.mobile_network_code}" - header geoip-organization "{geoip2.organization}" - header geoip-ip_address "{geoip2.ip_address}" + header x-geoip-is-anonymous "{geoip2.is_anonymous}" + header x-geoip-is-anonymous-vpn "{geoip2.is_anonymous_vpn}" + header x-geoip-is-hosting-provider "{geoip2.is_hosting_provider}" + header x-geoip-is-public-proxy "{geoip2.is_public_proxy}" + header x-geoip-is-residential-proxy "{geoip2.is_residential_proxy}" + header x-geoip-is-tor-exit-node "{geoip2.is_tor_exit_node}" + header x-geoip-connection-type "{geoip2.connection_type}" + header x-geoip-domain "{geoip2.domain}" + header x-geoip-country-code "{geoip2.country_code}" +# header x-geoip-country-confidence "{geoip2.country_confidence}" + header x-geoip-country-eu "{geoip2.country_eu}" + header x-geoip-country-geoname-id "{geoip2.country_geoname_id}" + header x-geoip-country-name "{geoip2.country_name}" + header x-geoip-continent-code "{geoip2.continent_code}" + header x-geoip-continent-geoname-id "{geoip2.continent_geoname_id}" + header x-geoip-continent-name "{geoip2.continent_name}" +# header x-geoip-city-confidence "{geoip2.city_confidence}" + header x-geoip-city-geoname-id "{geoip2.city_geoname_id}" + header x-geoip-city-name "{geoip2.city_name}" + header x-geoip-location-time-zone "{geoip2.location_time_zone}" + header x-geoip-autonomous-system-number "{geoip2.autonomous_system_number}" + header x-geoip-autonomous-system-organization "{geoip2.autonomous_system_organization}" + header x-geoip-isp "{geoip2.isp}" + header x-geoip-mobile-country-code "{geoip2.mobile_country_code}" + header x-geoip-mobile-network-code "{geoip2.mobile_network_code}" + header x-geoip-organization "{geoip2.organization}" + header x-geoip-ip-address "{geoip2.ip_address}" {% endif %} {% endif %} @@ -139,18 +178,6 @@ {% endfor %} } -{% if site.bot_barrier | default(caddy_sites_defaults.bot_barrier) %} - bot_barrier @not_whitelisted { - secret {{ site.bot_barrier_secret | default(caddy_sites_defaults.bot_barrier_secret) | mandatory }} - complexity {{ site.bot_barrier_complexity | default(caddy_sites_defaults.bot_barrier_complexity) }} - valid_for {{ site.bot_barrier_valid_for | default(caddy_sites_defaults.bot_barrier_valid_for) }} - seed_cookie_name __chall_{{ site.id }}_seed - solution_cookie_name __chall_{{ site.id }}_solution - mac_cookie_name __chall_{{ site.id }}_mac - template {{ caddy_config_dir }}/bot_barrier_template.html - } -{% endif %} - {% if site.rate_limit | default(caddy_sites_defaults.rate_limit) %} rate_limit @not_whitelisted { # distributed @@ -183,11 +210,31 @@ } {% endif %} +{% if site.cors | default(caddy_sites_defaults.cors) %} +{% for origin in site.cors_allow_origins | mandatory %} + import cors {{ origin }} "{{ site.cors_allow_methods | default(caddy_sites_defaults.cors_allow_methods) | join(', ') }}" "{{ site.cors_allow_headers | default(caddy_sites_defaults.cors_allow_headers) | join(', ') }}" "{{ site.cors_allow_credentials | default(caddy_sites_defaults.cors_allow_credentials) | ternary('true', 'false') }}" "{{ site.cors_max_age | default(caddy_sites_defaults.cors_max_age) }}" "{{ site.cors_vary | default(caddy_sites_defaults.cors_vary) | join(', ') }}" "{{ site.cors_expose_headers | default(caddy_sites_defaults.cors_expose_headers) | join(', ') }}" +{% endfor %} + import cors-deny +{% endif %} + +{% if site.bot_barrier | default(caddy_sites_defaults.bot_barrier) %} + bot_barrier @not_whitelisted { + secret {{ site.bot_barrier_secret | default(caddy_sites_defaults.bot_barrier_secret) | mandatory }} + complexity {{ site.bot_barrier_complexity | default(caddy_sites_defaults.bot_barrier_complexity) }} + valid_for {{ site.bot_barrier_valid_for | default(caddy_sites_defaults.bot_barrier_valid_for) }} + seed_cookie_name __chall_{{ site.id }}_seed + solution_cookie_name __chall_{{ site.id }}_solution + mac_cookie_name __chall_{{ site.id }}_mac + template {{ caddy_config_dir }}/bot_barrier_template.html + } +{% endif %} + header { Content-Security-Policy "{{ caddy_sites_defaults.header_content_security_policy | combine(site.header_content_security_policy | default({})) | dict2str(sep1=' ', sep2='; ', sep3=';') }}" - Cross-Origin-Embedder-Policy "{{ site.header_cross_origin_embedder_policy | default(caddy_sites_defaults.header_cross_origin_embedder_policy) }}" - Cross-Origin-Opener-Policy "{{ site.header_cross_origin_opener_policy | default(caddy_sites_defaults.header_cross_origin_opener_policy) }}" - Cross-Origin-Resource-Policy "{{ site.header_cross_origin_resource_policy | default(caddy_sites_defaults.header_cross_origin_resource_policy) }}" +# FIXME +# Cross-Origin-Embedder-Policy "{{ site.header_cross_origin_embedder_policy | default(caddy_sites_defaults.header_cross_origin_embedder_policy) }}" +# Cross-Origin-Opener-Policy "{{ site.header_cross_origin_opener_policy | default(caddy_sites_defaults.header_cross_origin_opener_policy) }}" +# Cross-Origin-Resource-Policy "{{ site.header_cross_origin_resource_policy | default(caddy_sites_defaults.header_cross_origin_resource_policy) }}" Feature-Policy "{{ caddy_sites_defaults.header_feature_policy | combine(site.header_feature_policy | default({})) | dict2str(sep1=' ', sep2='; ', sep3=';') }}" Permissions-Policy "{{ caddy_sites_defaults.header_permissions_policy | combine(site.header_permissions_policy | default({})) | dict2str(sep2=', ') }}" Referrer-Policy "{{ site.header_referrer_policy | default(caddy_sites_defaults.header_referrer_policy) }}" diff --git a/seb4itik/byow/roles/caddy/vars/main.yml b/seb4itik/byow/roles/caddy/vars/main.yml index 0864291..d6031d7 100644 --- a/seb4itik/byow/roles/caddy/vars/main.yml +++ b/seb4itik/byow/roles/caddy/vars/main.yml @@ -19,6 +19,11 @@ caddy_global_sites_defaults: # query [key] # header [field] # cookie [ []] + + + # Filters + filter_blacklist: [] # Superseeds filter_whitelist + filter_whitelist: ["127.0.0.1"] # Custom TLS certificate @@ -27,10 +32,27 @@ caddy_global_sites_defaults: custom_cert_key_file: "" - # Filters - filter_blacklist: [] # Superseeds filter_whitelist - filter_whitelist: ["127.0.0.1"] - + # CORS - Cross Origin Resource Sharing + cors: false + cors_allow_origins: null # Mandatory + cors_allow_methods: + - "GET" + - "HEAD" + - "POST" + - "OPTIONS" + cors_allow_headers: + - "Cache-Control" + - "Content-Type" + - "If-Modified-Since" + - "Range" + - "User-Agent" + cors_max_age: 3600 + cors_allow_credentials: false + cors_vary: + - "Origin" + cors_expose_headers: + - "Content-Length" + - "Content-Range" # Security headers header_content_security_policy: @@ -391,7 +413,3 @@ caddy_global_sites_defaults: geoip: false geoip_debug: false geoip_filter_expression: '{geoip2.country_code} == "UNK"' -# geoip_deny_countries: [] # ISO country codes (2 letters), "UNK" for unknown country -# geoip_allow_countries: [] # ISO country codes (2 letters), "UNK" for unknown country -# geopip_deny_asn: [] -# geopip_allow_asn: []