Skip to content

Commit 34cba8f

Browse files
committed
fix(notifiers): redact credential headers, harden http error paths
1 parent 67f2fa3 commit 34cba8f

24 files changed

Lines changed: 410 additions & 49 deletions

.tests/notifier.sh

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ check_echo_notifier() {
8282
local plugin="$1" path="$2" shape="$3" site found="ko" code r=0
8383
site="${plugin}.example.com"
8484
echo "ℹ️ [$plugin] provoking deny on http://$site/blocked ..."
85-
code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: $site" http://localhost/blocked)"
85+
# Send a credential header: the notifier must redact it (see redact_header in
86+
# each plugin's *_helpers.lua) before forwarding the request to the sink.
87+
code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: $site" -H "Cookie: redactme-supersecret" http://localhost/blocked)"
8688
if [ "$code" != "403" ] ; then
8789
echo "❌ [$plugin] expected 403 on /blocked, got $code"
8890
fail=1
@@ -120,7 +122,7 @@ check_echo_notifier matrix "/_matrix/client" '"formatted_body"'
120122
# DISCORD_RETRY_IF_LIMITED=yes the plugin retries once, so the mock should see
121123
# two requests to /discord.
122124
echo "ℹ️ [discord] provoking deny (retry path) ..."
123-
code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: discord.example.com" http://localhost/blocked)"
125+
code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: discord.example.com" -H "Cookie: redactme-supersecret" http://localhost/blocked)"
124126
if [ "$code" != "403" ] ; then
125127
echo "❌ [discord] expected 403 on /blocked, got $code"
126128
fail=1
@@ -149,12 +151,38 @@ else
149151
fail=1
150152
fi
151153

154+
# discord posts to the ratelimit sink (not the echo mock), so assert its
155+
# header redaction separately: the Cookie sent on the deny must be [REDACTED].
156+
if docker compose logs ratelimit 2>/dev/null | grep -qF "redactme-supersecret" ; then
157+
echo "❌ [discord] sensitive header value leaked to the notifier payload"
158+
fail=1
159+
elif docker compose logs ratelimit 2>/dev/null | grep -qF "[REDACTED]" ; then
160+
echo "✔️ [discord] sensitive header redacted in the notifier payload"
161+
else
162+
echo "❌ [discord] expected [REDACTED] marker missing from the notifier payload"
163+
fail=1
164+
fi
165+
152166
# Cross-check the denied payload reached the echo mock (slack/webhook).
153167
if ! docker compose logs mock 2>/dev/null | grep -qF "Denied request for IP" ; then
154168
echo "❌ echo mock never received a denied-request payload"
155169
fail=1
156170
fi
157171

172+
# Sensitive-header redaction: the Cookie value sent on the denied requests must
173+
# never reach the notification sink — it is replaced by [REDACTED]. This guards
174+
# the redact_header() logic shared by all notifier plugins (slack/webhook/matrix
175+
# post their header table to the echo mock).
176+
if docker compose logs mock 2>/dev/null | grep -qF "redactme-supersecret" ; then
177+
echo "❌ sensitive header value leaked to the notifier payload"
178+
fail=1
179+
elif docker compose logs mock 2>/dev/null | grep -qF "[REDACTED]" ; then
180+
echo "✔️ sensitive header redacted in the notifier payload"
181+
else
182+
echo "❌ expected [REDACTED] marker missing from the notifier payload"
183+
fail=1
184+
fi
185+
158186
if [ "$fail" -ne 0 ] ; then
159187
docker compose logs
160188
docker compose down -v

COMPATIBILITY.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@
1414
"1.6.0-rc2",
1515
"1.6.0-rc3",
1616
"1.6.0-rc4",
17-
"1.6.0"
17+
"1.6.0",
18+
"1.6.1",
19+
"1.6.2",
20+
"1.6.3",
21+
"1.6.4",
22+
"1.6.5",
23+
"1.6.6",
24+
"1.6.7",
25+
"1.6.8",
26+
"1.6.9",
27+
"1.6.10",
28+
"1.6.11"
1829
]
1930
}

