Skip to content

Commit e1b26b8

Browse files
committed
test: add matrix coverage and deepen plugin test suites
1 parent 2900f53 commit e1b26b8

19 files changed

Lines changed: 365 additions & 64 deletions

.tests/clamav.sh

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,37 @@ if [ "$success" == "ko" ] ; then
8585
echo "❌ Error did not receive 403 code"
8686
exit 1
8787
fi
88+
89+
# A clean file must NOT be denied by ClamAV. The hello upstream only accepts GET,
90+
# so a clean multipart POST reaches it and comes back 405 (method). Accept any 2xx
91+
# or non-403 4xx, but fail on 403 (denied) and on 5xx/000 (a crash or fail-closed
92+
# regression must not hide behind "not 403").
93+
echo "ℹ️ Testing that a clean file is not blocked ..."
94+
printf 'just a clean file\n' > /tmp/bunkerweb-plugins/clamav/clean.txt
95+
code="$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Host: www.example.com" -F "file=@/tmp/bunkerweb-plugins/clamav/clean.txt" http://localhost)"
96+
case "$code" in
97+
403) clean_err="should not be denied by ClamAV" ;;
98+
000 | 5??) clean_err="caused an upstream error/crash" ;;
99+
*) clean_err="" ;;
100+
esac
101+
if [ -n "$clean_err" ] ; then
102+
docker compose logs
103+
docker compose down -v
104+
echo "❌ Error: clean file $clean_err (got $code)"
105+
exit 1
106+
fi
107+
108+
# Upload EICAR a second time: it must still be denied. This re-hits the same
109+
# SHA-512, exercising the result cache (is_in_cache) rather than a fresh scan.
110+
echo "ℹ️ Testing repeated EICAR is still denied (cache path) ..."
111+
code="$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Host: www.example.com" -F "file=@/tmp/bunkerweb-plugins/clamav/eicar.com" http://localhost)"
112+
if [ "$code" != "403" ] ; then
113+
docker compose logs
114+
docker compose down -v
115+
echo "❌ Error: repeated EICAR should stay denied (got $code, expected 403)"
116+
exit 1
117+
fi
118+
88119
if [ "$1" = "verbose" ] ; then
89120
docker compose logs
90121
fi

.tests/coraza.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ if [ "$success" == "ko" ] ; then
8484
exit 1
8585
fi
8686

87+
# Scanner User-Agent (CRS 913xxx, request-headers phase): a different rule family
88+
# than the LFI args above, widening coverage to header inspection.
89+
echo "ℹ️ Testing with a scanner User-Agent ..."
90+
ret="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: www.example.com" -H "User-Agent: sqlmap/1.4.7" http://localhost/)"
91+
if [ "$ret" != "403" ] ; then
92+
docker compose logs
93+
docker compose down -v
94+
echo "❌ Error: scanner User-Agent should be blocked (got $ret, expected 403)"
95+
exit 1
96+
fi
97+
98+
# A benign request with a normal query arg must pass (no false positive -> 200).
99+
echo "ℹ️ Testing that a benign request passes ..."
100+
ret="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: www.example.com" "http://localhost/?q=hello-world")"
101+
if [ "$ret" != "200" ] ; then
102+
docker compose logs
103+
docker compose down -v
104+
echo "❌ Error: benign request should pass (got $ret, expected 200)"
105+
exit 1
106+
fi
107+
87108
# We're done
88109
if [ "$1" = "verbose" ] ; then
89110
docker compose logs

.tests/notifier.sh

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@
33
# shellcheck disable=SC1091
44
. .tests/utils.sh
55

6-
echo "ℹ️ Starting Notifier (discord/slack/webhook) tests ..."
6+
echo "ℹ️ Starting Notifier (discord/slack/webhook/matrix) tests ..."
77

