Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Each plugin is located in a subdirectory of this repository. A README file locat
- [CrowdSec](https://github.com/bunkerity/bunkerweb-plugins/tree/main/crowdsec)
- [Coraza](https://github.com/bunkerity/bunkerweb-plugins/tree/main/coraza)
- [Discord](https://github.com/bunkerity/bunkerweb-plugins/tree/main/discord)
- [Matrix](https://github.com/bunkerity/bunkerweb-plugins/tree/main/matrix)
- [Slack](https://github.com/bunkerity/bunkerweb-plugins/tree/main/slack)
- [VirusTotal](https://github.com/bunkerity/bunkerweb-plugins/tree/main/virustotal)
- [WebHook](https://github.com/bunkerity/bunkerweb-plugins/tree/main/webhook)
Expand Down
93 changes: 93 additions & 0 deletions matrix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Matrix Notification Plugin

This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github) plugin will automatically send attack notifications to a Matrix room of your choice.

# Table of contents

- [Matrix Notification Plugin](#matrix-notification-plugin)
- [Table of contents](#table-of-contents)
- [Prerequisites](#prerequisites)
- [Setup](#setup)
- [Docker](#docker)
- [Swarm](#swarm)
- [Kubernetes](#kubernetes)
- [Settings](#settings)

# Prerequisites

Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation first.

You will need:

- A Matrix server URL (e.g., `https://matrix.org`).
- A valid access token for the Matrix user you want to send notifications from.
- A room ID where notifications will be sent to. The matrix user has to be Member of that room.

Please refer to your homeserver's docs if you need help setting these up.

# Setup

See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation for the installation procedure depending on your integration.

There is no additional service setup required beyond configuring the plugin itself.

## Docker

```yaml
version: '3'

services:

bunkerweb:
image: bunkerity/bunkerweb:1.5.9
...
environment:
- USE_MATRIX=yes
- MATRIX_BASE_URL=https://matrix.org
- MATRIX_ROOM_ID=!yourRoomID:matrix.org
- MATRIX_ACCESS_TOKEN=your-access-token
...
```
Comment on lines +34 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update the examples to the 1.6 image series.

Lines 42 and 60 still pin bunkerity/bunkerweb:1.5.10, but this PR is explicitly about 1.6 compatibility. The examples should not steer users onto the wrong runtime version.

As per coding guidelines, Prefer concrete instructions, accurate examples, and explicit prerequisites.

Also applies to: 52-68

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@matrix/README.md` around lines 34 - 50, Update the Docker examples in
matrix/README.md to reference the 1.6 image series: replace all occurrences of
the image tag "bunkerity/bunkerweb:1.5.10" (seen in the bunkerweb service
blocks) with the appropriate 1.6 tag (e.g., "bunkerity/bunkerweb:1.6" or the
exact 1.6.x release you intend to document) so examples match this PR's 1.6
compatibility; ensure both instances mentioned in the diff are updated.


## Swarm

```yaml
version: '3'

services:

mybunker:
image: bunkerity/bunkerweb:1.5.9
..
environment:
- USE_MATRIX=yes
- MATRIX_BASE_URL=https://matrix.org
- MATRIX_ROOM_ID=!yourRoomID:matrix.org
- MATRIX_ACCESS_TOKEN=your-access-token
...
```

## Kubernetes

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress
annotations:
bunkerweb.io/USE_MATRIX: "yes"
bunkerweb.io/MATRIX_BASE_URL: "https://matrix.org"
bunkerweb.io/MATRIX_ROOM_ID: "!yourRoomID:matrix.org"
bunkerweb.io/MATRIX_ACCESS_TOKEN: "your-access-token"
```

# Settings

| Setting | Default | Context | Multiple | Description |
| ------------------------ | ------------------------ | --------- | -------- | --------------------------------------------------------- |
| `USE_MATRIX` | `no` | multisite | no | Enable sending alerts to a Matrix room. |
| `MATRIX_BASE_URL` | `https://matrix.org` | global | no | Base URL of the Matrix server (e.g., https://matrix.org). |
| `MATRIX_ROOM_ID` | `!yourRoomID:matrix.org` | global | no | Room ID of the Matrix room to send notifications to. |
| `MATRIX_ACCESS_TOKEN` | | global | no | Access token to authenticate with the Matrix server. |
| `MATRIX_ANONYMIZE_IP` | `no` | global | no | Mask the IP address in notifications. |
| `MATRIX_INCLUDE_HEADERS` | `no` | global | no | Include request headers in notifications. |
290 changes: 290 additions & 0 deletions matrix/matrix.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
local cjson = require("cjson")
local class = require("middleclass")
local http = require("resty.http")
local matrix_utils = require("matrix.utils")
local plugin = require("bunkerweb.plugin")
local utils = require("bunkerweb.utils")

local matrix = class("matrix", plugin)

local ngx = ngx
local ngx_req = ngx.req
local ERR = ngx.ERR
local INFO = ngx.INFO
local ngx_timer = ngx.timer
local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR
local HTTP_OK = ngx.HTTP_OK
local http_new = http.new
local has_variable = utils.has_variable
local get_variable = utils.get_variable
local get_reason = utils.get_reason
local get_country = utils.get_country
local get_asn = utils.get_asn
local get_asn_org = matrix_utils.get_asn_org
local tostring = tostring
local encode = cjson.encode
local escape_uri = ngx.escape_uri

-- Escape characters that are significant in the org.matrix.custom.html body so that
-- attacker-controlled values (URI, Host, header names/values) can't break the markup.
local function html_escape(str)
return (string.gsub(tostring(str), "[&<>]", { ["&"] = "&amp;", ["<"] = "&lt;", [">"] = "&gt;" }))
end

-- Escape Lua pattern magic characters so a literal string can be used as a gsub pattern.
local function escape_pattern(str)
return (string.gsub(str, "([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1"))
end

-- Mask an IP for notifications. Handles both IPv4 and IPv6.
local function anonymize_ip(ip)
if string.find(ip, ":", 1, true) then
-- IPv6: keep the first three hextets, mask the remainder
local prefix = string.match(ip, "^(%x*:%x*:%x*):")
return prefix and (prefix .. ":xxxx") or "xxxx::xxxx"
end
-- IPv4: mask the last two octets
return (string.gsub(ip, "%d+%.%d+$", "xxx.xxx"))
end

-- Per-worker, monotonically increasing counter to guarantee transaction-ID uniqueness.
-- ngx.now() is cached per event-loop cycle, so time + pid alone can still collide.
local txn_counter = 0

-- Build the Matrix "send message" endpoint URL.
-- The room ID (e.g. "!abc:matrix.org") must be percent-encoded in the path, and the
-- transaction ID must be unique: os.time() has 1s resolution and Matrix silently drops
-- duplicate transaction IDs, so bursts within the same second would lose notifications.
local function message_url(self)
local base_url = string.gsub(self.variables["MATRIX_BASE_URL"], "/+$", "")
local room_id = self.variables["MATRIX_ROOM_ID"]
txn_counter = txn_counter + 1
local txn_id = string.format("%d_%d_%d", math.floor(ngx.now() * 1000), ngx.worker.pid(), txn_counter)
return string.format("%s/_matrix/client/r0/rooms/%s/send/m.room.message/%s", base_url, escape_uri(room_id), txn_id)
end

function matrix:initialize(ctx)
-- Call parent initialize
plugin.initialize(self, "matrix", ctx)
end

function matrix:log(bypass_use_matrix)
-- Check if matrix is enabled
if not bypass_use_matrix then
if self.variables["USE_MATRIX"] ~= "yes" then
return self:ret(true, "matrix plugin not enabled")
end
end
-- Check if request is denied
local reason, reason_data = get_reason(self.ctx)
if reason == nil then
return self:ret(true, "request not denied")
end
-- Compute data
local request_host = ngx.var.host or "unknown host"
local remote_addr = self.ctx.bw.remote_addr
local request_method = self.ctx.bw.request_method
local country, err = get_country(remote_addr)
if not country then
self.logger:log(ERR, "can't get Country of IP " .. remote_addr .. " : " .. err)
country = "Country unknown"
else
country = tostring(country)
end
local asn
asn, err = get_asn(remote_addr)
if not asn then
self.logger:log(ERR, "can't get ASN of IP " .. remote_addr .. " : " .. err)
asn = "ASN unknown"
else
asn = "ASN " .. tostring(asn)
end
local asn_org
asn_org, err = get_asn_org(remote_addr)
if not asn_org then
self.logger:log(ERR, "can't get Organization of IP " .. remote_addr .. " : " .. err)
asn_org = "AS Organization unknown"
else
asn_org = tostring(asn_org)
end
local data = {}
data["formatted_body"] = "<p>Denied "
.. html_escape(request_method)
.. " from <b>"
.. remote_addr
.. "</b> ("
.. country
.. ' • "<i>'
.. html_escape(asn_org)
.. '</i>" • '
.. asn
.. ") to "
.. html_escape(request_host)
.. html_escape(self.ctx.bw.uri)
.. "<br>"
data["formatted_body"] = data["formatted_body"]
.. "Reason <b>"
.. html_escape(reason)
.. "</b> ("
.. html_escape(encode(reason_data or {}))
.. ").</p>"
data["body"] = "Denied "
.. request_method
.. " from "
.. remote_addr
.. " ("
.. country
.. ' • "'
.. asn_org
.. '" • '
.. asn
.. ") to "
.. request_host
.. self.ctx.bw.uri
.. "\n"
data["body"] = data["body"] .. "Reason " .. reason .. " (" .. encode(reason_data or {}) .. ")."
-- Add headers if enabled
if self.variables["MATRIX_INCLUDE_HEADERS"] == "yes" then
local headers
headers, err = ngx_req.get_headers()
if not headers then
data["formatted_body"] = data["formatted_body"] .. "error while getting headers: " .. err
data["body"] = data["body"] .. "\n error while getting headers: " .. err
else
data["formatted_body"] = data["formatted_body"] .. "<table><tr><th>Header</th><th>Value</th></tr>"
data["body"] = data["body"] .. "\n\n"
for header, value in pairs(headers) do
-- Repeated headers are returned as a table by ngx.req.get_headers()
local header_value = type(value) == "table" and table.concat(value, ", ") or value
data["formatted_body"] = data["formatted_body"]
.. "<tr><td>"
.. html_escape(header)
.. "</td><td>"
.. html_escape(header_value)
.. "</td></tr>"
data["body"] = data["body"] .. header .. ": " .. header_value .. "\n"
end
data["formatted_body"] = data["formatted_body"] .. "</table>"
end
end
-- Anonymize IP if enabled
if self.variables["MATRIX_ANONYMIZE_IP"] == "yes" then
local masked = anonymize_ip(remote_addr)
local pattern = escape_pattern(remote_addr)
data["formatted_body"] = (string.gsub(data["formatted_body"], pattern, masked))
data["body"] = (string.gsub(data["body"], pattern, masked))
end
-- Send request
local hdr
hdr, err = ngx_timer.at(0, self.send, self, data)
if not hdr then
return self:ret(true, "can't create report timer: " .. err)
end
return self:ret(true, "scheduled timer")
end

-- luacheck: ignore 212
function matrix.send(premature, self, data)
local httpc, err = http_new()
if not httpc then
self.logger:log(ERR, "can't instantiate http object : " .. err)
return
end
-- Prepare data
local access_token = self.variables["MATRIX_ACCESS_TOKEN"]
local url = message_url(self)
local message_data = {
msgtype = "m.text",
body = data["body"],
format = "org.matrix.custom.html",
formatted_body = data["formatted_body"],
}
local post_data = encode(message_data)
-- Send request
local res, err_http = httpc:request_uri(url, {
method = "PUT",
body = post_data,
headers = {
["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. access_token,
},
})
Comment on lines +204 to +211

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Locate the file and check its existence
find . -name "matrix.lua" -type f

Repository: bunkerity/bunkerweb-plugins

Length of output: 90


🏁 Script executed:

# Read the relevant sections of the file
if [ -f "matrix/matrix.lua" ]; then
  echo "=== Lines 124-131 ===" && sed -n '124,131p' matrix/matrix.lua
  echo ""
  echo "=== Lines 190-197 ===" && sed -n '190,197p' matrix/matrix.lua
  echo ""
  echo "=== Full file context (first check size) ===" && wc -l matrix/matrix.lua
fi

Repository: bunkerity/bunkerweb-plugins

Length of output: 662


🏁 Script executed:

# Search for all httpc:request_uri calls to understand timeout usage
rg "httpc:request_uri" -A 5 matrix/

Repository: bunkerity/bunkerweb-plugins

Length of output: 723


🏁 Script executed:

# Check how other plugins (coraza, virustotal, clamav) handle HTTP timeouts
rg "set_timeout|timeout" -A 2 -B 2 --type lua | head -100

Repository: bunkerity/bunkerweb-plugins

Length of output: 1683


🏁 Script executed:

# Search for resty.http usage in other plugins to see timeout patterns
rg "httpc.*=" -A 10 --type lua | grep -A 10 "resty.http" | head -80

Repository: bunkerity/bunkerweb-plugins

Length of output: 53


🏁 Script executed:

# Look for timeout patterns in virustotal.lua and coraza.lua specifically
for file in virustotal.lua coraza.lua clamav.lua; do
  echo "=== $file ===" 
  if [ -f "*/$file" ]; then
    find . -name "$file" -exec cat {} \; | grep -A 5 -B 5 "timeout\|request_uri" | head -50
  fi
done

Repository: bunkerity/bunkerweb-plugins

Length of output: 131


🏁 Script executed:

# Search for httpc initialization and set_timeout calls
rg "set_timeout|connect_timeout|send_timeout|read_timeout" --type lua

Repository: bunkerity/bunkerweb-plugins

Length of output: 496


Add explicit HTTP timeouts to Matrix requests.

Both httpc:request_uri() calls (lines 124–131 and 190–197) lack timeout configuration. Since these requests run on the worker thread, an unresponsive homeserver can stall processing and exhaust worker capacity. Call httpc:set_timeout() before request_uri() to match the pattern used in coraza and crowdsec plugins — for example, httpc:set_timeout(1000) for a 1-second timeout, or httpc:set_timeouts() for separate connect/send/read timeouts if configurable durations are preferred.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@matrix/matrix.lua` around lines 124 - 131, The Matrix HTTP calls use
httpc:request_uri() without timeouts; before each request_uri call (e.g., the
PUT call that sends post_data to url using access_token and the other
request_uri call later) call httpc:set_timeout(1000) or httpc:set_timeouts(...)
to enforce a 1s (or configured) timeout so the worker thread won't block; update
both places that call httpc:request_uri() to set the timeout on the httpc
instance immediately before invoking request_uri().

httpc:close()
if not res then
self.logger:log(ERR, "error while sending request : " .. err_http)
return
end
if res.status < 200 or res.status > 299 then
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self.logger:log(ERR, "request returned status " .. tostring(res.status))
return
end
self.logger:log(INFO, "request sent to matrix")
end

function matrix:log_default()
-- Check if matrix is activated
local check, err = has_variable("USE_MATRIX", "yes")
if check == nil then
return self:ret(false, "error while checking variable USE_MATRIX (" .. err .. ")")
end
if not check then
return self:ret(true, "matrix plugin not enabled")
end
-- Check if default server is disabled
check, err = get_variable("DISABLE_DEFAULT_SERVER", false)
if check == nil then
return self:ret(false, "error while getting variable DISABLE_DEFAULT_SERVER (" .. err .. ")")
end
if check ~= "yes" then
return self:ret(true, "default server not disabled")
end
-- Call log method
return self:log(true)
end

function matrix:api()
if self.ctx.bw.uri == "/matrix/ping" and self.ctx.bw.request_method == "POST" then
-- Check matrix connection
local check, err = has_variable("USE_MATRIX", "yes")
if check == nil then
return self:ret(true, "error while checking variable USE_MATRIX (" .. err .. ")")
end
if not check then
return self:ret(true, "matrix plugin not enabled")
end
-- Prepare data
local access_token = self.variables["MATRIX_ACCESS_TOKEN"]
local url = message_url(self)
local message_data = {
msgtype = "m.text",
body = "Test message from bunkerweb.",
}
-- Send request
local httpc
httpc, err = http_new()
if not httpc then
self.logger:log(ERR, "can't instantiate http object : " .. err)
return self:ret(true, "can't instantiate http object", HTTP_INTERNAL_SERVER_ERROR)
end
local res, err_http = httpc:request_uri(url, {
method = "PUT",
headers = {
["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. access_token,
},
body = encode(message_data),
})
httpc:close()
if not res then
self.logger:log(ERR, "error while sending request : " .. err_http)
return self:ret(true, "error while sending request", HTTP_INTERNAL_SERVER_ERROR)
end
if res.status < 200 or res.status > 299 then
return self:ret(true, "request returned status " .. tostring(res.status), HTTP_INTERNAL_SERVER_ERROR)
end
return self:ret(true, "request sent to matrix", HTTP_OK)
end
return self:ret(false, "success")
end

return matrix
Loading