clamav/README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,12 @@ metadata:
145145
146146
# Settings
147147
148-
| Setting | Default | Context | Multiple | Description |
149-
| ---------------- | -------- | --------- | -------- | ------------------------------------------------------- |
150-
| `USE_CLAMAV` | `no` | multisite | no | Activate automatic scan of uploaded files with ClamAV. |
151-
| `CLAMAV_HOST` | `clamav` | global | no | ClamAV hostname or IP address. |
152-
| `CLAMAV_PORT` | `3310` | global | no | ClamAV port. |
153-
| `CLAMAV_TIMEOUT` | `1000` | global | no | Network timeout (in ms) when communicating with ClamAV. |
148+
| Setting | Default | Context | Multiple | Description |
149+
| ---------------- | -------- | --------- | -------- | -------------------------------------------------------------------------------------- |
150+
| `USE_CLAMAV` | `no` | multisite | no | Activate automatic scan of uploaded files with ClamAV. |
151+
| `CLAMAV_HOST` | `clamav` | global | no | ClamAV hostname or IP address. |
152+
| `CLAMAV_PORT` | `3310` | global | no | ClamAV port. |
153+
| `CLAMAV_TIMEOUT` | `1000` | global | no | Network timeout in milliseconds when communicating with ClamAV (e.g. 1000 = 1 second). |
154154

155155
# TODO
156156