88
# Create working directory
99
if [ -d /tmp/bunkerweb-plugins ] ; then
1010
do_and_check_cmd sudo rm -rf /tmp/bunkerweb-plugins
1111
fi
1212
do_and_check_cmd mkdir -p /tmp/bunkerweb-plugins/notifier/bw-data/plugins
1313

14-
# Copy all three notifier plugins
14+
# Copy all four notifier plugins
1515
do_and_check_cmd cp -r ./discord /tmp/bunkerweb-plugins/notifier/bw-data/plugins
1616
do_and_check_cmd cp -r ./slack /tmp/bunkerweb-plugins/notifier/bw-data/plugins
1717
do_and_check_cmd cp -r ./webhook /tmp/bunkerweb-plugins/notifier/bw-data/plugins
18+
do_and_check_cmd cp -r ./matrix /tmp/bunkerweb-plugins/notifier/bw-data/plugins
1819
do_and_check_cmd sudo chown -R 101:101 /tmp/bunkerweb-plugins/notifier/bw-data
1920

2021
# Copy compose + the rate-limit mock config
@@ -111,6 +112,9 @@ check_echo_notifier() {
111112

112113
check_echo_notifier slack /slack '"text"'
113114
check_echo_notifier webhook /webhook '"content"'
115+
# matrix posts a PUT to the Matrix "send message" endpoint on the same echo mock.
116+
# Assert the room-send path was hit and the org.matrix.custom.html payload shape.
117+
check_echo_notifier matrix "/_matrix/client" '"formatted_body"'
114118

115119
# discord posts to the rate-limit mock (429 + Retry-After, then 200). With
116120
# DISCORD_RETRY_IF_LIMITED=yes the plugin retries once, so the mock should see

.tests/notifier/docker-compose.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ services:
2020
environment:
2121
- BUNKERWEB_INSTANCES=bunkerweb
2222
- MULTISITE=yes
23-
- SERVER_NAME=discord.example.com slack.example.com webhook.example.com
23+
- SERVER_NAME=discord.example.com slack.example.com webhook.example.com matrix.example.com
2424
- API_WHITELIST_IP=127.0.0.0/8 10.20.30.0/24
2525
- LOG_LEVEL=info
2626
- USE_BAD_BEHAVIOR=no
@@ -43,6 +43,12 @@ services:
4343
- DISCORD_RETRY_IF_LIMITED=yes
4444
- SLACK_WEBHOOK_URL=http://mock:8080/slack
4545
- WEBHOOK_URL=http://mock:8080/webhook
46+
# matrix posts a PUT to /_matrix/client/... on the catch-all echo mock (no
47+
# 429-retry path of its own). Globals are mandatory; values are dummies.
48+
- MATRIX_BASE_URL=http://mock:8080
49+
- MATRIX_ROOM_ID=!test:example.com
50+
- MATRIX_ACCESS_TOKEN=test-token
51+
- MATRIX_INCLUDE_HEADERS=yes
4652
# discord.example.com : notify on a blacklisted URI
4753
- discord.example.com_USE_REVERSE_PROXY=yes
4854
- discord.example.com_REVERSE_PROXY_HOST=http://hello:8080
@@ -64,6 +70,13 @@ services:
6470
- webhook.example.com_USE_WEBHOOK=yes
6571
- webhook.example.com_USE_BLACKLIST=yes
6672
- webhook.example.com_BLACKLIST_URI=/blocked
73+
# matrix.example.com
74+
- matrix.example.com_USE_REVERSE_PROXY=yes
75+
- matrix.example.com_REVERSE_PROXY_HOST=http://hello:8080
76+
- matrix.example.com_REVERSE_PROXY_URL=/
77+
- matrix.example.com_USE_MATRIX=yes
78+
- matrix.example.com_USE_BLACKLIST=yes
79+
- matrix.example.com_BLACKLIST_URI=/blocked
6780
networks:
6881
- bw-universe
6982

.tests/virustotal.sh

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,22 @@ if ! docker compose logs mock 2>/dev/null | grep -F "/files/275a021bbfb6489e54d4
100100
exit 1
101101
fi
102102

103-
# A clean file must pass through (mock returns 404 = not found on VT = clean)
104-
echo "ℹ️ Testing that a clean file passes ..."
103+
# A clean file must NOT be denied (mock returns 404 = not found on VT = clean).
104+
# The hello upstream only accepts GET, so the clean multipart POST reaches it and
105+
# comes back 405 (method). Accept any 2xx or non-403 4xx, but fail on 403 (denied)
106+
# and on 5xx/000 (a crash or fail-closed regression must not hide behind "not 403").
107+
echo "ℹ️ Testing that a clean file is not blocked ..."
105108
printf 'just a clean file\n' > /tmp/bunkerweb-plugins/virustotal/clean.txt
106109
code="$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Host: www.example.com" -F "file=@/tmp/bunkerweb-plugins/virustotal/clean.txt" http://localhost)"
107-
if [ "$code" != "200" ] ; then
110+
case "$code" in
111+
403) clean_err="should not be denied by VirusTotal" ;;
112+
000 | 5??) clean_err="caused an upstream error/crash" ;;
113+
*) clean_err="" ;;
114+
esac
115+
if [ -n "$clean_err" ] ; then
108116
docker compose logs
109117
docker compose down -v
110-
echo "❌ Error: clean file should pass (got $code, expected 200)"
118+
echo "❌ Error: clean file $clean_err (got $code)"
111119
exit 1
112120
fi
113121

