diff --git a/README.md b/README.md index bd2de83..6c8576d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/matrix/README.md b/matrix/README.md new file mode 100644 index 0000000..86aa719 --- /dev/null +++ b/matrix/README.md @@ -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 + ... +``` + +## 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. | diff --git a/matrix/matrix.lua b/matrix/matrix.lua new file mode 100644 index 0000000..c1c8546 --- /dev/null +++ b/matrix/matrix.lua @@ -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), "[&<>]", { ["&"] = "&", ["<"] = "<", [">"] = ">" })) +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"] = "
Denied "
+ .. html_escape(request_method)
+ .. " from "
+ .. remote_addr
+ .. " ("
+ .. country
+ .. ' • "'
+ .. html_escape(asn_org)
+ .. '" • '
+ .. asn
+ .. ") to "
+ .. html_escape(request_host)
+ .. html_escape(self.ctx.bw.uri)
+ .. "
"
+ data["formatted_body"] = data["formatted_body"]
+ .. "Reason "
+ .. html_escape(reason)
+ .. " ("
+ .. html_escape(encode(reason_data or {}))
+ .. ").
| Header | Value |
|---|---|
| " + .. html_escape(header) + .. " | " + .. html_escape(header_value) + .. " |