Skip to content

Commit 99e796d

Browse files
committed
Updates tests
1 parent 2147d7e commit 99e796d

File tree

5 files changed

+183
-17
lines changed

5 files changed

+183
-17
lines changed

.vscode/settings.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"ruff.importStrategy": "useBundled",
44
"editor.formatOnSave": true,
55
"editor.codeActionsOnSave": {
6-
"source.fixAll": true,
7-
"source.organizeImports": true
6+
"source.fixAll": "explicit",
7+
"source.organizeImports": "explicit"
88
}
99
}

Makefile

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
.PHONY: start
2+
start:
3+
docker compose up -d
4+
5+
.PHONY: logs
6+
logs:
7+
docker compose exec -it modsecurity tail -f /var/log/nginx/modsecurity.log || podman-compose exec modsecurity tail -f /var/log/nginx/modsecurity.log
8+
9+
.PHONY: restart
10+
restart:
11+
docker compose restart modsecurity
12+
13+
.PHONY: test
14+
test:
15+
pytest

README.md

+25-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
# 📛 OWASP ModSecurity Core Rule Set Tuning Practice
22

3-
**Disclaimer**: _Work in progress_
3+
[This collection](./test_waf.py) of basic unit tests is designed for practicing on how to adjust the [OWASP ModSecurity WAF Core Rule Set](https://owasp.org/www-project-modsecurity-core-rule-set/) to pass each test. It's important to note that these tests are not reflective of real-life situations and are solely intended for honing your skills in tuning WAF rules in different scenarios.
44

5-
[This collection](./test_waf.py) of basic unit tests is designed for practicing how to adjust [ModSecurity WAF rules](https://owasp.org/www-project-modsecurity-core-rule-set/) to pass each test. It's important to note that these tests are not reflective of real-life situations and are solely intended for honing your skills in tuning WAF rules in different scenarios.
5+
## Requirements
6+
7+
- Docker/Podman
8+
- Docker Compose
9+
- Python
610

711
## Getting Started
812

@@ -23,6 +27,10 @@ docker compose up -d
2327
docker compose exec -it modsecurity tail -f /var/log/nginx/modsecurity.log
2428
podman-compose exec modsecurity tail -f /var/log/nginx/modsecurity.log
2529

30+
# Restart container to apply new rules
31+
docker compose restart modsecurity
32+
podman-compose restart modsecurity
33+
2634
# Use BurpSuite proxy for request inspection
2735
export HTTP_PROXY=http://localhost:8080
2836
```
@@ -37,10 +45,25 @@ pytest
3745
pytest -k test_cookie_1
3846
```
3947

48+
### Recommended Process
49+
50+
1. Start WAF and webserver `docker compose up -d`
51+
2. Start monitoring of WAF logs `docker compose exec -it modsecurity tail -f /var/log/nginx/modsecurity.log`
52+
3. Review test definition in [`test_waf.py`](./test_waf.py)
53+
4. Execute individual test `pytest -k test_generic_form_1`
54+
5. Review WAF log entries
55+
6. Update WAF [rules](./waf)
56+
7. Restart WAF `docker compose restart modsecurity`
57+
8. Repeat steps 4 to 7 until test reports success.
58+
9. Move to the next unit test.
59+
4060
## References
4161

4262
- [owasp-modsecurity/ModSecurity](https://github.com/owasp-modsecurity/ModSecurity)
4363
- [OWASP ModSecurity Core Rule Set](https://owasp.org/www-project-modsecurity-core-rule-set/)
4464
- [coreruleset/coreruleset](https://github.com/coreruleset/coreruleset)
4565
- [OWASP CRS Docker Image](https://github.com/coreruleset/modsecurity-crs-docker)
4666
- [Handling False Positives with the OWASP ModSecurity Core Rule Set](https://www.netnea.com/cms/apache-tutorial-8_handling-false-positives-modsecurity-core-rule-set/)
67+
- [SANS ModSecurity Rules](https://wiki.sans.blue/Tools/pdfs/ModSecurity.pdf)
68+
- [ModSecurity Reference Manual (v3.x)](https://github.com/owasp-modsecurity/ModSecurity/wiki/Reference-Manual)
69+
- [Full pytest documentation](https://docs.pytest.org/en/8.2.x/contents.html)

pyproject.toml

+2-7
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,14 @@ select = [
6363
"E9",
6464
"F",
6565
]
66+
6667
ignore = [
6768
# mutable defaults
6869
"B006",
6970
]
7071

7172
# Allow fix for all enabled rules (when `--fix`) is provided.
72-
fixable = ["ALL"]
73+
fixable = ["I"]
7374

7475
unfixable = [
7576
# disable auto fix for print statements
@@ -108,9 +109,3 @@ docstring-code-format = false
108109
# This only has an effect when the `docstring-code-format` setting is
109110
# enabled.
110111
docstring-code-line-length = "dynamic"
111-
112-
[tool.ruff.lint.isort]
113-
length-sort = true
114-
length-sort-straight = true
115-
combine-as-imports = true
116-
extra-standard-library = ["typing_extensions"]

test_waf.py

+139-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import http
22
import urllib.parse
3+
import uuid
34
from random import randrange
45

56
import pytest
@@ -13,6 +14,10 @@ def get_url(path: str) -> str:
1314
return urllib.parse.urljoin(ENDPOINT, path)
1415

1516

17+
def url_encode_all(string):
18+
return "".join("%{0:0>2x}".format(ord(char)) for char in string)
19+
20+
1621
@pytest.fixture
1722
def user_agent():
1823
return {"User-Agent": UA}
@@ -75,37 +80,78 @@ def test_password_json_3(request_kwargs):
7580
assert response.status_code == http.HTTPStatus.FORBIDDEN
7681

7782

83+
def test_nosql_1(request_kwargs):
84+
"""This should work as it uses NOSQL"""
85+
data = {}
86+
data[str(uuid.uuid1())] = "test' or '1'='1"
87+
response = requests.post(get_url("/nosql/update"), data=data, **request_kwargs)
88+
assert response.status_code == http.HTTPStatus.OK
89+
90+
91+
def test_nosql_2(request_kwargs):
92+
"""NOSQL exploitation attempts should be blocked"""
93+
data = {"username": {"$eq": "admin"}, "password": {"$regex": "^mdp"}}
94+
response = requests.post(get_url("/nosql/update"), json=data, **request_kwargs)
95+
assert response.status_code == http.HTTPStatus.FORBIDDEN
96+
97+
7898
def test_cookie_1(user_agent):
79-
"""As an example, this cookie is considered safe and WAF should not block it"""
80-
cookie = "yummy_cookie=uname-it; tasty_cookie=strawberry"
99+
"""These cookies are considered safe and WAF should not block it"""
100+
cookie = "yummy_cookie=banana; tasty_cookie=strawberry"
81101
headers = {**user_agent, "Cookie": cookie}
82102
response = requests.get(get_url("/cookies"), headers=headers)
83103
assert response.status_code == http.HTTPStatus.OK
84104

85105

86106
def test_cookie_2(user_agent):
107+
"""As an example, this cookie is considered safe and WAF should not block it"""
108+
cookie = "delicious_cookie=uname-it; tasty_cookie=strawberry"
109+
headers = {**user_agent, "Cookie": cookie}
110+
response = requests.get(get_url("/cookies"), headers=headers)
111+
assert response.status_code == http.HTTPStatus.OK
112+
113+
114+
def test_cookie_3(user_agent):
87115
"""Create custom rule to detect use of `username` cookie and prevent use of it"""
88116
cookie = "username=admin; session-id=73101f80-727c-4c4d-b812-9023d40e8510"
89117
headers = {**user_agent, "Cookie": cookie}
90118
response = requests.get(get_url("/cookies"), headers=headers)
91119
assert response.status_code == http.HTTPStatus.FORBIDDEN
92120

93121

94-
def test_cookie_3(user_agent):
122+
def test_cookie_4(user_agent):
95123
"""Lets assume this is a legit cookie and needs to be allowed"""
96-
cookie = 'SESSION=a:2:{i:0;s:4:"bob";i:1;s:33:"admin\'istrator\'=";}'
124+
cookie = 'SESSION=O:13:"ConvisoPerson":5:{s:8:"username";s:6:"Antony";s:4:"team";s:5:"PTaaS";s:3:"age";i:17;s:6:"office";s:6:"Intern";s:12:"accountAdmin";b:0;}'
97125
headers = {**user_agent, "Cookie": cookie}
98126
response = requests.get(get_url("/cookies"), headers=headers)
99127
assert response.status_code == http.HTTPStatus.OK
100128

101129

130+
def test_cookie_5(user_agent):
131+
"""Lets assume this is a legit cookie and needs to be allowed"""
132+
cookie = f"SESSION={str(uuid.uuid1())}|uname -a"
133+
headers = {**user_agent, "Cookie": cookie}
134+
response = requests.get(get_url("/cookies"), headers=headers)
135+
assert response.status_code == http.HTTPStatus.FORBIDDEN
136+
137+
102138
def test_get_params_1(request_kwargs):
103-
"""This get parameter should not be blocked"""
139+
"""This get parameter value is considered safe and should not be blocked"""
104140
response = requests.get(get_url("/status") + "?action=netstat", **request_kwargs)
105141
assert response.status_code == http.HTTPStatus.OK
106142

107143

108-
def test_get_params_2(request_kwargs):
144+
@pytest.mark.parametrize(
145+
"cmd",
146+
["whoami", "uname", "netcat", "wget"],
147+
)
148+
def test_get_params_2(cmd: str, request_kwargs):
149+
"""This and any other potential RCE attack parameter values should be blocked"""
150+
response = requests.get(get_url("/status") + f"?action={cmd}", **request_kwargs)
151+
assert response.status_code == http.HTTPStatus.FORBIDDEN
152+
153+
154+
def test_get_params_3(request_kwargs):
109155
"""The below activity should be blocked"""
110156
response = requests.get(get_url("/status") + "?cmd=whoami", **request_kwargs)
111157
assert response.status_code == http.HTTPStatus.FORBIDDEN
@@ -118,3 +164,90 @@ def test_user_agent_1():
118164
}
119165
response = requests.get(get_url("/ua"), headers=headers)
120166
assert response.status_code == http.HTTPStatus.OK
167+
168+
169+
def test_user_agent_2():
170+
"""The below user-agent includes RCE attack attempt and must be blocked"""
171+
headers = {
172+
"User-Agent": 'Mozilla/5.0 (Linux; Android 10; id) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.3; echo "<h1>Defaced</h1>" > /var/www/html/public/index.php'
173+
}
174+
response = requests.get(get_url("/ua"), headers=headers)
175+
assert response.status_code == http.HTTPStatus.FORBIDDEN
176+
177+
178+
@pytest.mark.parametrize(
179+
"filename",
180+
["backup.sql", "dump.sql", "backup.old", "HEAD", "settings.xml", "config.json"],
181+
)
182+
def test_file_1(
183+
filename: str,
184+
):
185+
"""Prevent users from accessing sensitive files anywhere on the server"""
186+
path = f"/{str(uuid.uuid1())}/{filename}"
187+
response = requests.get(get_url(path))
188+
assert response.status_code == http.HTTPStatus.NOT_FOUND
189+
190+
191+
@pytest.mark.parametrize(
192+
"filename",
193+
[
194+
"jquery.js",
195+
"index.css",
196+
"latin-wght-normal.woff2",
197+
],
198+
)
199+
def test_file_2(filename: str, request_kwargs):
200+
"""Allow to download static content"""
201+
path = f"/{str(uuid.uuid1())}/{filename}"
202+
response = requests.get(get_url(path), **request_kwargs)
203+
assert response.status_code == http.HTTPStatus.OK
204+
205+
206+
@pytest.mark.parametrize(
207+
"path",
208+
[
209+
"/var/www/html/config.json",
210+
"/var/www/html/public/index.php",
211+
"/etc/passwd",
212+
"/proc/self/environ",
213+
"/var/log/nginx/access.log",
214+
],
215+
)
216+
def test_file_3(path: str, request_kwargs):
217+
"""Prevent users from accessing files outside /home/* directory"""
218+
response = requests.get(
219+
get_url("/file/api") + f"?path={url_encode_all(path)}", **request_kwargs
220+
)
221+
assert response.status_code == http.HTTPStatus.NOT_FOUND
222+
223+
224+
@pytest.mark.parametrize(
225+
"path",
226+
[
227+
"/etc/passwd",
228+
"/proc/self/environ",
229+
],
230+
)
231+
def test_file_4(path: str, request_kwargs):
232+
"""Requests for system files should be blocked in general"""
233+
response = requests.get(
234+
get_url("/system/manage") + f"?path={url_encode_all(path)}", **request_kwargs
235+
)
236+
assert response.status_code == http.HTTPStatus.FORBIDDEN
237+
238+
239+
@pytest.mark.parametrize(
240+
"filename",
241+
[
242+
"report.pdf",
243+
"notes.txt",
244+
"index.php",
245+
],
246+
)
247+
def test_file_5(filename: str, request_kwargs):
248+
"""Allow accessing specific files"""
249+
response = requests.get(
250+
get_url("/file/api") + f"?path=/home/{str(uuid.uuid1())}/{filename}",
251+
**request_kwargs,
252+
)
253+
assert response.status_code == http.HTTPStatus.OK

0 commit comments

Comments
 (0)