@@ -121,6 +129,20 @@ if [ "$code" != "403" ] ; then
121129
exit 1
122130
fi
123131

132+
# Error paths: a VT API failure must FAIL OPEN — the request is allowed through,
133+
# never denied (403) and never leaked as a server error (5xx) to the client.
134+
# 5.5.5.5 -> mock returns 500 ; 6.6.6.6 -> mock returns unparsable JSON.
135+
for bad_ip in 5.5.5.5 6.6.6.6 ; do
136+
echo "ℹ️ Testing fail-open when VT API errors for $bad_ip ..."
137+
code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: www.example.com" -H "X-Forwarded-For: $bad_ip" http://localhost/)"
138+
if [ "$code" != "200" ] ; then
139+
docker compose logs
140+
docker compose down -v
141+
echo "❌ Error: VT API failure for $bad_ip should fail open (got $code, expected 200)"
142+
exit 1
143+
fi
144+
done
145+
124146
if [ "$1" = "verbose" ] ; then
125147
docker compose logs
126148
fi

.tests/virustotal/vt-mock.conf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ server {
2222
location = /ip_addresses/1.2.3.4 {
2323
return 200 '{"data":{"attributes":{"last_analysis_stats":{"harmless":0,"malicious":10,"suspicious":0,"undetected":0,"timeout":0}}}}';
2424
}
25+
26+
# Error-path IPs: exercise the plugin's fail-open behaviour. A VT API failure
27+
# (5xx) or an unparsable body must NOT deny the request. 5.5.5.5 -> upstream
28+
# 500; 6.6.6.6 -> HTTP 200 with a body that is not valid JSON.
29+
location = /ip_addresses/5.5.5.5 {
30+
return 500 '{"error":{"code":"InternalError"}}';
31+
}
32+
location = /ip_addresses/6.6.6.6 {
33+
default_type text/plain;
34+
return 200 'this is not json';
35+
}
36+
2537
location /ip_addresses/ {
2638
return 200 '{"data":{"attributes":{"last_analysis_stats":{"harmless":80,"malicious":0,"suspicious":0,"undetected":0,"timeout":0}}}}';
2739
}

clamav/clamav.lua

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
local clamav_helpers = require("clamav.clamav_helpers")
12
local class = require("middleclass")
23
local plugin = require("bunkerweb.plugin")
34
local sha512 = require("resty.sha512")
@@ -17,18 +18,9 @@ local to_hex = str.to_hex
1718
local has_variable = utils.has_variable
1819
local get_deny_status = utils.get_deny_status
1920
local tonumber = tonumber
20-
local floor = math.floor
21-
22-
local stream_size = function(size)
23-
return ("%c%c%c%c")
24-
:format(
25-
size % 0x100,
26-
floor(size / 0x100) % 0x100,
27-
floor(size / 0x10000) % 0x100,
28-
floor(size / 0x1000000) % 0x100
29-
)
30-
:reverse()
31-
end
21+
-- The big-endian INSTREAM length prefix lives in clamav/clamav_helpers.lua so it
22+
-- can be unit-tested with busted outside OpenResty (see spec/clamav_helpers_spec.lua).
23+
local stream_size = clamav_helpers.stream_size
3224

3325
local read_all = function(form)
3426
while true do

clamav/clamav_helpers.lua

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- Pure helpers extracted from clamav.lua so they can be unit-tested with busted
2+
-- outside the OpenResty runtime. No ngx/resty dependencies — see
3+
-- spec/clamav_helpers_spec.lua.
4+
local floor = math.floor
5+
6+
local _M = {}
7+
8+
-- Encode a chunk length as the 4-byte big-endian (network byte order) prefix the
9+
-- ClamAV INSTREAM protocol expects before each chunk. The bytes are built
10+
-- little-endian then reversed, so byte 1 is the most-significant.
11+
function _M.stream_size(size)
12+
return ("%c%c%c%c")
13+
:format(
14+
size % 0x100,
15+
floor(size / 0x100) % 0x100,
16+
floor(size / 0x10000) % 0x100,
17+
floor(size / 0x1000000) % 0x100
18+
)
19+
:reverse()
20+
end
21+
22+
return _M

discord/discord.lua

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
local cjson = require("cjson")
22
local class = require("middleclass")
3+
local discord_helpers = require("discord.discord_helpers")
34
local http = require("resty.http")
45
local plugin = require("bunkerweb.plugin")
56
local utils = require("bunkerweb.utils")
@@ -20,8 +21,6 @@ local has_variable = utils.has_variable
2021
local get_variable = utils.get_variable
2122
local get_reason = utils.get_reason
2223
local tostring = tostring
23-
local len = string.len
24-
local sub = string.sub
2524
local format = string.format
2625
local encode = cjson.encode
2726
local floor = math.floor
@@ -48,13 +47,9 @@ function discord:log(bypass_use_discord)
4847
local timestamp = ngx_req.start_time()
4948
local formattedTimestamp = date("!%Y-%m-%dT%H:%M:%S", timestamp)
5049
local milliseconds = floor((timestamp - floor(timestamp)) * 1000)
51-
local formatField = function(inputString)
52-
if len(inputString) <= 1021 then
53-
return inputString
54-
else
55-
return sub(inputString, 1, 1021) .. "..."
56-
end
57-
end
50+
-- Discord caps embed field values at 1024 chars; truncation lives in
51+
-- discord/discord_helpers.lua (see spec/discord_helpers_spec.lua).
52+
local formatField = discord_helpers.format_field
5853

5954
local data = {
6055
username = "BunkerWeb",

discord/discord_helpers.lua

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Pure helpers extracted from discord.lua so they can be unit-tested with busted
2+
-- outside the OpenResty runtime. No ngx/resty dependencies — see
3+
-- spec/discord_helpers_spec.lua.
4+
local len = string.len
5+
local sub = string.sub
6+
7+
local _M = {}
8+
9+
-- Discord embed field values are capped at 1024 characters. Truncate to 1021 and
10+
-- append "..." so the value always fits, leaving shorter strings untouched.
11+
function _M.format_field(input_string)
12+
if len(input_string) <= 1021 then
13+
return input_string
14+
end
15+
return sub(input_string, 1, 1021) .. "..."
16+
end
17+
18+
return _M

0 commit comments

Comments
 (0)