Skip to content

Commit

Permalink
add support for coraza-waf integration
Browse files Browse the repository at this point in the history
  • Loading branch information
superstes committed Dec 27, 2024
1 parent 540eef2 commit 0f9798e
Show file tree
Hide file tree
Showing 16 changed files with 353 additions and 23 deletions.
226 changes: 226 additions & 0 deletions ExampleCorazaWAF.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
# Coraza WAF Example

You might want to combine the actual WAF-functionality with [basic Security-checks and TLS-Fingerprinting](https://github.com/ansibleguy/infra_haproxy/blob/latest/ExampleWAF.md)!

## Config

```yaml
waf: # Role: ansibleguy.haproxy_waf_coraza
apps:
- name: 'default'
- name: 'default_block'
block: true

# apis
- name: 'app1'
block: true

- name: 'app2'

haproxy:
waf:
coraza:
enable: true
default_app: 'default'

frontends:
fe_web:
bind: ['[::]:80 v4v6']

routes:
be_test1:
domains: ['test1.ansibleguy.net']

be_test2:
domains: ['test2.ansibleguy.net']

be_app1:
domains: ['app1.ansibleguy.net']

be_app2:
domains: ['app2.ansibleguy.net']

default_backend: 'be_fallback'

backends:
be_test1:
servers: ...

be_test2:
servers: ...
security:
coraza_app: 'default_block'

be_app1:
servers: ...

be_app2:
servers: ...

be_fallback:
lines: 'http-request redirect code 302 location https://github.com/ansibleguy'
```
----
## Result
```bash
root@test-ag-haproxy-waf:/# cat /etc/haproxy/haproxy.cfg
> # Ansible managed: Do NOT edit this file manually!
> # ansibleguy.infra_haproxy
>
> global
> daemon
> user haproxy
> group haproxy
>
> tune.ssl.capture-buffer-size 96
>
> log /dev/log local0
> log /dev/log local1 notice
> chroot /var/lib/haproxy
> stats socket /run/haproxy/admin.sock mode 660 level admin
> stats timeout 30s
> ca-base /etc/ssl/certs
> crt-base /etc/ssl/private
> ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
> ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
> ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
>
> defaults
> log global
> mode http
> option httplog
> option dontlognull
> timeout connect 5000
> timeout client 50000
> timeout server 50000
> errorfile 400 /etc/haproxy/errors/400.http
> errorfile 403 /etc/haproxy/errors/403.http
> errorfile 408 /etc/haproxy/errors/408.http
> errorfile 500 /etc/haproxy/errors/500.http
> errorfile 502 /etc/haproxy/errors/502.http
> errorfile 503 /etc/haproxy/errors/503.http
> errorfile 504 /etc/haproxy/errors/504.http

root@test-ag-haproxy-waf:/# cat /etc/haproxy/waf-coraza.cfg
> # Ansible managed
> # ansibleguy.haproxy_waf_coraza
>
> backend coraza-waf-spoa
> mode tcp
> server coraza-waf 127.0.0.1:9000 check

root@test-ag-haproxy-waf:/# cat waf-coraza-spoe.cfg
> # Ansible managed
> # ansibleguy.haproxy_waf_coraza
>
> [coraza]
> spoe-agent coraza-agent
> messages coraza-req
> option var-prefix coraza
> option set-on-error error
> timeout hello 2s
> timeout idle 2m
> timeout processing 500ms
> use-backend coraza-waf-spoa
> log global
>
> spoe-message coraza-req
> args app=var(txn.waf_app) src-ip=src src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body
> event on-backend-http-request

root@test-ag-haproxy-waf:/# cat /etc/haproxy/conf.d/frontend.cfg
> # Ansible managed: Do NOT edit this file manually!
> # ansibleguy.infra_haproxy
>
> frontend fe_web
> mode http
> bind [::]:80 v4v6
>
> # Security headers
> http-response add-header Strict-Transport-Security "max-age=16000000; includeSubDomains; preload;" if !{ res.hdr(Strict-Transport-Security) -m found }
> http-response add-header X-Frame-Options "SAMEORIGIN" if !{ res.hdr(X-Frame-Options) -m found }
> http-response add-header X-Content-Type-Options "nosniff" if !{ res.hdr(X-Content-Type-Options) -m found }
> http-response add-header X-Permitted-Cross-Domain-Policies "none" if !{ res.hdr(X-Permitted-Cross-Domain-Policies) -m found }
> http-response add-header X-XSS-Protection "1; mode=block" if !{ res.hdr(X-XSS-Protection) -m found }
>
> http-request capture req.fhdr(User-Agent) len 200
>
> # BACKEND be_test1
> acl be_test1_filter_domains req.hdr(host) -m str -i test1.ansibleguy.net
> use_backend be_test1 if be_test1_filter_domains
>
> # BACKEND be_test2
> acl be_test2_filter_domains req.hdr(host) -m str -i test2.ansibleguy.net
> use_backend be_test2 if be_test2_filter_domains
>
> http-request set-var(txn.waf_app) str(default_block) if be_test2_filter_domains
>
> # BACKEND be_app1
> acl be_app1_filter_domains req.hdr(host) -m str -i app1.ansibleguy.net
> use_backend be_app1 if be_app1_filter_domains
>
> http-request set-var(txn.waf_app) str(be_app1) if be_app1_filter_domains
>
> # BACKEND be_app2
> acl be_app2_filter_domains req.hdr(host) -m str -i app2.ansibleguy.net
> use_backend be_app2 if be_app2_filter_domains
>
> http-request set-var(txn.waf_app) str(be_app2) if be_app1_filter_domains
>
> # Coraza WAF
> http-request set-var(txn.waf_app) str(default) if !{ var(txn.waf_app) -m found }
>
> filter spoe engine coraza config /etc/haproxy/waf-coraza-spoe.cfg
> http-request capture var(txn.waf_app) len 50
> http-request capture var(txn.coraza.id) len 16
> http-request capture var(txn.coraza.fail) len 1
> http-request capture var(txn.coraza.action) len 8
> http-request deny status 403 default-errorfiles if { var(txn.coraza.action) -m str deny }
> http-response deny status 403 default-errorfiles if { var(txn.coraza.action) -m str deny }
> http-request silent-drop if { var(txn.coraza.action) -m str drop }
> http-response silent-drop if { var(txn.coraza.action) -m str drop }

root@test-ag-haproxy-waf:/# systemctl status haproxy.service
> * haproxy.service - HAProxy Load Balancer
> Loaded: loaded (/lib/systemd/system/haproxy.service; enabled; preset: enabled)
> Drop-In: /etc/systemd/system/haproxy.service.d
> `-override.conf
> Active: active (running) since Sat 2024-05-04 16:24:54 UTC; 4min 11s ago
> Docs: man:haproxy(1)
> file:/usr/share/doc/haproxy/configuration.txt.gz
> https://www.haproxy.com/documentation/haproxy-configuration-manual/latest/
> https://github.com/ansibleguy/infra_haproxy
> Process: 4574 ExecStartPre=/usr/sbin/haproxy -c -f $CONFIG -f /etc/haproxy/conf.d/ -f /etc/haproxy/waf-coraza.cfg (code=exited, status=0/SUCCESS)
> Process: 4635 ExecReload=/usr/sbin/haproxy -c -f $CONFIG -f /etc/haproxy/conf.d/ -f /etc/haproxy/waf-coraza.cfg (code=exited, status=0/SUCCESS)
> Process: 4637 ExecReload=/bin/kill -USR2 $MAINPID (code=exited, status=0/SUCCESS)
> Main PID: 4576 (haproxy)
> Status: "Ready."
> Tasks: 7 (limit: 1783)
> Memory: 132.2M
> CPU: 297ms
> CGroup: /system.slice/haproxy.service
> |-4576 /usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg -f /etc/haproxy/conf.d/ -p /run/haproxy.pid -S /run/haproxy-master.sock
> `-4639 /usr/sbin/haproxy -sf 4578 -x sockpair@4 -Ws -f /etc/haproxy/haproxy.cfg -f /etc/haproxy/conf.d/ -p /run/haproxy.pid -S /run/haproxy-master.sock

root@test-ag-haproxy-waf:/# systemctl status coraza-spoa.service
> ● coraza-spoa.service - Coraza WAF SPOA Daemon
> Loaded: loaded (/etc/systemd/system/coraza-spoa.service; enabled; preset: enabled)
> Drop-In: /etc/systemd/system/coraza-spoa.service.d
> └─override.conf
> Active: active (running) since Fri 2024-12-27 19:45:29 CET; 1h 12min ago
> Docs: https://www.coraza.io
> https://github.com/corazawaf/coraza-spoa
> https://github.com/corazawaf/coraza
> https://coraza.io/docs/seclang/directives/
> https://github.com/ansibleguy/haproxy_waf_coraza
> https://docs.o-x-l.com/waf/coraza.html
> Main PID: 3878168 (coraza-spoa)
> Tasks: 10 (limit: 4531)
> Memory: 11.7M
> CPU: 4.099s
> CGroup: /system.slice/coraza-spoa.service
> └─3878168 /usr/bin/coraza-spoa -config=/etc/coraza-spoa/spoa.yml
```
6 changes: 1 addition & 5 deletions ExampleWAF.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

There are still some basic WAF features to be implemented.

NOTE: The feature-set this role provides does not come lose to the one [available in HAProxy Enterprise by default](https://www.haproxy.com/solutions/web-application-firewall).
NOTE: The feature-set this role provides does not come close to a fully-fledged WAF like [Coraza WAF](https://github.com/ansibleguy/infra_haproxy/blob/latest/ExampleCorazaWAF.md) or the one [available in HAProxy Enterprise by default](https://www.haproxy.com/solutions/web-application-firewall).

[Fingerprinting Docs](https://github.com/ansibleguy/infra_haproxy/blob/latest/Fingerprinting.md) for detailed information on how you might want to track clients.

Expand Down Expand Up @@ -103,10 +103,6 @@ root@test-ag-haproxy-waf:/# cat /etc/haproxy/conf.d/frontend.cfg
> # Ansible managed: Do NOT edit this file manually!
> # ansibleguy.infra_haproxy
>
> frontend fe_web
> mode http
> bind [::]:80 v4v6
>
> frontend fe_web
> mode http
> bind [::]:80 v4v6
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<img src="https://www.haproxy.com/assets/legal/web-logo.png" alt="HAProxy Logo" width="300"/>
</a>

# Ansible Role - HAProxy Community (with ACME, GeoIP and some WAF-Features)
# Ansible Role - HAProxy Community (with ACME, GeoIP, WAF-Integration)

Role to deploy HAProxy (*Focus on the Community Version*)

Expand Down Expand Up @@ -79,6 +79,7 @@ Here some detailed config examples and their results:
* [Example ACME](https://github.com/ansibleguy/infra_haproxy/blob/latest/ExampleAcme.md)
* [Example GeoIP](https://github.com/ansibleguy/infra_haproxy/blob/latest/ExampleGeoIP.md)
* [Example WAF](https://github.com/ansibleguy/infra_haproxy/blob/latest/ExampleWAF.md)
* [Example Coraza-WAF](https://github.com/ansibleguy/infra_haproxy/blob/latest/ExampleCorazaWAF.md)
* [Example TCP](https://github.com/ansibleguy/infra_haproxy/blob/latest/ExampleTCP.md)

### Config
Expand Down
2 changes: 1 addition & 1 deletion defaults/main/0_hardcoded.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ HAPROXY_HC:

coraza:
be: '/etc/haproxy/waf-coraza.cfg'
spoa: '/etc/haproxy/waf-coraza-spoa.cfg'
spoa: '/etc/haproxy/waf-coraza-spoe.cfg'

coraza_app_var: 'txn.waf_app'

Expand Down
5 changes: 5 additions & 0 deletions defaults/main/1_main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
no_prompts: false
debug: false
force_update: false
skip_waf: false

# default config => is overwritten by provided config
defaults_haproxy:
Expand Down Expand Up @@ -99,6 +100,7 @@ defaults_frontend:

block_script_bots: false
block_bad_crawler_bots: false
block_ai_bots: false
block_script_kiddies: false # you might have to define 'haproxy.waf.script_kiddy.excludes' for your use-cases

flag_bots: false
Expand Down Expand Up @@ -157,12 +159,15 @@ defaults_backend:

block_script_bots: false
block_bad_crawler_bots: false
block_ai_bots: false
block_script_kiddies: false # you might have to define 'haproxy.waf.script_kiddy.excludes' for your use-cases

flag_bots: false
flag_bots_lines: [] # additional checks you want to append; you could p.e. check if a cookie set by JS exists
# is auto-prepended: 'http-request set-var(txn.bot) int(1) if !{ var(txn.bot) -m found } ' before your conditions

coraza_app: '' # define a specific coraza-waf-application to use (waf.apps.*.name)

basic_auth:
users: {}
plaintext: true # NOTE: currently we only support plaintext password in the config
Expand Down
42 changes: 38 additions & 4 deletions filter_plugins/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ def filters(self):
"ssl_fingerprint_ja4": self.ssl_fingerprint_ja4,
"build_route": self.build_route,
"join_w_excludes": self.join_w_excludes,
"waf_coraza_apps": self.waf_coraza_apps,
"all_route_backends_exist": self.all_route_backends_exist,
}

@staticmethod
Expand Down Expand Up @@ -71,10 +73,9 @@ def is_truthy(v: (bool, str, int)) -> bool:
def join_w_excludes(cls, v: list, excludes: list) -> str:
return ' '.join([v for v in cls.ensure_list(v) if v not in cls.ensure_list(excludes)])


# pylint: disable=R0912,R0915
@classmethod
def build_route(cls, fe_cnf: dict, be_cnf: dict, be_name: str) -> list:
def build_route(cls, fe_cnf: dict, be_cnf: dict, be_name: str, only_condition: bool = False) -> (list, str):
lines = []
to_match = []
var_prefix = f'{be_name}_filter'
Expand Down Expand Up @@ -145,6 +146,7 @@ def build_route(cls, fe_cnf: dict, be_cnf: dict, be_name: str) -> list:
to_match.append(f'!{var_prefix}_statics')
statics_to_match.append(f'{var_prefix}_statics')

condition = ''
for loop_be, loop_match in {
be_name: to_match,
be_statics_name: statics_to_match
Expand All @@ -163,12 +165,44 @@ def build_route(cls, fe_cnf: dict, be_cnf: dict, be_name: str) -> list:
for m in loop_match[1:]:
loop_match_or.append(f'{d} {m}')

lines.append(f"use_backend {loop_be} if {' || '.join(loop_match_or)}")
condition = f"if {' || '.join(loop_match_or)}"
lines.append(f"use_backend {loop_be} {condition}")

else:
lines.append(f"use_backend {loop_be} if {' '.join(loop_match)}")
condition = f"if {' '.join(loop_match)}"
lines.append(f"use_backend {loop_be} {condition}")

else:
lines.append(f"use_backend {loop_be}")

if only_condition:
return condition

return lines

@staticmethod
def waf_coraza_apps(waf_cnf: dict) -> list:
if not isinstance(waf_cnf, dict) or 'apps' not in waf_cnf or not isinstance(waf_cnf['apps'], list):
return []

apps = []
for app in waf_cnf['apps']:
try:
apps.append(app['name'])

except KeyError:
pass

return apps

@classmethod
def all_route_backends_exist(cls, cnf: dict) -> bool:
existing_be_names = [cls.safe_key(name) for name in cnf['backends']]

for fe_cnf in cnf['frontends'].values():
for be_name_user in fe_cnf['routes']:
be_name = cls.safe_key(be_name_user)
if be_name not in existing_be_names:
return False

return True
6 changes: 6 additions & 0 deletions requirements.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
# external roles and collections to download
# install: ansible-galaxy install -r requirements.yml

roles:
- src: 'ansibleguy.haproxy_waf_coraza'
1 change: 1 addition & 0 deletions tasks/debian/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- "{{ HAPROXY_HC.file.lst.bot_sub }}"
- "{{ HAPROXY_HC.file.lst.bad_bot_sub }}"
- "{{ HAPROXY_HC.file.lst.bad_bot_full }}"
- "{{ HAPROXY_HC.file.lst.ai_crawlers }}"

- name: HAProxy | Config | Globals/Defaults
ansible.builtin.template:
Expand Down
Loading

0 comments on commit 0f9798e

Please sign in to comment.