clamav/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"CLAMAV_TIMEOUT": {
3636
"context": "global",
3737
"default": "1000",
38-
"help": "Network timeout (in ms) when communicating with ClamAV.",
38+
"help": "Network timeout in milliseconds when communicating with ClamAV (e.g. 1000 = 1 second).",
3939
"id": "clamav-timeout",
4040
"label": "Network timeout",
4141
"regex": "^.*$",

clamav/ui/actions.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@ def pre_render(**kwargs):
1818
except BaseException as e:
1919
logger.debug(format_exc())
2020
logger.error(f"Failed to get clamav ping: {e}")
21-
ret["error"] = str(e)
22-
23-
if "error" in ret:
24-
return ret
21+
ret["error"] = "Could not retrieve the plugin status"
2522

2623
return ret
2724

coraza/ui/actions.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@ def pre_render(**kwargs):
1818
except BaseException as e:
1919
logger.debug(format_exc())
2020
logger.error(f"Failed to get coraza ping: {e}")
21-
ret["error"] = str(e)
22-
23-
if "error" in ret:
24-
return ret
21+
ret["error"] = "Could not retrieve the plugin status"
2522

2623
return ret
2724

discord/discord.lua

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ local format = string.format
2525
local encode = cjson.encode
2626
local floor = math.floor
2727
local date = os.date
28+
local redact_header = discord_helpers.redact_header
2829

2930
function discord:initialize(ctx)
3031
-- Call parent initialize
@@ -98,14 +99,18 @@ function discord:log(bypass_use_discord)
9899
if count > 23 then
99100
data.embeds[1].description = "Headers :\n```"
100101
for header, value in pairs(headers) do
101-
data.embeds[1].description = data.embeds[1].description .. header .. ": " .. value .. "\n"
102+
data.embeds[1].description = data.embeds[1].description
103+
.. header
104+
.. ": "
105+
.. redact_header(header, value)
106+
.. "\n"
102107
end
103108
data.embeds[1].description = data.embeds[1].description .. "```"
104109
else
105110
for header, value in pairs(headers) do
106111
table.insert(data.embeds[1].fields, {
107112
name = header,
108-
value = formatField(value),
113+
value = formatField(redact_header(header, value)),
109114
inline = true,
110115
})
111116
end
@@ -125,6 +130,7 @@ function discord.send(premature, self, data)
125130
local httpc, err = http_new()
126131
if not httpc then
127132
self.logger:log(ERR, "can't instantiate http object : " .. err)
133+
return
128134
end
129135
local res, err_http = httpc:request_uri(self.variables["DISCORD_WEBHOOK_URL"], {
130136
method = "POST",
@@ -136,6 +142,7 @@ function discord.send(premature, self, data)
136142
httpc:close()
137143
if not res then
138144
self.logger:log(ERR, "error while sending request : " .. err_http)
145+
return
139146
end
140147
if self.variables["DISCORD_RETRY_IF_LIMITED"] == "yes" and res.status == 429 and res.headers["Retry-After"] then
141148
self.logger:log(WARN, "Discord API is rate-limiting us, retrying in " .. res.headers["Retry-After"] .. "s")
@@ -211,6 +218,7 @@ function discord:api()
211218
httpc, err = http_new()
212219
if not httpc then
213220
self.logger:log(ERR, "can't instantiate http object : " .. err)
221+
return self:ret(true, "can't instantiate http object", HTTP_INTERNAL_SERVER_ERROR)
214222
end
215223
local res, err_http = httpc:request_uri(self.variables["DISCORD_WEBHOOK_URL"], {
216224
method = "POST",

discord/discord_helpers.lua

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,30 @@
33
-- spec/discord_helpers_spec.lua.
44
local len = string.len
55
local sub = string.sub
6+
local lower = string.lower
7+
local concat = table.concat
8+
local tostring = tostring
9+
local type = type
610

711
local _M = {}
812

13+
-- Request headers that carry credentials/secrets. Their values are never
14+
-- forwarded to the third-party notification service. Keys are lowercase so the
15+
-- lookup is case-insensitive (HTTP header names are case-insensitive).
16+
local SENSITIVE_HEADERS = {
17+
["authorization"] = true,
18+
["proxy-authorization"] = true,
19+
["cookie"] = true,
20+
["set-cookie"] = true,
21+
["x-api-key"] = true,
22+
["x-csrf-token"] = true,
23+
["x-xsrf-token"] = true,
24+
["x-auth-token"] = true,
25+
["x-access-token"] = true,
26+
["x-session-token"] = true,
27+
["x-amz-security-token"] = true,
28+
}
29+
930
-- Discord embed field values are capped at 1024 characters. Truncate to 1021 and
1031
-- append "..." so the value always fits, leaving shorter strings untouched.
1132
function _M.format_field(input_string)
@@ -15,4 +36,22 @@ function _M.format_field(input_string)
1536
return sub(input_string, 1, 1021) .. "..."
1637
end
1738

39+
-- Repeated headers are returned by ngx.req.get_headers() as an array table.
40+
-- Flatten to a single string so downstream concatenation never fails on a table.
41+
function _M.flatten_header_value(value)
42+
if type(value) == "table" then
43+
return concat(value, ", ")
44+
end
45+
return tostring(value)
46+
end
47+
48+
-- Return a notification-safe value for a header: "[REDACTED]" for sensitive
49+
-- headers, otherwise the flattened value.
50+
function _M.redact_header(name, value)
51+
if SENSITIVE_HEADERS[lower(name)] then
52+
return "[REDACTED]"
53+
end
54+
return _M.flatten_header_value(value)
55+
end
56+
1857
return _M

discord/ui/actions.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@ def pre_render(**kwargs):
1818
except BaseException as e:
1919
logger.debug(format_exc())
2020
logger.error(f"Failed to get discord ping: {e}")
21-
ret["error"] = str(e)
22-
23-
if "error" in ret:
24-
return ret
21+
ret["error"] = "Could not retrieve the plugin status"
2522

2623
return ret
2724

matrix/matrix.lua

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ local escape_uri = ngx.escape_uri
3131
local html_escape = matrix_helpers.html_escape
3232
local escape_pattern = matrix_helpers.escape_pattern
3333
local anonymize_ip = matrix_helpers.anonymize_ip
34+
local redact_header = matrix_helpers.redact_header
3435

3536
-- Per-worker, monotonically increasing counter to guarantee transaction-ID uniqueness.
3637
-- ngx.now() is cached per event-loop cycle, so time + pid alone can still collide.
@@ -96,7 +97,7 @@ function matrix:log(bypass_use_matrix)
9697
data["formatted_body"] = "<p>Denied "
9798
.. html_escape(request_method)
9899
.. " from <b>"
99-
.. remote_addr
100+
.. html_escape(remote_addr)
100101
.. "</b> ("
101102
.. country
102103
.. ' • "<i>'
@@ -139,8 +140,9 @@ function matrix:log(bypass_use_matrix)
139140
data["formatted_body"] = data["formatted_body"] .. "<table><tr><th>Header</th><th>Value</th></tr>"
140141
data["body"] = data["body"] .. "\n\n"
141142
for header, value in pairs(headers) do
142-
-- Repeated headers are returned as a table by ngx.req.get_headers()
143-
local header_value = type(value) == "table" and table.concat(value, ", ") or value
143+
-- Redact credential headers and flatten repeated headers (which arrive
144+
-- as a table from ngx.req.get_headers()) before leaving the instance.
145+
local header_value = redact_header(header, value)
144146
data["formatted_body"] = data["formatted_body"]
145147
.. "<tr><td>"
146148
.. html_escape(header)

0 commit comments

Comments
 (0)