diff --git a/.busted b/.busted
new file mode 100644
index 0000000..559bcc3
--- /dev/null
+++ b/.busted
@@ -0,0 +1,10 @@
+return {
+ default = {
+ -- Run from the repo root so `require("authentik/authentik_helpers")`
+ -- resolves authentik/authentik_helpers.lua, mirroring how BunkerWeb
+ -- requires plugins ("/").
+ lpath = "./?.lua",
+ ROOT = { "spec" },
+ pattern = "_spec",
+ },
+}
diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 0000000..0b8b91f
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,333 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+language: "en-GB"
+tone_instructions: "Be direct, technical, and actionable. Cite exact lines. Prefer concrete guidance over vague suggestions. Use British English."
+early_access: false
+
+knowledge_base:
+ opt_out: false
+ web_search:
+ enabled: false
+ code_guidelines:
+ enabled: true
+
+code_generation:
+ docstrings:
+ language: "en-GB"
+
+reviews:
+ profile: "assertive"
+ request_changes_workflow: false
+ high_level_summary: true
+ high_level_summary_in_walkthrough: true
+ high_level_summary_instructions: |
+ Summarise the PR by plugin (`clamav`, `coraza`, `discord`, `slack`, `virustotal`, `webhook`) and by shared area (`.tests`, `.github`, `misc`, repo root).
+ For Coraza, distinguish changes to `coraza.lua` (the OpenResty client) from changes to `coraza/api/**` (the standalone Go HTTP service shipped as `bunkerity/bunkerweb-coraza`).
+ Always call out: security impact, user-visible behaviour changes, `plugin.json` schema changes, `COMPATIBILITY.json` updates, plugin version bumps, packaging or deployment impact, and whether documentation/tests were updated.
+ Flag any drift between the per-plugin version in `plugin.json` and the collection version tracked in `COMPATIBILITY.json` / the README badge.
+ Keep the summary terse, technical, and suitable for maintainers reviewing external plugins for a security-critical WAF project.
+ review_status: true
+ review_details: true
+ collapse_walkthrough: false
+ changed_files_summary: true
+ sequence_diagrams: false
+ estimate_code_review_effort: true
+ assess_linked_issues: true
+ related_issues: true
+ related_prs: true
+ suggested_labels: false
+ suggested_reviewers: false
+ enable_prompt_for_ai_agents: true
+ # Fun features — keep CodeRabbit cheerful
+ poem: true
+ in_progress_fortune: true
+ commit_status: true
+ fail_commit_status: false
+ abort_on_close: true
+ auto_review:
+ enabled: true
+ drafts: false
+ auto_incremental_review: true
+ auto_pause_after_reviewed_commits: 8
+ base_branches:
+ - "dev"
+ - "main"
+ ignore_title_keywords:
+ - "WIP"
+ - "[WIP]"
+ - "RFC"
+ - "DO NOT REVIEW"
+ ignore_usernames:
+ - "dependabot[bot]"
+
+ slop_detection:
+ enabled: true
+ label: "needs-review"
+
+ pre_merge_checks:
+ title:
+ mode: "warning"
+ requirements: |
+ Use Conventional Commits or the " - description" format already used in bunkerweb-plugins history.
+ Examples: "feat: add configurable timeout for VirusTotal API requests", "fix: update plugin versions from 1.9 to 1.10", "refactor: remove CrowdSec configurations", "ci/cd - fix BW version".
+ Keep titles under ~70 characters; put detail in the description.
+ description:
+ mode: "warning"
+ issue_assessment:
+ mode: "off"
+ docstrings:
+ mode: "off"
+
+ finishing_touches:
+ # Auto-generated tests risk false confidence on a WAF; contributors write them.
+ unit_tests:
+ enabled: false
+
+ path_filters:
+ - "**"
+ - "!LICENSE.md"
+ # Vendored OWASP Core Rule Set — pulled in at build time by coraza/api/crs.sh
+ - "!coraza/api/coreruleset/**"
+ # Plugin architecture diagrams (Mermaid, embedded inline in each README)
+ - "!**/docs/diagram.mmd"
+ # Release artefacts that occasionally sit at the repo root
+ - "!**/*.zip"
+ - "!**/node_modules/**"
+ - "!**/.venv/**"
+ - "!**/venv/**"
+ - "!**/.pytest_cache/**"
+ - "!**/__pycache__/**"
+ - "!**/dist/**"
+ - "!**/build/**"
+ - "!**/*.min.js"
+ - "!**/*.log.txt"
+ - "!**/*.patch*"
+ - "!.claude/**"
+ - "!.gemini/**"
+ # Documentation assets — exclude images and PDFs to avoid review noise
+ - "!**/docs/**/*.png"
+ - "!**/docs/**/*.jpg"
+ - "!**/docs/**/*.jpeg"
+ - "!**/docs/**/*.gif"
+ - "!**/docs/**/*.pdf"
+ - "!**/images/**"
+
+ path_instructions:
+ - path: "**/*.py"
+ instructions: |
+ Follow BunkerWeb's Python standards and security posture:
+ - Use snake_case for functions and variables, PascalCase for classes, and provide concise, accurate docstrings for public classes, functions, and methods.
+ - Respect Black formatting with a 160-character line limit (`pyproject.toml`) and flake8 with `--max-line-length=250 --ignore=E266,E402,E722,W503` from `.pre-commit-config.yaml`.
+ - Catch specific exceptions; never use bare `except:`. Catching `BaseException` / `Exception` is acceptable only at explicit process boundaries (for example UI `pre_render` hooks that must degrade gracefully) when the code logs enough context and either re-raises, returns an error status, or terminates safely. `ui/actions.py` uses `BaseException` deliberately — do not regress that to a bare `except:`.
+ - Never invoke the shell from Python via `os.system(...)`, `subprocess` with `shell=True`, `eval`, or `exec`. Pass subprocess arguments as a list and prefer explicit binary paths for privileged operations.
+ - Do not use unsafe binary deserialisation libraries for untrusted data. Use `yaml.safe_load()` rather than unsafe YAML loading.
+ - Open files with an explicit encoding (normally `utf-8`) and use `with` statements for files, sockets, and temporary resources.
+ - For HTTP clients (`requests`, `httpx`): always set an explicit timeout, validate destination URLs to block RFC1918/loopback/link-local ranges (SSRF), disable automatic redirects to internal hosts, and be careful with proxy settings.
+ - Scrub secrets, tokens, cookies, database URIs, webhook URLs, and `Authorization` headers from logs. Use the logging framework rather than `print()`.
+ - Secrets, API keys, and credentials must never be hard-coded; read them from plugin settings or environment variables.
+
+ - path: "*/ui/actions.py"
+ instructions: |
+ Per-plugin `ui/actions.py` modules hook into the BunkerWeb web UI:
+ - `pre_render(**kwargs)` must return a dict of card data and must never raise out to the caller. The existing pattern wraps the body in `try/except BaseException` and logs via `getLogger("UI")` + `format_exc()`; preserve that posture.
+ - The top-level function named after the plugin (e.g. `discord(**kwargs)`) is the main-page entry point; keep it side-effect free unless the plugin genuinely needs server-side state.
+ - `kwargs["bw_instances_utils"]` exposes BW helpers such as `get_ping(service)`; do not assume other keys exist without checking.
+ - Never expose secrets, webhook URLs, API keys, or tokens in the returned card payload — they are rendered into the UI.
+ - Keep the module importable with only the BunkerWeb UI runtime; do not add heavy third-party dependencies without justification.
+
+ - path: "**/*.lua"
+ instructions: |
+ Lua code runs on OpenResty inside the BunkerWeb nginx container and sits on the request hot path:
+ - Every plugin subclasses `bunkerweb.plugin` via `middleclass`: `local = class("", plugin)` and `plugin.initialize(self, "", ctx)` in `initialize`. Hook methods (`init_worker`, `access`, `log`, `preread`, ...) must return `self:ret(ok_bool, msg, [http_status])`. To deny a request, return `self:ret(true, "reason", utils.get_deny_status())`.
+ - Gate expensive work at `init_worker` with `utils.has_variable("USE_", "yes")` and skip when `self.is_loading` is true, matching the pattern used across `clamav.lua`, `coraza.lua`, and `virustotal.lua`.
+ - Use local variables and local module tables; avoid globals. Cache `ngx.var.*` and `ngx.req.*` values in locals instead of re-reading them repeatedly.
+ - Precompile regular expressions in module-level locals; never compile inside request loops. For `ngx.re.match`/`find`/`gmatch`/`sub`, pass the option string `"jo"` (`j` enables PCRE JIT, `o` compiles the pattern once and caches it), anchor patterns with `^...$` when a full match is intended, and cap input length before matching to prevent ReDoS.
+ - Validate and sanitise all request-derived input. Never evaluate request-derived code via `load`, `loadstring`, or similar mechanisms.
+ - Use `ngx.socket` for raw TCP (see the ClamAV INSTREAM pattern in `clamav.lua`) and `resty.http` for HTTP upstreams (see `virustotal.lua` and `coraza.lua`). Prefer `resty.upload` for streaming request bodies — `clamav.lua` is the reference.
+ - Cache upstream scan results by a strong digest of the body (SHA-512 in `virustotal.lua`) rather than by filename or weaker hashes; mind cache key length.
+ - Shared-dictionary read-modify-write sequences are race-prone; prefer atomic operations such as `incr` or explicit locking where correctness matters.
+ - Never log request bodies, cookies, bearer tokens, webhook secrets, or API keys.
+ - Use `pcall` or explicit error handling at safe boundaries so a malformed request or upstream failure does not crash worker processes.
+ - Use `cjson` safely for JSON encode/decode and do not build JSON by string concatenation.
+ - Respect the stylua config in `stylua.toml` and the luacheck config in `.luacheckrc` (`--std min --codes --ranges --no-cache`).
+
+ - path: "**/plugin.json"
+ instructions: |
+ `plugin.json` files define the settings schema that BunkerWeb reads to register settings and render the UI:
+ - The top-level fields `id`, `name`, `version`, and `stream` (`yes`/`no`/`partial`) must stay consistent with the plugin directory name and its Lua module.
+ - Ensure setting IDs remain stable unless there is an intentional breaking change documented in the PR.
+ - Each setting must declare `context` (`global` or `multisite`), `default`, `help`, `id`, `label`, `regex`, and `type`. Regex validators must be anchored where appropriate, compile cleanly, and avoid catastrophic backtracking. Default values must satisfy their own validators.
+ - `USE_` toggles are the standard gate for `init_worker` short-circuiting; keep them in sync with the Lua implementation.
+ - Bump the per-plugin `version` field through `./misc/update_version.sh ` so every `plugin.json` and the README badge move together. Do not edit the version in a single `plugin.json` by hand.
+ - If a PR changes a setting ID, type, context, accepted value shape, or compatibility behaviour, require migration notes and confirm that the README settings table is regenerated via `.tests/misc/json2md.py`.
+
+ - path: "COMPATIBILITY.json"
+ instructions: |
+ `COMPATIBILITY.json` maps a plugins-collection version to the BunkerWeb versions it supports. This is a different version stream from the per-plugin `plugin.json` version:
+ - New entries must use ascending collection versions and reference real, released BunkerWeb tags.
+ - Do not collapse, reorder, or drop historical mappings — older BunkerWeb deployments rely on them.
+ - When a new collection version is introduced, ensure the README badge and, where relevant, the hardcoded BW tag in `.github/workflows/tests.yml` move together.
+
+ - path: "coraza/api/**/*.go"
+ instructions: |
+ `coraza/api/` is a standalone Go HTTP service that wraps `github.com/corazawaf/coraza/v3` and is called over HTTP by `coraza.lua`. The image is published as `bunkerity/bunkerweb-coraza`:
+ - Use `gofmt`/`go vet` conventions; exported identifiers need doc comments.
+ - Do not block the HTTP handler goroutines on long-running work; honour request context cancellation and set read/write/idle timeouts on `http.Server`.
+ - Validate request bodies strictly, cap body size, and return precise HTTP status codes. Never echo request data unsanitised into responses or logs.
+ - Use `io.ReadAll` (stdlib) rather than the deprecated `io/ioutil`. Close every `io.ReadCloser` you open.
+ - `coraza.WAF` and `types.Transaction` lifecycles matter: create transactions per request, always call `ProcessLogging` / `Close` on the defer path, and surface interruptions through a consistent JSON shape (`Resp{Deny, Msg}`).
+ - Be careful with rule loading at startup: fail fast with a clear error if the CRS or custom rules cannot be parsed, rather than serving requests with a degraded WAF.
+ - Never log request bodies, cookies, bearer tokens, or secrets. Redact any user-supplied header before logging.
+ - Keep third-party dependencies minimal and pinned in `go.mod` / `go.sum`.
+
+ - path: "coraza/api/coraza.conf"
+ instructions: |
+ `coraza.conf` and `bunkerweb.conf` configure the WAF engine at startup:
+ - Preserve `SecRuleEngine`, request-body inspection limits, and audit-log defaults unless the PR explains why a weaker setting is safe.
+ - Any rule exclusions or paranoia-level adjustments must be documented with a concrete reason.
+ - Do not weaken CRS wiring or broaden allowlists without a strong, specific justification.
+
+ - path: "coraza/api/crs.sh"
+ instructions: |
+ `crs.sh` vendors the OWASP Core Rule Set into the Docker image at build time:
+ - The CRS commit/ref must stay pinned; floating `main` or `master` checkouts are not acceptable.
+ - The script must strip `.git` (and any other build-time cruft) from the vendored tree so the published image stays reproducible.
+ - Prefer `git_secure_clone` style helpers or verified downloads; never pipe a remote script straight into a shell.
+
+ - path: ".tests/*.sh"
+ instructions: |
+ Integration test scripts under `.tests/` drive end-to-end tests against real BunkerWeb containers:
+ - Source `.tests/utils.sh` and use `do_and_check_cmd` for any command whose failure should abort the test run; do not silently ignore non-zero exits.
+ - Scripts must start with a clean `/tmp/bunkerweb-plugins/` workspace and chown the plugin tree to `101:101` (BunkerWeb's uid) before `docker compose up`.
+ - Rewrite image references to `bunkerweb:tests` / `bunkerweb-scheduler:tests` via `sed` exactly as the existing scripts do, so the pinned tags from `./.tests/bw.sh` are picked up.
+ - Keep the BunkerWeb tag selection (`main` → pinned release, `dev` → `dev`) consistent with `.github/workflows/tests.yml`.
+ - On failure, dump `docker compose logs` before `docker compose down -v` so CI output is debuggable.
+ - Accept a `verbose` first argument to dump logs on success; do not remove that affordance.
+ - Never hard-code secrets. `VIRUSTOTAL_API_KEY` and any similar values must come from the environment.
+
+ - path: ".tests/**/docker-compose.yml"
+ instructions: |
+ Test Compose files must stay compatible with the `sed`-based image retagging in the test scripts:
+ - Use `bunkerity/bunkerweb:` and `bunkerity/bunkerweb-scheduler:` image references so the rewrite to `:tests` works without changes.
+ - Pin all other service images (ClamAV, upstream apps, ...) to explicit versions.
+ - Prefer `read_only`, `no-new-privileges`, dropped capabilities, and minimal mounts where compatible with the scenario under test.
+ - Volumes that hold plugin code must mount the path the test script prepared under `/tmp/bunkerweb-plugins//bw-data/plugins`.
+
+ - path: ".tests/misc/json2md.py"
+ instructions: |
+ `json2md.py` regenerates the README settings tables from each `plugin.json`:
+ - The script is run manually when settings change; keep it idempotent and deterministic.
+ - Any change to the output format must be reflected in every plugin README in the same PR.
+ - Do not introduce dependencies beyond `pytablewriter` without a very good reason.
+
+ - path: "misc/update_version.sh"
+ instructions: |
+ `update_version.sh` is the canonical way to bump the per-plugin `plugin.json` version across the repo:
+ - The script must rewrite every `plugin.json` version and the README badge in a single pass; do not split the responsibilities.
+ - Do not touch `COMPATIBILITY.json` from this script — the plugins-collection version stream is managed separately.
+ - Prefer `sed -i` with anchored expressions over fragile regexes that could match unrelated fields.
+
+ - path: "**/*.sh"
+ instructions: |
+ Shell scripts must match BunkerWeb's portability expectations:
+ - If the script is POSIX shell, prefer `set -eu`; if it explicitly requires Bash, use `set -euo pipefail`.
+ - Quote variables and command substitutions consistently and prefer `${var}` when concatenating.
+ - Do not use Bash-only features in `/bin/sh` scripts.
+ - Handle failures explicitly, use `trap` for cleanup where temporary files are created, and use `mktemp` safely.
+ - Never pipe a remote download straight into a shell interpreter; verify downloads by checksum or signature and avoid `-k` / `--insecure`.
+ - Do not rely on inherited `PATH` in privileged contexts; set it explicitly where needed.
+ - Avoid `eval` and unsafe command construction from untrusted data.
+
+ - path: ".github/workflows/**/*.yml"
+ instructions: &github_actions_instructions |
+ GitHub Actions workflows must be reproducible and safe:
+ - Pin third-party actions by commit SHA, not floating tags — match the style already used in `tests.yml` and `codeql.yml`.
+ - Declare an explicit top-level `permissions:` block and keep it minimal by default.
+ - Be extremely careful with `pull_request_target`: do not combine untrusted PR code with repository secrets.
+ - Do not interpolate `${{ github.event.* }}` values directly inside `run:` scripts; assign them via `env:` first to reduce script-injection risk.
+ - Use `concurrency` intentionally: cancel superseded PR jobs, but avoid cancelling release or deployment jobs that should run to completion.
+ - The hardcoded BunkerWeb tag in `tests.yml` (for example `1.6.1`) is not read from `COMPATIBILITY.json`; any bump must update both places in the same PR.
+
+ - path: ".github/workflows/**/*.yaml"
+ instructions: *github_actions_instructions
+
+ - path: ".pre-commit-config.yaml"
+ instructions: |
+ Keep the pre-commit stack aligned with repository reality:
+ - Hook revisions must stay pinned by frozen SHA, matching the existing style.
+ - The `exclude` pattern must keep `coraza/api/coreruleset` and `LICENSE.md` out of the hook run; do not silently broaden or drop those exclusions.
+ - New hooks must not silently reformat or reject large swathes of historical code without an agreed migration plan.
+ - Python hook versions must stay compatible with `language_version: python3.9`, which matches the `black` configuration.
+
+ - path: "**/*.yaml"
+ instructions: &yaml_instructions |
+ YAML files (CI, Compose, app config) must be structured and reproducible:
+ - Use consistent indentation, stable key ordering where the project already has one, and avoid duplicate keys.
+ - Pin container images, dependency versions, and GitHub Actions rather than using `latest`.
+ - Never commit secrets in plaintext; use secret managers, encrypted values, or template/example files.
+ - Docker Compose changes should prefer `read_only`, `no-new-privileges`, dropped capabilities, and minimal mounts where compatible.
+
+ - path: "**/*.yml"
+ instructions: *yaml_instructions
+
+ - path: "**/Dockerfile*"
+ instructions: |
+ Dockerfiles (notably `coraza/api/Dockerfile`) must be hardened and reproducible:
+ - Do not use `latest`; pin base images precisely, ideally by digest.
+ - Prefer multi-stage builds and keep the runtime image small — the Coraza API image is shipped to end users as `bunkerity/bunkerweb-coraza`.
+ - Install packages in a single `RUN` layer and clean the package cache in that same layer (Alpine: `apk add --no-cache`; Debian/Ubuntu: `apt-get update && apt-get install -y --no-install-recommends ... && apt-get clean && rm -rf /var/lib/apt/lists/*`).
+ - Prefer `COPY` over `ADD`, avoid passing secrets via `ARG`, and use BuildKit secrets for sensitive material.
+ - The final runtime stage should run as a non-root user and include a `HEALTHCHECK` where a healthcheck script (for example `coraza/api/healthcheck.sh`) exists.
+
+ - path: "**/*.md"
+ instructions: |
+ Documentation should be concise, accurate, and written in British English:
+ - Keep the structure clear with a sensible heading hierarchy.
+ - Each plugin README contains a settings table generated from `plugin.json` via `.tests/misc/json2md.py`; regenerate it whenever settings change rather than hand-editing the table.
+ - The repository README shows a compatibility badge tied to the plugins-collection version in `COMPATIBILITY.json` — keep it in sync with any version bump.
+ - Prefer concrete instructions, accurate examples, and explicit prerequisites (Docker, `sudo`, required env vars like `VIRUSTOTAL_API_KEY`).
+ - When a PR changes behaviour, defaults, packaging, or security posture, ask for the corresponding documentation update.
+
+ tools:
+ github-checks:
+ enabled: true
+ timeout_ms: 120000
+ languagetool:
+ enabled: true
+ level: "default"
+ gitleaks:
+ enabled: true
+ luacheck:
+ enabled: true
+ shellcheck:
+ enabled: true
+ hadolint:
+ enabled: true
+ yamllint:
+ enabled: true
+ actionlint:
+ enabled: true
+ markdownlint:
+ enabled: true
+ checkov:
+ enabled: true
+ osvScanner:
+ enabled: true
+ dotenvLint:
+ enabled: true
+ flake8:
+ enabled: false
+ ruff:
+ enabled: false
+ eslint:
+ enabled: false
+ stylelint:
+ enabled: false
+ semgrep:
+ enabled: false
+ biome:
+ enabled: false
+ htmlhint:
+ enabled: false
+
+chat:
+ auto_reply: true
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 7362233..3b89b06 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -16,6 +16,21 @@ updates:
prefix: "deps/gha"
target-branch: "dev"
+ # npm (Prettier tooling)
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "09:00"
+ timezone: "Europe/Paris"
+ assignees:
+ - "TheophileDiot"
+ reviewers:
+ - "TheophileDiot"
+ commit-message:
+ prefix: "deps/npm"
+ target-branch: "dev"
+
# Coraza
- package-ecosystem: "docker"
directory: "/coraza/api"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index cc09fdf..546a746 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -19,13 +19,13 @@ jobs:
language: ["python", "go"]
steps:
- name: Checkout repository
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Initialize CodeQL
- uses: github/codeql-action/init@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
+ uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql.yml
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
+ uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..8c812ed
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,81 @@
+name: Release
+
+# Cut a GitHub release once per plugin version bump. Mirrors the release-creation
+# step of bunkerity/bunkerweb (softprops/action-gh-release, pinned SHA, draft for
+# human review) without the heavy build matrix the plugins repo does not need.
+#
+# Fires only after the "Tests" workflow succeeds on main, and only when the
+# version in plugin.json does not already have a release — so a normal push to
+# main that does not bump the version is a no-op.
+
+on:
+ workflow_run:
+ workflows: [Tests]
+ types: [completed]
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ release:
+ if: github.event.workflow_run.conclusion == 'success'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # create the tag + release
+ steps:
+ - name: Checkout the tested commit
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ ref: ${{ github.event.workflow_run.head_sha }}
+ fetch-depth: 0 # full history so generated release notes are complete
+
+ - name: Resolve plugin version
+ id: version
+ run: |
+ # All plugin.json carry the same version (kept in lockstep by
+ # misc/update_version.sh), so any one of them is the source of truth.
+ version="$(jq -r .version clamav/plugin.json)"
+ case "$version" in
+ "" | null | *[!0-9.]*)
+ echo "::error::unexpected plugin version '$version'"
+ exit 1
+ ;;
+ esac
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ echo "tag=v$version" >> "$GITHUB_OUTPUT"
+
+ - name: Skip if this version is already released
+ id: guard
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ steps.version.outputs.tag }}
+ run: |
+ # Match on tag_name across ALL releases, drafts included. A draft has
+ # no git tag until it is published, so `gh release view ` would
+ # miss an unpublished draft and we'd create a duplicate on every push.
+ # The assignment aborts the step if `gh api` fails (no fail-open to
+ # "create" on an auth/network error).
+ tags="$(gh api "repos/$GITHUB_REPOSITORY/releases" --paginate --jq '.[].tag_name')"
+ if grep -Fxq "$TAG" <<<"$tags"; then
+ echo "exists=true" >> "$GITHUB_OUTPUT"
+ echo "Release $TAG already exists (including drafts) — nothing to do."
+ else
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ echo "No release for $TAG yet — creating a draft."
+ fi
+
+ - name: Create draft release
+ if: steps.guard.outputs.exists == 'false'
+ uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
+ with:
+ tag_name: ${{ steps.version.outputs.tag }}
+ target_commitish: ${{ github.event.workflow_run.head_sha }}
+ name: ${{ steps.version.outputs.tag }}
+ draft: true # published by a maintainer after a quick review
+ generate_release_notes: true # auto-changelog from merged PRs/commits
+ body: |
+ BunkerWeb external plugins **${{ steps.version.outputs.tag }}**.
+
+ Install a plugin by mounting its directory into the scheduler's `/data/plugins`
+ (see the README). Coraza ships its WAF API as `bunkerity/bunkerweb-coraza`.
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 4c6bcbd..f193611 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -4,6 +4,10 @@ on:
push:
branches: [dev, main]
+concurrency:
+ group: tests-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
codeql:
uses: ./.github/workflows/codeql.yml
@@ -12,40 +16,143 @@ jobs:
contents: read
security-events: write
- setup:
+ tag:
+ runs-on: ubuntu-latest
+ outputs:
+ bw_tag: ${{ steps.tag.outputs.bw_tag }}
+ steps:
+ - name: Resolve latest stable BunkerWeb tag
+ id: tag
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ # Always test against the latest STABLE BunkerWeb release, resolved at
+ # runtime so it never goes stale. GitHub's releases/latest endpoint
+ # excludes drafts and pre-releases (rc/beta), giving us "stable".
+ # Same tag on every branch (dev and main) — there is no pinned version.
+ # `|| true`: a 404 (no stable release yet) or rate-limit makes gh exit
+ # non-zero, which under `bash -e` would abort the step here with a raw
+ # error. Swallow it so the empty tag falls through to the case below
+ # and reports our own diagnostic.
+ tag="$(gh api repos/bunkerity/bunkerweb/releases/latest --jq .tag_name || true)"
+ tag="${tag#v}" # release tag may be "v1.6.1"; Docker tags have no "v"
+ case "$tag" in
+ "" | *-*)
+ echo "::error::could not resolve a stable BunkerWeb tag (got '${tag:-}')"
+ exit 1
+ ;;
+ esac
+ echo "Resolved latest stable BunkerWeb tag: $tag"
+ echo "bw_tag=$tag" >> "$GITHUB_OUTPUT"
+
+ lint:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - name: Set up Python
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ with:
+ python-version: "3.11"
+ - name: Install pre-commit
+ run: pip install pre-commit
+ - name: Install Lua toolchain
+ # The luacheck pre-commit hook is language:lua and needs luarocks on the
+ # runner to bootstrap its environment.
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
+ - name: Set up Node
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: "22"
+ cache: "npm"
+ - name: Install Prettier
+ # The prettier hook is language:system and resolves prettier from the
+ # repo's pinned package-lock.json via npx.
+ run: npm ci
+ - name: Run pre-commit
+ run: pre-commit run --all-files
+
+ unit:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ lang: [go, python, lua]
+ steps:
+ - name: Checkout source code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ # --- Go : coraza API service ---
+ - name: Set up Go
+ if: matrix.lang == 'go'
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
+ with:
+ go-version: "1.25"
+ - name: Run Go unit tests
+ if: matrix.lang == 'go'
+ working-directory: coraza/api
+ run: |
+ # No go.sum is committed (the Dockerfile resolves deps at build time),
+ # so populate it before testing. Build tag must match the binary.
+ go mod tidy
+ go test -tags=coraza.rule.multiphase_evaluation ./...
+
+ # --- Python : ui/actions.py ---
+ - name: Set up Python
+ if: matrix.lang == 'python'
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ with:
+ python-version: "3.11"
+ - name: Run Python unit tests
+ if: matrix.lang == 'python'
+ run: |
+ pip install pytest
+ pytest tests/ -q
- - name: Get BW tag
+ # --- Lua : busted specs ---
+ - name: Run Lua unit tests
+ if: matrix.lang == 'lua'
run: |
- if [ "$GITHUB_REF" = "refs/heads/main" ] ; then
- echo "BW_TAG=1.6.1" >> $GITHUB_ENV
- else
- echo "BW_TAG=dev" >> $GITHUB_ENV
- fi
+ sudo apt-get update
+ # liblua5.4-dev provides headers for busted's C deps; pin the luarocks
+ # tree to 5.4 so the busted CLI runs against the lua5.4 we installed.
+ sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
+ sudo luarocks --lua-version=5.4 install busted
+ busted
+ integration:
+ needs: [tag, lint, unit]
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ plugin: [clamav, cloudflare, coraza, virustotal, authentik, notifier]
+ steps:
+ - name: Checkout source code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Login to Docker Hub
- uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
-
- name: Pull and build BW
- run: ./.tests/bw.sh "${{ env.BW_TAG }}"
-
- - name: Run ClamAV tests
- run: ./.tests/clamav.sh
-
- - name: Run Coraza tests
- run: ./.tests/coraza.sh
-
- - name: Run VirusTotal tests
- run: ./.tests/virustotal.sh
- env:
- VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
+ run: bash .tests/bw.sh "${{ needs.tag.outputs.bw_tag }}"
+ - name: Run ${{ matrix.plugin }} tests
+ run: bash .tests/${{ matrix.plugin }}.sh
+ build-push:
+ needs: [tag, integration]
+ if: github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout source code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - name: Login to Docker Hub
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push APIs
- if: env.BW_TAG == '1.6.1'
- run: ./.tests/build-push.sh "${{ env.BW_TAG }}"
+ run: bash .tests/build-push.sh "${{ needs.tag.outputs.bw_tag }}"
diff --git a/.gitignore b/.gitignore
index 32e352b..0369a4f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,12 @@
env
node_modules
style.css
+.idea
+
+# Python test artifacts
+__pycache__/
+*.pyc
+.pytest_cache/
+
+# Go: deps are resolved at build time; go.sum is regenerated, not committed
+coraza/api/go.sum
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index eefe735..e841ca6 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,9 +1,9 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
-exclude: (^coraza/api/coreruleset|(^LICENSE.md|.svg)$)
+exclude: (^coraza/api/coreruleset|^package-lock\.json$|(^LICENSE.md|.svg)$)
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: 2c9f875913ee60ca25ce70243dc24d5b6415598c # frozen: v4.6.0
+ rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@@ -11,21 +11,26 @@ repos:
args: ["--allow-multiple-documents"]
- id: check-case-conflict
- - repo: https://github.com/ambv/black
- rev: 3702ba224ecffbcec30af640c149f231d90aebdb # frozen: 24.4.2
+ - repo: https://github.com/psf/black
+ rev: 87928e6d6761a4a6d22250e1fee5601b3998086e # frozen: 26.5.1
hooks:
- id: black
name: Black Python Formatter
- language_version: python3.9
+ language_version: python3.11
- - repo: https://github.com/pre-commit/mirrors-prettier
- rev: ffb6a759a979008c0e6dff86e39f4745a2d9eac4 # frozen: v3.1.0
+ - repo: local
hooks:
- id: prettier
name: Prettier Code Formatter
+ # Uses the pinned prettier from the repo's package.json / package-lock.json
+ # (installed via `npm ci`); npx resolves the local node_modules/.bin binary.
+ entry: npx prettier --write
+ language: system
+ types: [text]
+ files: \.(js|ts|jsx|tsx|css|less|html|json|markdown|md|yaml|yml)$
- repo: https://github.com/JohnnyMorganz/StyLua
- rev: 84c370104d6a8d1eef00c80a3ebd42f7033aaaad # frozen: v0.20.0
+ rev: a6433cd8ed974a1dd769d41a46b5008858e58e94 # frozen: v2.5.2
hooks:
- id: stylua-github
@@ -36,14 +41,22 @@ repos:
args: ["--std", "min", "--codes", "--ranges", "--no-cache"]
- repo: https://github.com/pycqa/flake8
- rev: 1978e2b0de6efa0cb2a2b6f3f7986aa6569dd2be # frozen: 7.1.0
+ rev: d93590f5be797aabb60e3b09f2f52dddb02f349f # frozen: 7.3.0
hooks:
- id: flake8
name: Flake8 Python Linter
- args: ["--max-line-length=250", "--ignore=E266,E402,E722,W503"]
+ args: ["--max-line-length=160", "--ignore=E266,E402,E501,E722,W503"]
+
+ - repo: https://github.com/dosisod/refurb
+ rev: 0dbb127465ca9398b6c89c32a7fd86d78ca755c4 # frozen: v2.3.1
+ hooks:
+ - id: refurb
+ name: Refurb Python Refactoring Tool
+ exclude: ^tests/
+ additional_dependencies: ["mypy<1.20"]
- repo: https://github.com/codespell-project/codespell
- rev: 193cd7d27cd571f79358af09a8fb8997e54f8fff # frozen: v2.3.0
+ rev: 2ccb47ff45ad361a21071a7eedda4c37e6ae8c5a # frozen: v2.4.2
hooks:
- id: codespell
name: Codespell Spell Checker
@@ -52,11 +65,11 @@ repos:
types: [text]
- repo: https://github.com/gitleaks/gitleaks
- rev: 77c3c6a34b2577d71083442326c60b8fd58926ec # frozen: v8.18.4
+ rev: 2ca41cc1372d1e939a6a879f18cdc19fc1cac1ce # frozen: v8.30.0
hooks:
- id: gitleaks
- repo: https://github.com/koalaman/shellcheck-precommit
- rev: 2491238703a5d3415bb2b7ff11388bf775372f29 # frozen: v0.10.0
+ rev: 99470f5e12208ff0fb17ab81c3c494f7620a1d8d # frozen: v0.11.0
hooks:
- id: shellcheck
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..837365f
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,4 @@
+node_modules/
+package-lock.json
+LICENSE.md
+coraza/api/coreruleset/
diff --git a/.tests/authentik.sh b/.tests/authentik.sh
new file mode 100644
index 0000000..5c7da96
--- /dev/null
+++ b/.tests/authentik.sh
@@ -0,0 +1,121 @@
+#!/bin/bash
+
+# shellcheck disable=SC1091
+. .tests/utils.sh
+
+echo "ℹ️ Starting Authentik tests ..."
+
+# Create working directory
+if [ -d /tmp/bunkerweb-plugins ] ; then
+ do_and_check_cmd sudo rm -rf /tmp/bunkerweb-plugins
+fi
+do_and_check_cmd mkdir -p /tmp/bunkerweb-plugins/authentik/bw-data/plugins
+do_and_check_cmd cp -r ./authentik /tmp/bunkerweb-plugins/authentik/bw-data/plugins
+do_and_check_cmd sudo chown -R 101:101 /tmp/bunkerweb-plugins/authentik/bw-data
+
+# Copy compose + mock outpost config
+do_and_check_cmd cp .tests/authentik/docker-compose.yml /tmp/bunkerweb-plugins/authentik
+do_and_check_cmd cp .tests/authentik/mock-outpost.conf /tmp/bunkerweb-plugins/authentik
+
+# Edit compose to use the locally built :tests images
+do_and_check_cmd sed -i "s@bunkerity/bunkerweb:.*\$@bunkerweb:tests@g" /tmp/bunkerweb-plugins/authentik/docker-compose.yml
+do_and_check_cmd sed -i "s@bunkerity/bunkerweb-scheduler:.*\$@bunkerweb-scheduler:tests@g" /tmp/bunkerweb-plugins/authentik/docker-compose.yml
+
+# Do the tests
+cd /tmp/bunkerweb-plugins/authentik || exit 1
+echo "ℹ️ Running compose ..."
+do_and_check_cmd docker compose up --build -d
+
+# Wait until the plugin is LIVE: while BunkerWeb is still applying config it serves a
+# 200 "Generating..." page and the plugin is inactive. An unauthenticated request only
+# becomes a 302 (gated) once config is applied -> use that as the readiness signal.
+echo "ℹ️ Waiting for BW (plugin live) ..."
+success="ko"
+retry=0
+while [ $retry -lt 120 ] ; do
+ code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: app.example.com" http://localhost 2>/dev/null)"
+ if [ "$code" = "302" ] ; then
+ success="ok"
+ break
+ fi
+ retry=$((retry + 1))
+ sleep 2
+done
+if [ "$success" = "ko" ] ; then
+ docker compose logs
+ docker compose down -v
+ echo "❌ Error: BunkerWeb / authentik plugin never became active"
+ exit 1
+fi
+
+fail=0
+
+# T1: unauthenticated -> 302 to the outpost sign-in
+echo "ℹ️ T1: unauthenticated request is redirected to the outpost ..."
+loc="$(curl -s -D - -o /dev/null -H "Host: app.example.com" http://localhost | tr -d '\r' | awk -F': ' 'tolower($1)=="location"{print $2}')"
+if echo "$loc" | grep -q "/outpost.goauthentik.io/start?rd=" ; then
+ echo "✔️ T1 ok ($loc)"
+else
+ echo "❌ T1 failed (Location: $loc)" ; fail=1
+fi
+
+# T2: authenticated -> 200 and identity header forwarded upstream (PASS=yes)
+echo "ℹ️ T2: authenticated request reaches upstream with identity header ..."
+body="$(curl -s -H "Host: app.example.com" -b "mock_session=valid" http://localhost)"
+if echo "$body" | grep -qi '"x-authentik-username": *"alice"' ; then
+ echo "✔️ T2 ok"
+else
+ echo "❌ T2 failed" ; fail=1
+fi
+
+# T3: spoofed X-authentik-* are stripped (security); only Authentik's values survive
+echo "ℹ️ T3: spoofed identity headers are stripped ..."
+body="$(curl -s -H "Host: app.example.com" -b "mock_session=valid" -H "X-authentik-username: hacker" -H "X-authentik-uid: 0" http://localhost)"
+if echo "$body" | grep -qi '"x-authentik-username": *"alice"' \
+ && ! echo "$body" | grep -qi '"x-authentik-username": *"hacker"' \
+ && ! echo "$body" | grep -qi '"x-authentik-uid"' ; then
+ echo "✔️ T3 ok (spoof stripped)"
+else
+ echo "❌ T3 failed (spoof not stripped)" ; fail=1
+fi
+
+# T4: PASS=no site strips spoofed identity headers and forwards none
+echo "ℹ️ T4: PASS=no site forwards no identity header ..."
+body="$(curl -s -H "Host: noheaders.example.com" -b "mock_session=valid" -H "X-authentik-username: hacker" http://localhost)"
+if ! echo "$body" | grep -qi "x-authentik-username" ; then
+ echo "✔️ T4 ok"
+else
+ echo "❌ T4 failed (identity header reached upstream)" ; fail=1
+fi
+
+# T5: outpost path is proxied (not gated)
+echo "ℹ️ T5: outpost path is proxied to the outpost ..."
+body="$(curl -s -H "Host: app.example.com" http://localhost/outpost.goauthentik.io/start)"
+if echo "$body" | grep -q "MOCK AUTHENTIK OUTPOST" ; then
+ echo "✔️ T5 ok"
+else
+ echo "❌ T5 failed" ; fail=1
+fi
+
+# T6: trailing-slash AUTHENTIK_URL still proxies the outpost (rstrip fix)
+echo "ℹ️ T6: trailing-slash AUTHENTIK_URL still proxies ..."
+body="$(curl -s -H "Host: noheaders.example.com" http://localhost/outpost.goauthentik.io/start)"
+if echo "$body" | grep -q "MOCK AUTHENTIK OUTPOST" ; then
+ echo "✔️ T6 ok"
+else
+ echo "❌ T6 failed (trailing-slash URL broke the outpost proxy)" ; fail=1
+fi
+
+if [ "$fail" -ne 0 ] ; then
+ docker compose logs
+ docker compose down -v
+ echo "❌ Authentik tests failed"
+ exit 1
+fi
+
+if [ "$1" = "verbose" ] ; then
+ docker compose logs
+fi
+
+docker compose down -v
+echo "✔️ Authentik tests succeeded"
diff --git a/.tests/authentik/docker-compose.yml b/.tests/authentik/docker-compose.yml
new file mode 100644
index 0000000..5de3a44
--- /dev/null
+++ b/.tests/authentik/docker-compose.yml
@@ -0,0 +1,85 @@
+version: "3"
+
+services:
+ bunkerweb:
+ image: bunkerity/bunkerweb:1.6.0
+ ports:
+ - 80:8080/tcp
+ - 443:8443/tcp
+ - 443:8443/udp
+ environment:
+ - API_WHITELIST_IP=127.0.0.0/8 10.20.30.0/24
+ networks:
+ - bw-universe
+ - bw-services
+
+ bw-scheduler:
+ image: bunkerity/bunkerweb-scheduler:1.6.0
+ depends_on:
+ - bunkerweb
+ volumes:
+ - ./bw-data/plugins:/data/plugins
+ environment:
+ - BUNKERWEB_INSTANCES=bunkerweb
+ - MULTISITE=yes
+ - SERVER_NAME=app.example.com noheaders.example.com
+ - API_WHITELIST_IP=127.0.0.0/8 10.20.30.0/24
+ - LOG_LEVEL=info
+ - USE_BAD_BEHAVIOR=no
+ - USE_LIMIT_REQ=no
+ - USE_BUNKERNET=no
+ - USE_BLACKLIST=no
+ - USE_GREYLIST=no
+ - USE_WHITELIST=no
+ - USE_MODSECURITY=no
+ - USE_ANTIBOT=no
+ - SERVE_FILES=no
+ - DISABLE_DEFAULT_SERVER=yes
+ - AUTO_LETS_ENCRYPT=no
+ - USE_LETS_ENCRYPT=no
+ - REDIRECT_HTTP_TO_HTTPS=no
+ - AUTO_REDIRECT_HTTP_TO_HTTPS=no
+ # app.example.com : identity forwarding ON
+ - app.example.com_USE_REVERSE_PROXY=yes
+ - app.example.com_REVERSE_PROXY_HOST=http://echo:8080
+ - app.example.com_REVERSE_PROXY_URL=/
+ - app.example.com_USE_AUTHENTIK=yes
+ - app.example.com_AUTHENTIK_URL=http://mock-outpost:9000
+ - app.example.com_AUTHENTIK_SSL_VERIFY=no
+ - app.example.com_AUTHENTIK_PASS_IDENTITY_HEADERS=yes
+ # noheaders.example.com : forwarding OFF + TRAILING SLASH url (rstrip test)
+ - noheaders.example.com_USE_REVERSE_PROXY=yes
+ - noheaders.example.com_REVERSE_PROXY_HOST=http://echo:8080
+ - noheaders.example.com_REVERSE_PROXY_URL=/
+ - noheaders.example.com_USE_AUTHENTIK=yes
+ - noheaders.example.com_AUTHENTIK_URL=http://mock-outpost:9000/
+ - noheaders.example.com_AUTHENTIK_SSL_VERIFY=no
+ - noheaders.example.com_AUTHENTIK_PASS_IDENTITY_HEADERS=no
+ networks:
+ - bw-universe
+
+ # Mock authentik outpost: 200 + X-authentik-* when cookie mock_session=valid, else 401.
+ mock-outpost:
+ image: nginx:alpine
+ volumes:
+ - ./mock-outpost.conf:/etc/nginx/conf.d/default.conf:ro
+ networks:
+ - bw-services
+
+ # Echo upstream: reflects request headers as JSON so assertions can inspect them.
+ echo:
+ image: mendhak/http-https-echo:31
+ environment:
+ - HTTP_PORT=8080
+ networks:
+ - bw-services
+
+networks:
+ bw-universe:
+ name: bw-universe
+ ipam:
+ driver: default
+ config:
+ - subnet: 10.20.30.0/24
+ bw-services:
+ name: bw-services
diff --git a/.tests/authentik/mock-outpost.conf b/.tests/authentik/mock-outpost.conf
new file mode 100644
index 0000000..3e4ff87
--- /dev/null
+++ b/.tests/authentik/mock-outpost.conf
@@ -0,0 +1,33 @@
+map $http_cookie $mock_authed {
+ default 0;
+ "~*mock_session=valid" 1;
+}
+
+server {
+ listen 9000;
+ server_name _;
+
+ # Authentik forward-auth decision endpoint:
+ # 200 (+ identity headers) when the session cookie is present, else 401.
+ location = /outpost.goauthentik.io/auth/nginx {
+ default_type text/plain;
+ add_header X-authentik-username "alice";
+ add_header X-authentik-email "alice@example.com";
+ add_header X-authentik-groups "admins";
+ if ($mock_authed = 0) {
+ return 401 "unauthorized";
+ }
+ return 200 "ok";
+ }
+
+ # Browser-facing outpost endpoints (start, callback, sign_out, ...).
+ location /outpost.goauthentik.io {
+ default_type text/plain;
+ return 200 "MOCK AUTHENTIK OUTPOST PATH";
+ }
+
+ location / {
+ default_type text/plain;
+ return 404 "not found";
+ }
+}
diff --git a/.tests/build-push.sh b/.tests/build-push.sh
index 8b4cdc5..04c2de4 100755
--- a/.tests/build-push.sh
+++ b/.tests/build-push.sh
@@ -1,5 +1,6 @@
#!/bin/bash
+# shellcheck disable=SC1091
. .tests/utils.sh
echo "ℹ️ Build bunkerweb-coraza ..."
diff --git a/.tests/clamav.sh b/.tests/clamav.sh
index 84d8093..708eea3 100755
--- a/.tests/clamav.sh
+++ b/.tests/clamav.sh
@@ -85,6 +85,37 @@ if [ "$success" == "ko" ] ; then
echo "❌ Error did not receive 403 code"
exit 1
fi
+
+# A clean file must NOT be denied by ClamAV. The hello upstream only accepts GET,
+# so a clean multipart POST reaches it and comes back 405 (method). Accept any 2xx
+# or non-403 4xx, but fail on 403 (denied) and on 5xx/000 (a crash or fail-closed
+# regression must not hide behind "not 403").
+echo "ℹ️ Testing that a clean file is not blocked ..."
+printf 'just a clean file\n' > /tmp/bunkerweb-plugins/clamav/clean.txt
+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)"
+case "$code" in
+403) clean_err="should not be denied by ClamAV" ;;
+000 | 5??) clean_err="caused an upstream error/crash" ;;
+*) clean_err="" ;;
+esac
+if [ -n "$clean_err" ] ; then
+ docker compose logs
+ docker compose down -v
+ echo "❌ Error: clean file $clean_err (got $code)"
+ exit 1
+fi
+
+# Upload EICAR a second time: it must still be denied. This re-hits the same
+# SHA-512, exercising the result cache (is_in_cache) rather than a fresh scan.
+echo "ℹ️ Testing repeated EICAR is still denied (cache path) ..."
+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)"
+if [ "$code" != "403" ] ; then
+ docker compose logs
+ docker compose down -v
+ echo "❌ Error: repeated EICAR should stay denied (got $code, expected 403)"
+ exit 1
+fi
+
if [ "$1" = "verbose" ] ; then
docker compose logs
fi
diff --git a/.tests/cloudflare.sh b/.tests/cloudflare.sh
new file mode 100755
index 0000000..9ba57bf
--- /dev/null
+++ b/.tests/cloudflare.sh
@@ -0,0 +1,110 @@
+#!/bin/bash
+
+# shellcheck disable=SC1091
+. .tests/utils.sh
+
+echo "ℹ️ Starting Cloudflare tests ..."
+
+WORKDIR=/tmp/bunkerweb-plugins/cloudflare
+
+fail() {
+ echo "❌ $1"
+ docker compose logs
+ docker compose down -v
+ exit 1
+}
+
+# Create working directory (plugin data may be owned by uid 101 from a prior run, so
+# prefer sudo when available — but fall back to a plain rm for sudo-less local runs).
+if [ -d "$WORKDIR" ] ; then
+ sudo -n rm -rf "$WORKDIR" 2>/dev/null || do_and_check_cmd rm -rf "$WORKDIR"
+fi
+do_and_check_cmd mkdir -p "$WORKDIR/bw-data/plugins"
+do_and_check_cmd cp -r ./cloudflare "$WORKDIR/bw-data/plugins/cloudflare"
+# BunkerWeb runs as uid 101 and only needs to READ the mounted plugin (:ro). Prefer the
+# canonical chown; fall back to world-readable when passwordless sudo isn't available.
+if sudo -n chown -R 101:101 "$WORKDIR/bw-data" 2>/dev/null ; then
+ echo "ℹ️ chowned plugin data to 101:101"
+else
+ echo "ℹ️ sudo unavailable, making plugin data world-readable instead"
+ do_and_check_cmd chmod -R a+rX "$WORKDIR/bw-data"
+fi
+
+# Copy compose + mocks
+do_and_check_cmd cp -r .tests/cloudflare/. "$WORKDIR/"
+
+# Point the compose at the locally pulled :tests images
+do_and_check_cmd sed -i "s@bunkerity/bunkerweb:.*\$@bunkerweb:tests@g" "$WORKDIR/docker-compose.yml"
+do_and_check_cmd sed -i "s@bunkerity/bunkerweb-scheduler:.*\$@bunkerweb-scheduler:tests@g" "$WORKDIR/docker-compose.yml"
+
+cd "$WORKDIR" || exit 1
+# Use fail() (not do_and_check_cmd) so a failed bring-up is also torn down with down -v.
+docker compose up --build -d || fail "docker compose up failed"
+
+# Cloudflare proxies to the origin over HTTPS, so the functional checks use :8443. (Once
+# the origin cert is in place BunkerWeb redirects HTTP->HTTPS, which would mask the deny
+# verdict on :8080.) --connect-to keeps SNI=www.example.com while dialing the container.
+https_code() {
+ docker compose exec -T "$1" curl -sk -o /dev/null -w "%{http_code}" \
+ --connect-to www.example.com:8443:bunkerweb:8443 https://www.example.com:8443/ 2>/dev/null
+}
+
+# Readiness: wait until the untrusted client is DENIED over HTTPS. The plugin fails open
+# while the trusted-IP list is empty (BunkerWeb's loading page + the window before
+# cf-trusted-ips-download.py runs), so a 403 here means the list AND the origin cert have
+# been downloaded, synced to BunkerWeb and reloaded — fully ready.
+echo "ℹ️ Waiting for the cloudflare plugin to enforce deny over HTTPS ..."
+success="ko"
+retry=0
+while [ $retry -lt 180 ] ; do
+ if [ "$(https_code evil-client)" = "403" ] ; then
+ success="ok"
+ break
+ fi
+ retry=$((retry + 1))
+ sleep 1
+done
+[ "$success" = "ok" ] || fail "untrusted client never got denied — trusted IP list / origin cert not ready"
+echo "✅ Untrusted client (192.0.2.10) is denied (403)"
+
+# A Cloudflare-range client must reach the upstream (200) — proving the trusted path and
+# that the list really contains 173.245.48.0/20. (BunkerWeb is fully loaded by now, per
+# the deny above, so a 200 is the upstream, not the "Generating..." loading page.)
+echo "ℹ️ Testing that a Cloudflare-range client reaches the upstream ..."
+code="$(https_code cf-client)"
+[ "$code" = "200" ] || fail "Cloudflare-range client (173.245.48.2) should reach the upstream (got $code)"
+echo "✅ Cloudflare-range client (173.245.48.2) reaches the upstream (200)"
+
+# Provenance: confirm the verdicts came from THIS plugin.
+docker compose logs bunkerweb 2>/dev/null | grep -F "192.0.2.10 is not trusted" >/dev/null || fail "no cloudflare 'not trusted' log for the untrusted client"
+docker compose logs bunkerweb 2>/dev/null | grep -F "173.245.48.2 is trusted" >/dev/null || fail "no cloudflare 'is trusted' log for the Cloudflare-range client"
+echo "✅ Deny/allow verdicts are attributable to the cloudflare plugin"
+
+# Feature 1: the trusted-IP download job ran and hit the mock.
+docker compose logs bw-scheduler 2>/dev/null | grep -E "Downloaded [0-9]+ trusted ipv4" >/dev/null || fail "trusted-IP download job did not report success"
+docker compose logs cfips-mock 2>/dev/null | grep -F "GET /ips-v4/" >/dev/null || fail "the IP-list mock was never queried"
+echo "✅ Trusted-IP download job ran against the mock"
+
+# Feature 3: the origin-cert job generated a cert via the mock CF API ...
+docker compose logs bw-scheduler 2>/dev/null | grep -F "Successfully generated origin certificate" >/dev/null || fail "origin certificate was never generated"
+docker compose logs cf-api-mock 2>/dev/null | grep -F "POST /certificates" >/dev/null || fail "the mock CF API was never asked to sign a certificate"
+echo "✅ Origin certificate generated via the mock CF API"
+
+# ... and BunkerWeb serves it over SNI. Redirect curl's verbose output inside the
+# container (sh -c '... 2>&1') so the TLS lines survive `docker compose exec -T`.
+echo "ℹ️ Checking the served origin certificate ..."
+out="$(docker compose exec -T cf-client sh -c 'curl -ksv --connect-to www.example.com:8443:bunkerweb:8443 https://www.example.com:8443/ 2>&1' 2>/dev/null)"
+echo "$out" | grep -qE "subject:.*CN ?= ?www\.example\.com" || fail "served certificate subject is not CN=www.example.com (got: $(echo "$out" | grep -i subject | tr -d '\r'))"
+echo "$out" | grep -q "Mock Cloudflare Origin CA" || fail "served certificate is not issued by the mock Origin CA"
+echo "✅ BunkerWeb serves the Cloudflare origin certificate over SNI"
+
+# Feature F1: the Authenticated Origin Pull CA was downloaded.
+docker compose logs bw-scheduler 2>/dev/null | grep -F "Authenticated Origin Pull CA" >/dev/null || fail "AOP CA download job did not report success"
+echo "✅ Authenticated Origin Pull CA downloaded"
+
+if [ "$1" = "verbose" ] ; then
+ docker compose logs
+fi
+docker compose down -v
+
+echo "ℹ️ Cloudflare tests done"
diff --git a/.tests/cloudflare/cf-api-mock/Dockerfile b/.tests/cloudflare/cf-api-mock/Dockerfile
new file mode 100644
index 0000000..7576114
--- /dev/null
+++ b/.tests/cloudflare/cf-api-mock/Dockerfile
@@ -0,0 +1,5 @@
+FROM python:3.12-slim
+RUN pip install --no-cache-dir cryptography
+COPY app.py /app.py
+EXPOSE 8080
+CMD ["python", "/app.py"]
diff --git a/.tests/cloudflare/cf-api-mock/app.py b/.tests/cloudflare/cf-api-mock/app.py
new file mode 100755
index 0000000..cd2e166
--- /dev/null
+++ b/.tests/cloudflare/cf-api-mock/app.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Deterministic mock of the Cloudflare API for the cloudflare plugin e2e tests.
+
+Implements just enough of the REST surface the plugin's Origin CA flow uses (the
+official `cloudflare` SDK is pointed here via CLOUDFLARE_API_URL):
+
+ GET /user/tokens/verify -> active token
+ GET /zones -> one active zone (only hit if no CLOUDFLARE_ZONE_ID)
+ GET /certificates -> [] (forces a fresh generation)
+ POST /certificates -> SIGNS the submitted CSR with a mock Origin CA and
+ echoes the CSR back verbatim (the job verifies the
+ returned csr == the one it sent)
+ GET /certificates/ -> the stored cert
+ DELETE /certificates/ -> revoke
+ GET /aop-ca.pem -> the mock CA in PEM (Authenticated Origin Pull CA)
+
+Each request is logged to stdout so the test can assert the plugin reached the mock.
+"""
+
+import json
+from datetime import datetime, timedelta, timezone
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.parse import urlparse
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+
+_NOW = datetime.now(timezone.utc)
+
+# Mock Origin CA (also reused as the Authenticated Origin Pull CA).
+_ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+_ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Mock Cloudflare Origin CA")])
+_ca_cert = (
+ x509.CertificateBuilder()
+ .subject_name(_ca_name)
+ .issuer_name(_ca_name)
+ .public_key(_ca_key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(_NOW - timedelta(days=1))
+ .not_valid_after(_NOW + timedelta(days=3650))
+ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
+ .sign(_ca_key, hashes.SHA256())
+)
+_CA_PEM = _ca_cert.public_bytes(serialization.Encoding.PEM)
+
+_certs = {}
+
+
+def sign_csr(csr_pem: str) -> str:
+ csr = x509.load_pem_x509_csr(csr_pem.encode())
+ builder = (
+ x509.CertificateBuilder()
+ .subject_name(csr.subject)
+ .issuer_name(_ca_cert.subject)
+ .public_key(csr.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(_NOW - timedelta(days=1))
+ .not_valid_after(_NOW + timedelta(days=3650))
+ )
+ try:
+ san = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName)
+ builder = builder.add_extension(san.value, critical=False)
+ except x509.ExtensionNotFound:
+ pass
+ return builder.sign(_ca_key, hashes.SHA256()).public_bytes(serialization.Encoding.PEM).decode()
+
+
+def envelope(result):
+ return {"success": True, "errors": [], "messages": [], "result": result}
+
+
+class Handler(BaseHTTPRequestHandler):
+ def log_message(self, format, *args): # noqa: A002
+ print(f"MOCK {self.command} {self.path}", flush=True)
+
+ def _send(self, code, obj):
+ body = json.dumps(obj).encode()
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _send_raw(self, code, body, content_type):
+ self.send_response(code)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _not_found(self):
+ self._send(404, {"success": False, "errors": [{"code": 1, "message": "not found"}], "messages": [], "result": None})
+
+ def do_GET(self):
+ path = urlparse(self.path).path
+ if path == "/user/tokens/verify":
+ return self._send(200, envelope({"id": "mock-token", "status": "active"}))
+ if path == "/aop-ca.pem":
+ return self._send_raw(200, _CA_PEM, "application/x-pem-file")
+ if path == "/zones":
+ zone = {"id": "zone-mock-123", "name": "example.com", "status": "active", "type": "full", "modified_on": "2025-01-01T00:00:00Z"}
+ obj = envelope([zone])
+ obj["result_info"] = {"page": 1, "per_page": 25, "count": 1, "total_count": 1}
+ return self._send(200, obj)
+ if path == "/certificates":
+ obj = envelope([])
+ obj["result_info"] = {"page": 1, "per_page": 25, "count": 0, "total_count": 0}
+ return self._send(200, obj)
+ if path.startswith("/certificates/"):
+ cert = _certs.get(path.rsplit("/", 1)[-1])
+ return self._send(200, envelope(cert)) if cert else self._not_found()
+ return self._not_found()
+
+ def do_POST(self):
+ path = urlparse(self.path).path
+ length = int(self.headers.get("Content-Length", "0"))
+ try:
+ body = json.loads(self.rfile.read(length) or b"{}") if length else {}
+ except Exception:
+ body = {}
+ if path == "/certificates":
+ csr = body.get("csr", "")
+ cert_id = f"cert-mock-{len(_certs) + 1}"
+ result = {
+ "id": cert_id,
+ "certificate": sign_csr(csr),
+ "csr": csr, # exact echo: the plugin verifies result.csr == the CSR it sent
+ "hostnames": body.get("hostnames", []),
+ "request_type": body.get("request_type", "origin-rsa"),
+ "requested_validity": body.get("requested_validity", 5475),
+ "expires_on": "2039-01-01 00:00:00 +0000 UTC",
+ }
+ _certs[cert_id] = result
+ return self._send(200, envelope(result))
+ return self._not_found()
+
+ def do_DELETE(self):
+ path = urlparse(self.path).path
+ if path.startswith("/certificates/"):
+ cert_id = path.rsplit("/", 1)[-1]
+ _certs.pop(cert_id, None)
+ return self._send(200, envelope({"id": cert_id}))
+ return self._not_found()
+
+
+if __name__ == "__main__":
+ ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
diff --git a/.tests/cloudflare/cfips-mock.conf b/.tests/cloudflare/cfips-mock.conf
new file mode 100644
index 0000000..679aefd
--- /dev/null
+++ b/.tests/cloudflare/cfips-mock.conf
@@ -0,0 +1,15 @@
+# Deterministic mock of Cloudflare's public IP-range endpoints so CI doesn't depend
+# on the network. Logs to stdout so the test can assert the plugin actually hit it.
+server {
+ listen 80;
+ default_type text/plain;
+ access_log /dev/stdout;
+
+ location = /ips-v4/ {
+ return 200 '173.245.48.0/20\n103.21.244.0/22\n104.16.0.0/13\n';
+ }
+ location = /ips-v6/ {
+ return 200 '2400:cb00::/32\n2606:4700::/32\n';
+ }
+ location / { return 404; }
+}
diff --git a/.tests/cloudflare/docker-compose.yml b/.tests/cloudflare/docker-compose.yml
new file mode 100644
index 0000000..e2edf92
--- /dev/null
+++ b/.tests/cloudflare/docker-compose.yml
@@ -0,0 +1,123 @@
+services:
+ bunkerweb:
+ image: bunkerity/bunkerweb:1.6.11 # sed -> bunkerweb:tests
+ # No host port mapping: the tests reach BunkerWeb in-network from the client
+ # containers (bunkerweb:8080 / :8443), so publishing 80/443 only risks an
+ # "address already in use" clash on the host / CI runner.
+ environment:
+ - API_WHITELIST_IP=127.0.0.0/8 172.16.0.0/12 192.168.0.0/16
+ networks:
+ - bw-universe
+ - bw-services
+ - cf-net
+ - evil-net
+
+ bw-scheduler:
+ image: bunkerity/bunkerweb-scheduler:1.6.11 # sed -> bunkerweb-scheduler:tests
+ depends_on:
+ - bunkerweb
+ volumes:
+ - ./bw-data/plugins:/data/plugins:ro # plugin/ copied in as ./bw-data/plugins/cloudflare
+ environment:
+ - BUNKERWEB_INSTANCES=bunkerweb
+ - SERVER_NAME=www.example.com
+ - API_WHITELIST_IP=127.0.0.0/8 172.16.0.0/12 192.168.0.0/16
+ - LOG_LEVEL=info
+ # --- cloudflare: trusted IPs + deny (mock IP-list endpoints) ---
+ - USE_CLOUDFLARE=yes
+ - CLOUDFLARE_DENY_NON_TRUSTED_IPS=yes
+ - CLOUDFLARE_IPS_V4_URL=http://cfips-mock/ips-v4/
+ - CLOUDFLARE_IPS_V6_URL=http://cfips-mock/ips-v6/
+ # --- cloudflare: zero-config real IP ---
+ - CLOUDFLARE_AUTO_REAL_IP=yes
+ - CLOUDFLARE_REAL_IP_HEADER=CF-Connecting-IP
+ # --- cloudflare: origin CA certs (mock CF API) ---
+ - CLOUDFLARE_API_TOKEN=dummy-token
+ - CLOUDFLARE_ZONE_ID=zone-mock-123
+ - CLOUDFLARE_MANAGE_ORIGIN_CERTS=yes
+ - CLOUDFLARE_API_URL=http://cf-api-mock:8080
+ - CLOUDFLARE_ORIGIN_CERT_TYPE=rsa
+ # --- cloudflare: Authenticated Origin Pulls (log mode, mock CA) ---
+ - CLOUDFLARE_AUTHENTICATED_ORIGIN_PULLS=yes
+ - CLOUDFLARE_AOP_MODE=log
+ - CLOUDFLARE_AOP_CA_URL=http://cf-api-mock:8080/aop-ca.pem
+ # --- isolate the cloudflare verdict from other deny sources / SSL sources ---
+ - USE_REAL_IP=no
+ - USE_BAD_BEHAVIOR=no
+ - USE_LIMIT_REQ=no
+ - USE_LIMIT_CONN=no
+ - USE_BUNKERNET=no
+ - USE_BLACKLIST=no
+ - USE_GREYLIST=no
+ - USE_WHITELIST=no
+ - USE_MODSECURITY=no
+ - USE_ANTIBOT=no
+ - USE_MTLS=no
+ - AUTO_LETS_ENCRYPT=no
+ - GENERATE_SELF_SIGNED_SSL=no
+ - USE_CUSTOM_SSL=no
+ - REDIRECT_HTTP_TO_HTTPS=no
+ - DISABLE_DEFAULT_SERVER=no
+ - USE_REVERSE_PROXY=yes
+ - REVERSE_PROXY_HOST=http://hello:8080
+ - REVERSE_PROXY_URL=/
+ networks:
+ - bw-universe
+ - bw-services
+
+ hello:
+ image: nginxdemos/nginx-hello
+ networks:
+ - bw-services
+
+ # Mock Cloudflare public IP-range endpoints (static).
+ cfips-mock:
+ image: nginx:alpine
+ volumes:
+ - ./cfips-mock.conf:/etc/nginx/conf.d/default.conf:ro
+ networks:
+ - bw-services
+
+ # Mock Cloudflare API (dynamic: signs the CSR, serves the AOP CA).
+ cf-api-mock:
+ build: ./cf-api-mock
+ networks:
+ - bw-services
+
+ # Trusted client: source IP inside a real Cloudflare range.
+ cf-client:
+ image: curlimages/curl:latest
+ command: ["sleep", "infinity"]
+ networks:
+ cf-net:
+ ipv4_address: 173.245.48.2
+
+ # Untrusted client: source IP in TEST-NET-1, never a Cloudflare range.
+ evil-client:
+ image: curlimages/curl:latest
+ command: ["sleep", "infinity"]
+ networks:
+ evil-net:
+ ipv4_address: 192.0.2.10
+
+networks:
+ # The BunkerWeb internal API/service networks don't need fixed addressing, so
+ # let Docker allocate their subnets from its default pool. Hardcoding them (e.g.
+ # 10.20.30.0/24) risks "Address already in use" at bring-up when the CI runner's
+ # host happens to have an interface in that range. API_WHITELIST_IP above is
+ # widened to cover Docker's default bridge pool (172.16/12, 192.168/16).
+ bw-universe:
+ bw-services:
+ # cf-net and evil-net MUST stay pinned: cf-client's source IP has to fall inside
+ # a real Cloudflare range to be "trusted", and evil-client outside it. Both use
+ # public / TEST-NET-1 space that never overlaps the runner's private host IPs.
+ cf-net:
+ ipam:
+ driver: default
+ config:
+ - subnet: 173.245.48.0/20
+ evil-net:
+ ipam:
+ driver: default
+ config:
+ - subnet: 192.0.2.0/24
diff --git a/.tests/coraza.sh b/.tests/coraza.sh
index 2d6df2e..bd45d5a 100755
--- a/.tests/coraza.sh
+++ b/.tests/coraza.sh
@@ -84,6 +84,27 @@ if [ "$success" == "ko" ] ; then
exit 1
fi
+# Scanner User-Agent (CRS 913xxx, request-headers phase): a different rule family
+# than the LFI args above, widening coverage to header inspection.
+echo "ℹ️ Testing with a scanner User-Agent ..."
+ret="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: www.example.com" -H "User-Agent: sqlmap/1.4.7" http://localhost/)"
+if [ "$ret" != "403" ] ; then
+ docker compose logs
+ docker compose down -v
+ echo "❌ Error: scanner User-Agent should be blocked (got $ret, expected 403)"
+ exit 1
+fi
+
+# A benign request with a normal query arg must pass (no false positive -> 200).
+echo "ℹ️ Testing that a benign request passes ..."
+ret="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: www.example.com" "http://localhost/?q=hello-world")"
+if [ "$ret" != "200" ] ; then
+ docker compose logs
+ docker compose down -v
+ echo "❌ Error: benign request should pass (got $ret, expected 200)"
+ exit 1
+fi
+
# We're done
if [ "$1" = "verbose" ] ; then
docker compose logs
diff --git a/.tests/misc/json2md.py b/.tests/misc/json2md.py
index 9c39cfb..c90f7f9 100755
--- a/.tests/misc/json2md.py
+++ b/.tests/misc/json2md.py
@@ -44,7 +44,7 @@ def stream_support(support) -> str:
if len(core_plugin["settings"]) > 0:
core_settings[core_plugin["name"]] = core_plugin
-for name, data in dict(sorted(core_settings.items())).items():
+for data in dict(sorted(core_settings.items())).values():
print(f"### {data['name']}\n", file=doc)
print(f"{stream_support(data['stream'])}\n", file=doc)
print(f"{data['description']}\n", file=doc)
diff --git a/.tests/notifier.sh b/.tests/notifier.sh
new file mode 100755
index 0000000..159df24
--- /dev/null
+++ b/.tests/notifier.sh
@@ -0,0 +1,198 @@
+#!/bin/bash
+
+# shellcheck disable=SC1091
+. .tests/utils.sh
+
+echo "ℹ️ Starting Notifier (discord/slack/webhook/matrix) tests ..."
+
+# Create working directory
+if [ -d /tmp/bunkerweb-plugins ] ; then
+ do_and_check_cmd sudo rm -rf /tmp/bunkerweb-plugins
+fi
+do_and_check_cmd mkdir -p /tmp/bunkerweb-plugins/notifier/bw-data/plugins
+
+# Copy all four notifier plugins
+do_and_check_cmd cp -r ./discord /tmp/bunkerweb-plugins/notifier/bw-data/plugins
+do_and_check_cmd cp -r ./slack /tmp/bunkerweb-plugins/notifier/bw-data/plugins
+do_and_check_cmd cp -r ./webhook /tmp/bunkerweb-plugins/notifier/bw-data/plugins
+do_and_check_cmd cp -r ./matrix /tmp/bunkerweb-plugins/notifier/bw-data/plugins
+do_and_check_cmd sudo chown -R 101:101 /tmp/bunkerweb-plugins/notifier/bw-data
+
+# Copy compose + the rate-limit mock config
+do_and_check_cmd cp .tests/notifier/docker-compose.yml /tmp/bunkerweb-plugins/notifier
+do_and_check_cmd cp .tests/notifier/ratelimit.conf /tmp/bunkerweb-plugins/notifier
+
+# Edit compose
+do_and_check_cmd sed -i "s@bunkerity/bunkerweb:.*\$@bunkerweb:tests@g" /tmp/bunkerweb-plugins/notifier/docker-compose.yml
+do_and_check_cmd sed -i "s@bunkerity/bunkerweb-scheduler:.*\$@bunkerweb-scheduler:tests@g" /tmp/bunkerweb-plugins/notifier/docker-compose.yml
+
+# Do the tests
+cd /tmp/bunkerweb-plugins/notifier || exit 1
+echo "ℹ️ Running compose ..."
+do_and_check_cmd docker compose up --build -d
+
+# Wait until BW is started (any vhost reverse-proxies to hello). This is a
+# 3-site multisite stack, so allow the same generous budget as authentik (240s).
+echo "ℹ️ Waiting for BW ..."
+success="ko"
+retry=0
+while [ $retry -lt 120 ] ; do
+ ret="$(curl -s -H "Host: discord.example.com" http://localhost | grep -i "hello")"
+ # shellcheck disable=SC2181
+ if [ $? -eq 0 ] && [ "$ret" != "" ] ; then
+ success="ok"
+ break
+ fi
+ retry=$((retry + 1))
+ sleep 2
+done
+
+if [ "$success" = "ko" ] ; then
+ docker compose logs
+ docker compose down -v
+ echo "❌ Error: BunkerWeb never became ready"
+ exit 1
+fi
+
+fail=0
+
+# Negative control: a request that is NOT denied must NOT notify. Hit a benign
+# URL (proxied to hello, 200) and confirm no POST reaches the mock. Runs BEFORE
+# any deny so the echo mock log is still empty of notifier POSTs.
+echo "ℹ️ Negative test: a non-denied request must not notify ..."
+code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: slack.example.com" http://localhost/)"
+if [ "$code" != "200" ] ; then
+ echo "❌ benign request to slack vhost expected 200, got $code"
+ fail=1
+fi
+sleep 3
+if docker compose logs mock 2>/dev/null | grep -qF "/slack" ; then
+ echo "❌ benign request triggered a notifier POST"
+ fail=1
+else
+ echo "✔️ benign request did not notify"
+fi
+
+# discord/slack/webhook log() fire on a DENIED request, then POST asynchronously
+# from ngx.timer.at AFTER the 403 is returned — so we poll the mock logs.
+
+# slack/webhook post to the echo mock; assert path + payload-shape key.
+check_echo_notifier() {
+ # $1=plugin $2=path needle $3=payload-shape needle
+ local plugin="$1" path="$2" shape="$3" site found="ko" code r=0
+ site="${plugin}.example.com"
+ echo "ℹ️ [$plugin] provoking deny on http://$site/blocked ..."
+ # Send a credential header: the notifier must redact it (see redact_header in
+ # each plugin's *_helpers.lua) before forwarding the request to the sink.
+ code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: $site" -H "Cookie: redactme-supersecret" http://localhost/blocked)"
+ if [ "$code" != "403" ] ; then
+ echo "❌ [$plugin] expected 403 on /blocked, got $code"
+ fail=1
+ return
+ fi
+ while [ $r -lt 60 ] ; do
+ if docker compose logs mock 2>/dev/null | grep -qF "$path" ; then
+ found="ok"
+ break
+ fi
+ r=$((r + 1))
+ sleep 1
+ done
+ if [ "$found" != "ok" ] ; then
+ echo "❌ [$plugin] mock never received the POST ($path)"
+ fail=1
+ return
+ fi
+ echo "✔️ [$plugin] async POST received ($path)"
+ if docker compose logs mock 2>/dev/null | grep -qF "$shape" ; then
+ echo "✔️ [$plugin] payload shape ok ($shape)"
+ else
+ echo "❌ [$plugin] payload missing expected key $shape"
+ fail=1
+ fi
+}
+
+check_echo_notifier slack /slack '"text"'
+check_echo_notifier webhook /webhook '"content"'
+# matrix posts a PUT to the Matrix "send message" endpoint on the same echo mock.
+# Assert the room-send path was hit and the org.matrix.custom.html payload shape.
+check_echo_notifier matrix "/_matrix/client" '"formatted_body"'
+
+# discord posts to the rate-limit mock (429 + Retry-After, then 200). With
+# DISCORD_RETRY_IF_LIMITED=yes the plugin retries once, so the mock should see
+# two requests to /discord.
+echo "ℹ️ [discord] provoking deny (retry path) ..."
+code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: discord.example.com" -H "Cookie: redactme-supersecret" http://localhost/blocked)"
+if [ "$code" != "403" ] ; then
+ echo "❌ [discord] expected 403 on /blocked, got $code"
+ fail=1
+fi
+n=0
+retry=0
+while [ $retry -lt 60 ] ; do
+ n="$(docker compose logs ratelimit 2>/dev/null | grep -c "uri=/discord")"
+ if [ "$n" -ge 2 ] ; then
+ break
+ fi
+ retry=$((retry + 1))
+ sleep 1
+done
+if [ "$n" -ge 2 ] ; then
+ echo "✔️ [discord] retry fired after 429 ($n requests to /discord)"
+else
+ echo "❌ [discord] retry did not fire (only $n request(s) to /discord)"
+ fail=1
+fi
+if docker compose logs ratelimit 2>/dev/null | grep -qF '"username"' \
+ && docker compose logs ratelimit 2>/dev/null | grep -qF "Denied request for IP" ; then
+ echo "✔️ [discord] payload shape ok (username/embeds, denied payload)"
+else
+ echo "❌ [discord] payload shape or denied-content missing"
+ fail=1
+fi
+
+# discord posts to the ratelimit sink (not the echo mock), so assert its
+# header redaction separately: the Cookie sent on the deny must be [REDACTED].
+if docker compose logs ratelimit 2>/dev/null | grep -qF "redactme-supersecret" ; then
+ echo "❌ [discord] sensitive header value leaked to the notifier payload"
+ fail=1
+elif docker compose logs ratelimit 2>/dev/null | grep -qF "[REDACTED]" ; then
+ echo "✔️ [discord] sensitive header redacted in the notifier payload"
+else
+ echo "❌ [discord] expected [REDACTED] marker missing from the notifier payload"
+ fail=1
+fi
+
+# Cross-check the denied payload reached the echo mock (slack/webhook).
+if ! docker compose logs mock 2>/dev/null | grep -qF "Denied request for IP" ; then
+ echo "❌ echo mock never received a denied-request payload"
+ fail=1
+fi
+
+# Sensitive-header redaction: the Cookie value sent on the denied requests must
+# never reach the notification sink — it is replaced by [REDACTED]. This guards
+# the redact_header() logic shared by all notifier plugins (slack/webhook/matrix
+# post their header table to the echo mock).
+if docker compose logs mock 2>/dev/null | grep -qF "redactme-supersecret" ; then
+ echo "❌ sensitive header value leaked to the notifier payload"
+ fail=1
+elif docker compose logs mock 2>/dev/null | grep -qF "[REDACTED]" ; then
+ echo "✔️ sensitive header redacted in the notifier payload"
+else
+ echo "❌ expected [REDACTED] marker missing from the notifier payload"
+ fail=1
+fi
+
+if [ "$fail" -ne 0 ] ; then
+ docker compose logs
+ docker compose down -v
+ echo "❌ Notifier tests failed"
+ exit 1
+fi
+
+if [ "$1" = "verbose" ] ; then
+ docker compose logs
+fi
+docker compose down -v
+
+echo "ℹ️ Notifier tests done"
diff --git a/.tests/notifier/docker-compose.yml b/.tests/notifier/docker-compose.yml
new file mode 100644
index 0000000..f5849ed
--- /dev/null
+++ b/.tests/notifier/docker-compose.yml
@@ -0,0 +1,115 @@
+services:
+ bunkerweb:
+ image: bunkerity/bunkerweb:1.6.0
+ ports:
+ - 80:8080/tcp
+ - 443:8443/tcp
+ - 443:8443/udp
+ environment:
+ - API_WHITELIST_IP=127.0.0.0/8 10.20.30.0/24
+ networks:
+ - bw-universe
+ - bw-services
+
+ bw-scheduler:
+ image: bunkerity/bunkerweb-scheduler:1.6.0
+ depends_on:
+ - bunkerweb
+ volumes:
+ - ./bw-data/plugins:/data/plugins
+ environment:
+ - BUNKERWEB_INSTANCES=bunkerweb
+ - MULTISITE=yes
+ - SERVER_NAME=discord.example.com slack.example.com webhook.example.com matrix.example.com
+ - API_WHITELIST_IP=127.0.0.0/8 10.20.30.0/24
+ - LOG_LEVEL=info
+ - USE_BAD_BEHAVIOR=no
+ - USE_LIMIT_REQ=no
+ - USE_BUNKERNET=no
+ - USE_BLACKLIST=no
+ - USE_GREYLIST=no
+ - USE_WHITELIST=no
+ - USE_MODSECURITY=no
+ - USE_ANTIBOT=no
+ - SERVE_FILES=no
+ - DISABLE_DEFAULT_SERVER=yes
+ - AUTO_LETS_ENCRYPT=no
+ - USE_LETS_ENCRYPT=no
+ - REDIRECT_HTTP_TO_HTTPS=no
+ - AUTO_REDIRECT_HTTP_TO_HTTPS=no
+ # Notifier webhook URLs are global; point each at a distinct mock path.
+ # discord -> ratelimit mock (429 then 200) to exercise the retry path.
+ - DISCORD_WEBHOOK_URL=http://ratelimit:8080/discord
+ - DISCORD_RETRY_IF_LIMITED=yes
+ - SLACK_WEBHOOK_URL=http://mock:8080/slack
+ - WEBHOOK_URL=http://mock:8080/webhook
+ # matrix posts a PUT to /_matrix/client/... on the catch-all echo mock (no
+ # 429-retry path of its own). Globals are mandatory; values are dummies.
+ - MATRIX_BASE_URL=http://mock:8080
+ - MATRIX_ROOM_ID=!test:example.com
+ - MATRIX_ACCESS_TOKEN=test-token
+ - MATRIX_INCLUDE_HEADERS=yes
+ # discord.example.com : notify on a blacklisted URI
+ - discord.example.com_USE_REVERSE_PROXY=yes
+ - discord.example.com_REVERSE_PROXY_HOST=http://hello:8080
+ - discord.example.com_REVERSE_PROXY_URL=/
+ - discord.example.com_USE_DISCORD=yes
+ - discord.example.com_USE_BLACKLIST=yes
+ - discord.example.com_BLACKLIST_URI=/blocked
+ # slack.example.com
+ - slack.example.com_USE_REVERSE_PROXY=yes
+ - slack.example.com_REVERSE_PROXY_HOST=http://hello:8080
+ - slack.example.com_REVERSE_PROXY_URL=/
+ - slack.example.com_USE_SLACK=yes
+ - slack.example.com_USE_BLACKLIST=yes
+ - slack.example.com_BLACKLIST_URI=/blocked
+ # webhook.example.com
+ - webhook.example.com_USE_REVERSE_PROXY=yes
+ - webhook.example.com_REVERSE_PROXY_HOST=http://hello:8080
+ - webhook.example.com_REVERSE_PROXY_URL=/
+ - webhook.example.com_USE_WEBHOOK=yes
+ - webhook.example.com_USE_BLACKLIST=yes
+ - webhook.example.com_BLACKLIST_URI=/blocked
+ # matrix.example.com
+ - matrix.example.com_USE_REVERSE_PROXY=yes
+ - matrix.example.com_REVERSE_PROXY_HOST=http://hello:8080
+ - matrix.example.com_REVERSE_PROXY_URL=/
+ - matrix.example.com_USE_MATRIX=yes
+ - matrix.example.com_USE_BLACKLIST=yes
+ - matrix.example.com_BLACKLIST_URI=/blocked
+ networks:
+ - bw-universe
+
+ # Webhook sink: logs every inbound request (incl. JSON body) to stdout, so the
+ # async POST from each notifier plugin can be asserted via `docker compose logs`.
+ mock:
+ image: mendhak/http-https-echo:31
+ environment:
+ - HTTP_PORT=8080
+ networks:
+ - bw-services
+
+ # Stateful sink for discord: 429 + Retry-After on the first hit, then 200, so
+ # the retry path (DISCORD_RETRY_IF_LIMITED=yes) can be asserted.
+ ratelimit:
+ image: openresty/openresty:alpine
+ volumes:
+ - ./ratelimit.conf:/etc/nginx/conf.d/default.conf:ro
+ networks:
+ - bw-services
+
+ # Upstream so the readiness poll (curl | grep hello) works like the other tests.
+ hello:
+ image: nginxdemos/nginx-hello
+ networks:
+ - bw-services
+
+networks:
+ bw-universe:
+ name: bw-universe
+ ipam:
+ driver: default
+ config:
+ - subnet: 10.20.30.0/24
+ bw-services:
+ name: bw-services
diff --git a/.tests/notifier/ratelimit.conf b/.tests/notifier/ratelimit.conf
new file mode 100644
index 0000000..20bddcb
--- /dev/null
+++ b/.tests/notifier/ratelimit.conf
@@ -0,0 +1,30 @@
+# Stateful mock for the notifier 429/Retry-After retry path. Returns 429 +
+# Retry-After on the FIRST request to a path, then 200 — so a plugin with
+# RETRY_IF_LIMITED=yes should retry once and succeed (2 requests total). Logs
+# each request (with body) to stderr so the test can count retries and assert
+# the payload shape.
+lua_shared_dict hits 1m;
+
+server {
+ listen 8080;
+ error_log /dev/stderr info;
+
+ location / {
+ lua_need_request_body on;
+ content_by_lua_block {
+ local key = ngx.var.uri
+ local n = (ngx.shared.hits:get(key) or 0) + 1
+ ngx.shared.hits:set(key, n)
+ local body = ngx.req.get_body_data() or ""
+ ngx.log(ngx.ERR, "MOCKREQ uri=", key, " n=", n, " body=", body)
+ if n == 1 then
+ ngx.header["Retry-After"] = "1"
+ ngx.status = 429
+ ngx.say("rate limited")
+ else
+ ngx.status = 200
+ ngx.say("ok")
+ end
+ }
+ }
+}
diff --git a/.tests/virustotal.sh b/.tests/virustotal.sh
index 474dc15..45ca1b4 100755
--- a/.tests/virustotal.sh
+++ b/.tests/virustotal.sh
@@ -13,13 +13,17 @@ do_and_check_cmd mkdir -p /tmp/bunkerweb-plugins/virustotal/bw-data/plugins
do_and_check_cmd cp -r ./virustotal /tmp/bunkerweb-plugins/virustotal/bw-data/plugins
do_and_check_cmd sudo chown -R 101:101 /tmp/bunkerweb-plugins/virustotal/bw-data
-# Copy compose
+# Copy compose + mock VT API config
do_and_check_cmd cp .tests/virustotal/docker-compose.yml /tmp/bunkerweb-plugins/virustotal
+do_and_check_cmd cp .tests/virustotal/vt-mock.conf /tmp/bunkerweb-plugins/virustotal
# Edit compose
do_and_check_cmd sed -i "s@bunkerity/bunkerweb:.*\$@bunkerweb:tests@g" /tmp/bunkerweb-plugins/virustotal/docker-compose.yml
do_and_check_cmd sed -i "s@bunkerity/bunkerweb-scheduler:.*\$@bunkerweb-scheduler:tests@g" /tmp/bunkerweb-plugins/virustotal/docker-compose.yml
-do_and_check_cmd sed -i "s@%VTKEY%@${VIRUSTOTAL_API_KEY}@g" /tmp/bunkerweb-plugins/virustotal/docker-compose.yml
+
+# The compose points the plugin at a local mock VT API, so no real key is needed.
+# To exercise the real API locally instead, edit docker-compose.yml and set
+# VIRUSTOTAL_API_URL=https://www.virustotal.com/api/v3 + a real VIRUSTOTAL_API_KEY.
# Download EICAR file
do_and_check_cmd wget -O /tmp/bunkerweb-plugins/virustotal/eicar.com https://secure.eicar.org/eicar.com
@@ -85,6 +89,60 @@ if [ "$success" == "ko" ] ; then
echo "❌ Error did not receive 403 code"
exit 1
fi
+
+# Assert the plugin actually queried the mock VT API for the EICAR hash
+# (proves the 403 came from VirusTotal, not some other deny).
+echo "ℹ️ Checking the mock VT API was queried ..."
+if ! docker compose logs mock 2>/dev/null | grep -F "/files/275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f" >/dev/null 2>&1 ; then
+ docker compose logs
+ docker compose down -v
+ echo "❌ Error: plugin never queried the mock VT API for the EICAR hash"
+ exit 1
+fi
+
+# A clean file must NOT be denied (mock returns 404 = not found on VT = clean).
+# The hello upstream only accepts GET, so the clean multipart POST reaches it and
+# comes back 405 (method). Accept any 2xx or non-403 4xx, but fail on 403 (denied)
+# and on 5xx/000 (a crash or fail-closed regression must not hide behind "not 403").
+echo "ℹ️ Testing that a clean file is not blocked ..."
+printf 'just a clean file\n' > /tmp/bunkerweb-plugins/virustotal/clean.txt
+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)"
+case "$code" in
+403) clean_err="should not be denied by VirusTotal" ;;
+000 | 5??) clean_err="caused an upstream error/crash" ;;
+*) clean_err="" ;;
+esac
+if [ -n "$clean_err" ] ; then
+ docker compose logs
+ docker compose down -v
+ echo "❌ Error: clean file $clean_err (got $code)"
+ exit 1
+fi
+
+# A malicious IP must be denied (real-ip trusts the X-Forwarded-For we send)
+echo "ℹ️ Testing that a malicious IP is denied ..."
+code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: www.example.com" -H "X-Forwarded-For: 1.2.3.4" http://localhost/)"
+if [ "$code" != "403" ] ; then
+ docker compose logs
+ docker compose down -v
+ echo "❌ Error: malicious IP should be denied (got $code, expected 403)"
+ exit 1
+fi
+
+# Error paths: a VT API failure must FAIL OPEN — the request is allowed through,
+# never denied (403) and never leaked as a server error (5xx) to the client.
+# 5.5.5.5 -> mock returns 500 ; 6.6.6.6 -> mock returns unparsable JSON.
+for bad_ip in 5.5.5.5 6.6.6.6 ; do
+ echo "ℹ️ Testing fail-open when VT API errors for $bad_ip ..."
+ code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: www.example.com" -H "X-Forwarded-For: $bad_ip" http://localhost/)"
+ if [ "$code" != "200" ] ; then
+ docker compose logs
+ docker compose down -v
+ echo "❌ Error: VT API failure for $bad_ip should fail open (got $code, expected 200)"
+ exit 1
+ fi
+done
+
if [ "$1" = "verbose" ] ; then
docker compose logs
fi
diff --git a/.tests/virustotal/docker-compose.yml b/.tests/virustotal/docker-compose.yml
index 48631cd..10989d6 100644
--- a/.tests/virustotal/docker-compose.yml
+++ b/.tests/virustotal/docker-compose.yml
@@ -22,8 +22,18 @@ services:
- SERVER_NAME=www.example.com
- API_WHITELIST_IP=127.0.0.0/8 10.20.30.0/24
- USE_VIRUSTOTAL=yes
- - VIRUSTOTAL_API_KEY=%VTKEY%
+ # Point the plugin at the local mock VT API (deterministic, no real key).
+ - VIRUSTOTAL_API_KEY=dummy
+ - VIRUSTOTAL_API_URL=http://mock:8080
- LOG_LEVEL=info
+ # Trust X-Forwarded-For from the test client so the IP-deny case can
+ # present a global IP (the real docker client IP is private and would
+ # skip the IP scan via ip_is_global). File-scan cases send no XFF, so they
+ # keep the private client IP and only exercise the file path.
+ - USE_REAL_IP=yes
+ - REAL_IP_FROM=0.0.0.0/0
+ - REAL_IP_HEADER=X-Forwarded-For
+ - REAL_IP_RECURSIVE=no
- USE_BAD_BEHAVIOR=no
- USE_LIMIT_REQ=no
- USE_LIMIT_CONN=no
@@ -41,6 +51,14 @@ services:
networks:
- bw-services
+ # Mock VirusTotal API: deterministic responses keyed on the request path.
+ mock:
+ image: nginx:alpine
+ volumes:
+ - ./vt-mock.conf:/etc/nginx/conf.d/default.conf:ro
+ networks:
+ - bw-services
+
volumes:
bw-data:
diff --git a/.tests/virustotal/vt-mock.conf b/.tests/virustotal/vt-mock.conf
new file mode 100644
index 0000000..de4ca50
--- /dev/null
+++ b/.tests/virustotal/vt-mock.conf
@@ -0,0 +1,44 @@
+# Mock VirusTotal API v3. Deterministic replacement for the real API so CI does
+# not depend on network / rate limits / a real key. The plugin queries
+# GET /files/ and GET /ip_addresses/.
+server {
+ listen 8080;
+ default_type application/json;
+ # Log every request to stdout so tests can assert the plugin actually
+ # reached the mock (docker compose logs mock).
+ access_log /dev/stdout;
+
+ # EICAR test-file sha256 -> reported as malicious so the plugin denies.
+ location = /files/275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f {
+ return 200 '{"data":{"attributes":{"last_analysis_stats":{"harmless":0,"malicious":60,"suspicious":0,"undetected":0,"timeout":0}}}}';
+ }
+
+ # Any other file hash -> 404 (plugin treats this as "not found" = clean).
+ location /files/ {
+ return 404 '{"error":{"code":"NotFoundError"}}';
+ }
+
+ # One specific IP -> malicious (used by the IP-deny test); other IPs clean.
+ location = /ip_addresses/1.2.3.4 {
+ return 200 '{"data":{"attributes":{"last_analysis_stats":{"harmless":0,"malicious":10,"suspicious":0,"undetected":0,"timeout":0}}}}';
+ }
+
+ # Error-path IPs: exercise the plugin's fail-open behaviour. A VT API failure
+ # (5xx) or an unparsable body must NOT deny the request. 5.5.5.5 -> upstream
+ # 500; 6.6.6.6 -> HTTP 200 with a body that is not valid JSON.
+ location = /ip_addresses/5.5.5.5 {
+ return 500 '{"error":{"code":"InternalError"}}';
+ }
+ location = /ip_addresses/6.6.6.6 {
+ default_type text/plain;
+ return 200 'this is not json';
+ }
+
+ location /ip_addresses/ {
+ return 200 '{"data":{"attributes":{"last_analysis_stats":{"harmless":80,"malicious":0,"suspicious":0,"undetected":0,"timeout":0}}}}';
+ }
+
+ location / {
+ return 404 '{"error":{"code":"NotFoundError"}}';
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..c0962e1
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,101 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Repository purpose
+
+Official external plugins for [BunkerWeb](https://github.com/bunkerity/bunkerweb). Each top-level directory (`authentik/`, `clamav/`, `cloudflare/`, `coraza/`, `discord/`, `matrix/`, `slack/`, `virustotal/`, `webhook/`) is an independently-shipped plugin. There is no monorepo build — plugins are consumed by BunkerWeb at runtime by mounting the plugin directory into `/data/plugins` of the `bunkerweb-scheduler` container.
+
+## Plugin anatomy
+
+Every plugin follows the same BunkerWeb-imposed layout:
+
+- `plugin.json` — id, name, version, `stream` (yes/no/partial), and the `settings` schema (each setting has `context` = `global`|`multisite`, `default`, `regex`, UI metadata). BunkerWeb reads this to register settings and render the UI.
+- `.lua` — main logic; requires `bunkerweb.plugin` and subclasses it via `middleclass`. Hook methods (`init_worker`, `access`, `log`, `preread`, etc.) return via `self:ret(ok, msg, [status])`. Runs inside OpenResty in the BunkerWeb nginx container.
+- `ui/actions.py` — optional Python hooks for the BunkerWeb web UI. `pre_render(**kwargs)` returns card data; a function named after the plugin is called for the main page. `kwargs["bw_instances_utils"]` exposes BW helpers like `get_ping(service)`.
+- `README.md` — user-facing docs; the settings table is generated from `plugin.json` via `.tests/misc/json2md.py` (run manually when settings change).
+- `docs/diagram.mmd` — Mermaid architecture diagram shipped with each plugin and embedded inline as a ```mermaid block in its `README.md`(GitHub renders it natively). authentik is the reference style (subgraph + verdict diamond +`classDef ok/deny/svc/app`colors +`accTitle`/`accDescr`).
+
+Coraza is special: it also ships `coraza/api/` — a standalone Go HTTP service (`main.go`, built by `coraza/api/Dockerfile`) that wraps `corazawaf/coraza/v3` and is called over HTTP by `coraza.lua`. Image is published as `bunkerity/bunkerweb-coraza`. CRS rules are vendored at build time by `crs.sh` (pinned to a commit hash, `.git` stripped).
+
+## Versioning — two different version numbers
+
+There are **two unrelated version streams**, easy to confuse:
+
+1. **Individual plugin version** in each `plugin.json` (currently `1.11`). Bump with `./misc/update_version.sh ` **run from the repo root** (it uses `find .` + `README.md` relative paths) — it rewrites every `plugin.json` **and the README badge** to that plugin version (see `misc/update_version.sh:17-18`).
+2. **Plugins-collection version** in `COMPATIBILITY.json`, which maps a collection version (currently `1.8`) to the BunkerWeb versions it supports (e.g. `"1.8": ["1.6.0", ...]`). Controls compatibility gates.
+
+These two streams are independent and `update_version.sh` only touches (1) — it now updates the README badge too (the badge's sed pattern was anchored on a leading `"` that never matched the shields.io URL, so the badge silently froze; fixed to anchor on `/badge/`, so it tracks the plugin version automatically). When bumping: run the script for (1), and edit `COMPATIBILITY.json` by hand for (2).
+
+## Testing
+
+Two layers: fast **unit tests** (no Docker) and **end-to-end integration tests** under `.tests/`.
+
+### Unit tests (no Docker, run locally + in CI)
+
+- **Go** — `coraza/api/main_test.go` covers the Go WAF service. Run `cd coraza/api && go test ./...`.
+- **Python (pytest)** — `tests/test_ui_actions.py` parametrizes over every plugin's `ui/actions.py`; `tests/conftest.py` provides a `FakePingUtils` mock for `bw_instances_utils`. Run `pytest tests/ -q`.
+- **Lua (busted)** — `spec/*_helpers_spec.lua` exercises the pure-logic helper modules (`authentik`, `clamav`, `discord`, `matrix`, `virustotal`) against `spec/helpers/fake_ngx.lua`. Run `busted`. This is why upstream-specific logic is factored into `_helpers.lua` — so it's testable outside OpenResty.
+
+### Integration tests (Docker, e2e)
+
+- `./.tests/bw.sh ` pulls `bunkerity/bunkerweb:` + `bunkerweb-scheduler:` and retags them as `bunkerweb:tests` / `bunkerweb-scheduler:tests`. Must run first.
+- Per-plugin scripts: `./.tests/clamav.sh`, `coraza.sh`, `virustotal.sh`, `authentik.sh`, and `notifier.sh` (the last covers discord/slack/webhook/matrix together in one multisite stack). Each copies the plugin into `/tmp/bunkerweb-plugins//bw-data/plugins` (owned `101:101`), copies `.tests//docker-compose.yml`, `sed`s it to the `:tests` tagged images, then `docker compose up --build -d` and polls with `curl`. EICAR file is downloaded for ClamAV; VirusTotal requires `VIRUSTOTAL_API_KEY`. Several use mock upstreams instead of the real service: `.tests/virustotal/vt-mock.conf`, `.tests/authentik/mock-outpost.conf`, `.tests/notifier/ratelimit.conf`.
+- `.tests/utils.sh` provides `do_and_check_cmd` (runs a command, echoes output on failure, exits on non-zero) and `git_secure_clone` (pinned-commit clone helper). Source it with `. .tests/utils.sh`.
+- Run a single plugin's e2e: `./.tests/bw.sh && ./.tests/clamav.sh` (or `coraza`/`virustotal`/`authentik`/`notifier`). CI resolves `` to the latest stable release; locally any pulled tag works (e.g. a stable `1.6.1`, or `dev` for the upcoming build). Pass `verbose` as `$1` to dump compose logs on success.
+- Tests need `sudo` (for chowning to BW's uid 101) and a working Docker daemon. They leave state in `/tmp/bunkerweb-plugins/` — the scripts clean it at start, but `docker compose down -v` is the safe manual reset.
+
+## CI/CD (`.github/workflows/tests.yml`)
+
+Runs on push to `dev` and `main`. A `tag` job resolves the **latest stable BunkerWeb release** at runtime via the GitHub `releases/latest` API (`gh api repos/bunkerity/bunkerweb/releases/latest`, which excludes drafts and pre-releases; a leading `v` is stripped) and feeds that tag to every downstream job — same version on both branches, never pinned. Pipeline:
+
+1. **codeql** — `.github/workflows/codeql.yml` (also runs weekly on a cron), matrix `[python, go]`.
+2. **lint** — `pre-commit run --all-files`.
+3. **unit** — matrix `[go, python, lua]` (the unit tests above).
+4. **integration** — `needs: [tag, lint, unit]`, matrix `plugin: [clamav, coraza, virustotal, authentik, notifier]`; each runs `.tests/bw.sh ` then `.tests/.sh`.
+5. **build-push** — `main` only: `./.tests/build-push.sh ` builds and pushes the `bunkerweb-coraza` image.
+
+There is **no pinned BW version** — the `tag` job always resolves the latest stable release, so the tests track upstream automatically (`COMPATIBILITY.json` is not consulted here). The resolved tag flows into `bw.sh` (pulls `bunkerity/bunkerweb[-scheduler]:`) and, on `main`, into `build-push.sh` (which also tags the pushed `bunkerweb-coraza` image with it). The job fails fast if the API returns an empty or pre-release (hyphenated) tag.
+
+Two tradeoffs of tracking upstream: (1) the `dev` branch no longer tests against BunkerWeb's `dev` build, so a plugin change that relies on an unreleased BW feature gets no CI coverage until BW ships a stable release; (2) every `main` push republishes `bunkerweb-coraza:latest` (and `:`) — harmless here because that image is a self-contained Go binary + vendored CRS, independent of the BW base tag, so only the extra tag's value moves.
+
+## Releasing (`.github/workflows/release.yml`)
+
+Releases are cut automatically from `main`. After `Tests` succeeds there, a `Release` workflow (a `workflow_run` trigger on the `Tests` workflow) reads the plugin version from `plugin.json` and, if no release `v` exists yet (**drafts included** — it matches `tag_name` via `gh api`, since a draft has no git tag), opens a **draft** GitHub release with `softprops/action-gh-release` and auto-generated notes. A maintainer reviews and publishes it; a push that doesn't bump the version is a no-op. Two consequences of the `workflow_run` model: the file only fires once it is on the default branch (`main`), and it can't be tested from `dev`. So **cutting a release = `./misc/update_version.sh ` → merge to `main` → publish the draft** the workflow creates.
+
+## Linting — pre-commit is the source of truth
+
+`.pre-commit-config.yaml` pins every linter to a frozen SHA. Install once with `pre-commit install`, then `pre-commit run --all-files` before committing. The stack:
+
+- `black` (Python, py3.9) — configured in `pyproject.toml` with `line-length = 160`
+- `flake8` — `--max-line-length=250 --ignore=E266,E402,E722,W503`
+- `stylua` — config in `stylua.toml`
+- `luacheck` — config in `.luacheckrc`, run with `--std min --codes --ranges --no-cache`
+- `prettier`, `shellcheck`, `codespell`, `gitleaks`, standard pre-commit hygiene hooks
+
+`coraza/api/coreruleset/**` and `LICENSE.md` are excluded from all hooks.
+
+## Writing Lua plugin code — conventions to follow
+
+- Always subclass via `local = class("", plugin)` and call `plugin.initialize(self, "", ctx)` in `initialize(self, ctx)`.
+- Every hook method returns `self:ret(ok_bool, msg, [http_status])`. To deny a request, return `self:ret(true, "reason", utils.get_deny_status())`.
+- Gate expensive work at `init_worker` with `utils.has_variable("USE_", "yes")` — avoids connecting to upstream services when the plugin is globally disabled. Skip when `self.is_loading` is true.
+- Use `ngx.socket` for TCP (see `clamav.lua` INSTREAM protocol) and `resty.http` for HTTP upstreams. Prefer `resty.upload` for streaming request bodies (`clamav.lua` is the reference).
+- Cache scan results keyed by the file's hash so identical uploads skip the upstream. `clamav.lua` hashes the body with **SHA-512** (`resty.sha512`); `virustotal.lua` uses **SHA-256** (`resty.sha256`, matching VT's file-id) with a 24h TTL — it's the reference for cached HTTP-API lookups.
+
+## Plugin-specific notes
+
+The "Plugin anatomy" layout and the Lua conventions above are shared by all plugins. The non-obvious, per-plugin logic lives in code — these pointers save a re-read:
+
+- **coraza** — the only plugin with an external sidecar. `coraza.lua` talks HTTP to the Go service in `coraza/api/` (`/ping` health check, `/request` for the verdict; the service returns deny/msg). CRS rules are vendored at build time by `coraza/api/crs.sh`, **pinned to a commit hash** with `.git` stripped; the two-stage `coraza/api/Dockerfile` builds the Go binary (multiphase-evaluation build tag) and bakes the rules in. Bumping CRS = bump the hash in `crs.sh`. Image: `bunkerity/bunkerweb-coraza`.
+- **clamav** — speaks ClamAV's **binary INSTREAM protocol** over `ngx.socket.tcp` (each chunk framed by a 4-byte big-endian length, terminated by a zero-length frame), not HTTP. Streams the request body via `resty.upload`, scanning only multipart parts that have a real filename (`Content-Disposition` parsing handles quoted, unquoted, and RFC 5987 `filename*`). SHA-512 cache (see above).
+- **virustotal** — HTTP to VT API v3 with `VIRUSTOTAL_API_KEY`; scans files and/or IPs. Verdict logic is in `virustotal_helpers.lua` (`evaluate()` compares VT's suspicious/malicious counts to configurable thresholds) — unit-tested in `spec/virustotal_helpers_spec.lua`. SHA-256 cache, 24h TTL. No usable ping endpoint, so `init_worker` does not pre-connect.
+- **authentik** — forward-auth: `confs/` ships the nginx snippet; the Lua access handler whitelists outpost paths and forwards/extracts auth headers (needs enlarged proxy buffers for big JWTs).
+- **discord / slack / webhook / matrix** — notifiers: build a JSON payload and POST it to an external URL from an `ngx.timer.at` async timer on the `log` hook (denials only), so request latency is unaffected. The generic case; little plugin-specific state.
+- **cloudflare** — the only plugin with **scheduler `jobs`** (declared in `plugin.json` `"jobs"`, scripts under `cloudflare/jobs/`). Four jobs: `cf-trusted-ips-download` (downloads Cloudflare's public IP ranges → `set_real_ip_from`), `cf-manage-origin-certs` (manages Cloudflare Origin CA certs via the official `cloudflare` Python SDK — bundled in the scheduler image — and serves them through the `ssl_certificate` hook, storing parsed cert/key in `self.internalstore` like core letsencrypt), `cf-aop-ca-download` (Authenticated Origin Pulls CA), and `cf-edge-ban-sync` (pushes BunkerWeb bans to a Cloudflare account IP List, reads bans from Redis via `common_utils.get_redis_client`). `cloudflare.lua` denies non-Cloudflare peers (`access`/`preread`, fails **open** while the IP list is empty so it never denies everyone at boot), verifies Authenticated Origin Pulls (`$ssl_client_verify`), and strips spoofed `CF-*` headers. Pure logic is in `cloudflare_helpers.lua` (busted) + `cloudflare/jobs/cloudflare_helpers.py` (pytest). e2e (`.tests/cloudflare.sh`) mocks the Cloudflare IP-list + API (the dynamic `cf-api-mock` signs the submitted CSR) and drives deny/allow via **real container source IPs** on docker networks inside/outside a Cloudflare range (the deny check reads `realip_remote_addr`, the TCP peer — it can't be header-spoofed). Confs gate `real_ip_header` on `USE_REAL_IP != "yes"` and the AOP `ssl_verify_client` on `USE_MTLS != "yes"` to avoid duplicate-directive clashes with the core realip/mtls plugins. Settings/jobs support the Docker-secret `_FILE` convention.
+
+## Pull requests & commits
+
+- Default branch for PRs is `main`; active development lands on `dev` first.
+- Commits follow conventional-commits style (`feat:`, `fix:`, `refactor:`, `ci/cd -`). See `git log` for prior examples.
+- CONTRIBUTING.md requires an issue before non-trivial PRs.
diff --git a/COMPATIBILITY.json b/COMPATIBILITY.json
index 2b3f9b8..b4c3384 100644
--- a/COMPATIBILITY.json
+++ b/COMPATIBILITY.json
@@ -14,6 +14,17 @@
"1.6.0-rc2",
"1.6.0-rc3",
"1.6.0-rc4",
- "1.6.0"
+ "1.6.0",
+ "1.6.1",
+ "1.6.2",
+ "1.6.3",
+ "1.6.4",
+ "1.6.5",
+ "1.6.6",
+ "1.6.7",
+ "1.6.8",
+ "1.6.9",
+ "1.6.10",
+ "1.6.11"
]
}
diff --git a/README.md b/README.md
index 1d764a0..a432e67 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
-
+
@@ -21,9 +21,12 @@ The installation of external plugins is covered in the [plugins section](https:/
Each plugin is located in a subdirectory of this repository. A README file located in each subdirectory contains documentation about the plugin. Here is the list :
+- [Authentik](https://github.com/bunkerity/bunkerweb-plugins/tree/main/authentik)
- [ClamAV](https://github.com/bunkerity/bunkerweb-plugins/tree/main/clamav)
+- [Cloudflare](https://github.com/bunkerity/bunkerweb-plugins/tree/main/cloudflare)
- [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/authentik/README.md b/authentik/README.md
new file mode 100644
index 0000000..5812dca
--- /dev/null
+++ b/authentik/README.md
@@ -0,0 +1,239 @@
+# Authentik plugin
+
+
+
+```mermaid
+flowchart TD
+ accTitle: BunkerWeb Authentik plugin request flow
+ accDescr: A client request first passes BunkerWeb core checks, then authentik.lua calls the Authentik outpost auth endpoint. On 200 the request reaches the upstream with optional identity headers while client-supplied identity headers are stripped. On 401 or 403 the client is redirected to the Authentik SSO login.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb access phase]
+ direction TB
+ core["1. Core checks first: rate limit, bad behavior, antibot, DNSBL, black / whitelist"]
+ lua["2. authentik.lua: GET AUTHENTIK_URL + /outpost.goauthentik.io/auth/nginx (forwards cookies)"]
+ core --> lua
+ end
+
+ verdict{"Outpost verdict"}
+ allow["Allow to upstream: forward X-authentik-* (opt-in), client X-authentik-* stripped"]
+ redirect["302 to /outpost.goauthentik.io/start"]
+ upstream([Upstream app: reverse proxy or served files])
+ outpost[["Authentik outpost: /auth/nginx, /start, /callback, /sign_out"]]
+ server([Authentik server: SSO login flow])
+
+ client -->|request| core
+ lua -.->|auth sub-request| outpost
+ outpost -.->|status| verdict
+ verdict -->|200| allow
+ verdict -->|401 or 403| redirect
+ allow --> upstream
+ redirect -->|browser follows| client
+ client -->|"/outpost.goauthentik.io/* SSO, proxied by authentik.conf"| outpost
+ outpost --- server
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef ak fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class allow,upstream ok;
+ class redirect deny;
+ class outpost,server ak;
+ class client,core,lua app;
+```
+
+This [plugin](https://www.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+adds [Authentik](https://goauthentik.io/) forward authentication to a
+BunkerWeb site. It works on top of any existing service configuration -
+reverse proxy, served files, custom location blocks - without replacing them.
+
+The auth check runs from Lua during BunkerWeb's access phase, so all of
+BunkerWeb's built-in checks (rate limit, bad behavior, antibot, DNSBL,
+whitelist / blacklist, ...) run _before_ the Authentik subrequest fires.
+Bots and rate-limited clients get denied without ever touching Authentik.
+
+# Table of contents
+
+- [Authentik plugin](#authentik-plugin)
+- [Table of contents](#table-of-contents)
+- [Request flow](#request-flow)
+- [Setup](#setup)
+ - [Docker / Swarm](#docker--swarm)
+ - [Authentik configuration](#authentik-configuration)
+ - [Verifying it works](#verifying-it-works)
+- [Settings](#settings)
+- [Troubleshooting](#troubleshooting)
+- [Notes](#notes)
+
+# Request flow
+
+For a request to `https://app.example.com/something`:
+
+1. BunkerWeb's access-phase checks run (rate limit, bad behavior, antibot,
+ DNSBL, blacklist, ...). If any of them deny, the request stops here.
+2. `authentik.lua` runs. If the URI is under `AUTHENTIK_OUTPOST_PATH`
+ (default `/outpost.goauthentik.io`), it passes through untouched - that's
+ the SSO flow itself, served by the outpost.
+3. Otherwise the handler does an HTTP `GET` against
+ `/outpost.goauthentik.io/auth/nginx`, forwarding the
+ browser's cookies and `X-Original-URL`.
+ - **200** - request continues to its normal destination (reverse proxy,
+ file serving, custom location). Any `Set-Cookie` from Authentik is
+ relayed to the client so the session refreshes correctly.
+ - **401 / 403** - `302` to `/start?rd=`, which
+ kicks off the SSO login.
+4. The server-level snippet (`confs/server-http/authentik.conf`) raises
+ `proxy_buffers` / `proxy_buffer_size`, sets `port_in_redirect off`, and
+ declares the `location ` block that proxies the SSO
+ endpoints (`/auth`, `/start`, `/callback`, `/sign_out`, ...) back to the
+ Authentik outpost. Keeping these on the protected site's own domain is
+ what lets the proxy provider's session cookie be scoped correctly.
+
+# Setup
+
+See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+of the BunkerWeb documentation for the generic plugin installation procedure
+(the short version: drop the `authentik/` directory into the scheduler's
+`/data/plugins/` and restart).
+
+## Docker / Swarm
+
+`AUTHENTIK_URL` is the URL **BunkerWeb itself** uses to call Authentik -
+typically an internal Docker network address. Users still complete the
+login on Authentik's own public URL (configured separately in Authentik,
+not here). Both BunkerWeb and the user's browser need to be able to reach
+that public URL; otherwise login redirects from `/outpost.../start` go
+nowhere.
+
+```yaml
+services:
+
+ bunkerweb:
+ image: bunkerity/bunkerweb:1.6.11
+ ...
+ networks:
+ - bw-services
+ - bw-authentik
+ ...
+
+ bw-scheduler:
+ image: bunkerity/bunkerweb-scheduler:1.6.11
+ ...
+ environment:
+ SERVER_NAME: "app.example.com"
+ USE_REVERSE_PROXY: "yes"
+ REVERSE_PROXY_HOST: "http://app:3000"
+ REVERSE_PROXY_URL: "/"
+
+ USE_AUTHENTIK: "yes"
+ # Internal URL - what BunkerWeb uses to call Authentik:
+ AUTHENTIK_URL: "http://authentik-server:9000"
+
+ authentik-server:
+ # Must also be reachable on a public URL (e.g. https://authentik.example.com)
+ # so users can complete the login flow.
+ image: ghcr.io/goauthentik/server:latest
+ ...
+ networks:
+ - bw-authentik
+
+networks:
+ bw-services:
+ name: bw-services
+ bw-authentik:
+ name: bw-authentik
+```
+
+## Authentik configuration
+
+In the Authentik admin UI:
+
+1. Create a **Proxy Provider** for the protected site in **Forward Auth
+ (single application)** mode. _External host_ should be the public URL of
+ the protected site (e.g. `https://app.example.com`).
+2. Create or assign an **Application** that uses the provider.
+3. Attach the application to an **Outpost**. The built-in _authentik Embedded
+ Outpost_ is the simplest choice - `AUTHENTIK_URL` then points at the
+ Authentik server itself (`http://authentik-server:9000` in the example
+ above). For a standalone outpost, point `AUTHENTIK_URL` at that outpost's
+ address instead.
+4. Make sure the Authentik server itself has a public URL (defined in
+ _System → Brands_ or via the `AUTHENTIK_HOST` env var). Browsers are
+ redirected there to enter credentials.
+
+## Verifying it works
+
+1. Reload the scheduler. The Authentik plugin should appear in BunkerWeb's
+ plugins list (web UI or scheduler logs).
+2. Visit a protected URL in a private window. You should land on the
+ Authentik login page (note the URL - it's served by Authentik, not by
+ BunkerWeb).
+3. After logging in, you should be redirected back to the protected URL and
+ see the upstream service's response.
+4. In the Authentik server logs you should see one `/outpost.goauthentik.io/auth/nginx`
+ call per protected request. If you see far more (e.g. one per static
+ asset), the outpost-path skip isn't matching - double-check
+ `AUTHENTIK_OUTPOST_PATH`.
+
+# Settings
+
+| Setting | Default | Context | Multiple | Description |
+| --------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `USE_AUTHENTIK` | `no` | multisite | no | Activate Authentik forward authentication for this site. |
+| `AUTHENTIK_URL` | | multisite | no | Base URL of the Authentik outpost (e.g. http://authentik:9000 for the embedded outpost or http://outpost:9000 for a standalone one). The plugin calls this base URL followed by /outpost.goauthentik.io/auth/nginx for the auth check and uses the same base for the outpost proxy. Required when USE_AUTHENTIK is yes. |
+| `AUTHENTIK_OUTPOST_PATH` | `/outpost.goauthentik.io` | multisite | no | Local URL path under which the Authentik outpost endpoints (auth, start, callback, sign_out, ...) are exposed on this site. Must start with /. |
+| `AUTHENTIK_SSL_VERIFY` | `yes` | multisite | no | Verify the TLS certificate of the Authentik outpost (used both by the Lua auth subrequest and the outpost proxy_pass). |
+| `AUTHENTIK_TIMEOUT` | `5000` | global | no | Timeout (ms) for the Lua auth subrequest to the Authentik outpost. |
+| `AUTHENTIK_PROXY_BUFFER_SIZE` | `32k` | multisite | no | Value used for proxy_buffer_size on this server. Authentik sets large response headers that may overflow the default buffer. |
+| `AUTHENTIK_PROXY_BUFFERS` | `8 16k` | multisite | no | Value used for proxy_buffers on this server. Authentik sets large response headers that may overflow the default buffers. |
+| `AUTHENTIK_PASS_IDENTITY_HEADERS` | `no` | multisite | no | Forward Authentik's identity headers (X-authentik-username, -groups, -email, ...) from the auth response to the upstream. Every client-supplied X-authentik-\* request header is always stripped before the request reaches the upstream, regardless of this setting, so a client can never spoof an identity. Enable only if your backend uses trusted-header authentication. |
+| `AUTHENTIK_IDENTITY_HEADERS` | `X-authentik-username X-authentik-groups X-authentik-entitlements X-authentik-email X-authentik-name X-authentik-uid` | multisite | no | Space/comma-separated list of identity headers to forward from Authentik's auth response when AUTHENTIK_PASS_IDENTITY_HEADERS is yes. Every X-authentik-\* request header from the client is always stripped regardless of this list, to prevent spoofing; this list only selects which Authentik response headers are passed to the upstream. Defaults match Authentik's nginx forward-auth header set. |
+
+# Troubleshooting
+
+- **HTTP 500 on every protected URL, scheduler log says "AUTHENTIK_URL not
+ configured".** `USE_AUTHENTIK=yes` is set but `AUTHENTIK_URL` is empty.
+- **`upstream sent too big header while reading response header from upstream`.**
+ Raise `AUTHENTIK_PROXY_BUFFER_SIZE` (try `64k`) and/or `AUTHENTIK_PROXY_BUFFERS`.
+- **Login loop - browser cycles between the protected URL and the Authentik
+ login page.** Almost always a cookie-domain mismatch. Confirm that
+ `AUTHENTIK_OUTPOST_PATH` resolves on the _same domain_ as the protected
+ app, and that the Authentik proxy provider's _External host_ matches that
+ domain exactly (scheme included).
+- **`502` from the outpost path.** BunkerWeb can't reach `AUTHENTIK_URL` -
+ check the Docker network membership and that the Authentik service is up.
+- **Auth subrequests time out.** Increase `AUTHENTIK_TIMEOUT`, or move the
+ Authentik outpost closer to BunkerWeb (ideally same Docker network).
+- **Bots are still hitting Authentik.** They shouldn't be - `bad_behavior`
+ and friends run before the Authentik subrequest. If you're seeing
+ unauthenticated traffic at the outpost, it's likely the SSO redirect
+ fanout from real users; check the Authentik logs by user agent.
+
+# Notes
+
+- **Identity headers downstream (opt-in).** By default this plugin only gates
+ access and forwards nothing about the user to the upstream. If your backend
+ uses trusted-header authentication (Nextcloud, Grafana header-auth,
+ Bookstack, ...), set `AUTHENTIK_PASS_IDENTITY_HEADERS=yes` to relay
+ Authentik's `X-authentik-*` headers from the auth response to the upstream.
+ Customize the set via `AUTHENTIK_IDENTITY_HEADERS`.
+
+ **Security:** _every_ client-supplied `X-authentik-*` request header is
+ stripped before the request reaches the upstream - on every gated request,
+ whether or not forwarding is enabled - so a client can never spoof an
+ identity by sending its own `X-authentik-username` (etc.). When forwarding
+ is enabled, only the headers Authentik actually returns are re-applied from
+ the `AUTHENTIK_IDENTITY_HEADERS` list; missing ones are left absent. The
+ default list matches Authentik's nginx forward-auth header set; extend it if
+ your Authentik version emits extra identity headers your backend trusts.
+
+- **Per-request cost.** Every gated request makes one HTTP call to the
+ Authentik outpost's `/auth/nginx`. The outpost caches session lookups, so
+ this is cheap - but keep `AUTHENTIK_URL` pointing at something nearby
+ (same Docker network is ideal).
+- **Domain-level vs single-application mode.** This plugin assumes the
+ _Forward Auth (single application)_ provider mode. Domain-level mode
+ (shared SSO cookie across `*.example.com`) needs additional Authentik
+ configuration but works with the same plugin settings as long as
+ `AUTHENTIK_OUTPOST_PATH` resolves on the protected domain.
diff --git a/authentik/authentik.lua b/authentik/authentik.lua
new file mode 100644
index 0000000..bc905cc
--- /dev/null
+++ b/authentik/authentik.lua
@@ -0,0 +1,160 @@
+local class = require("middleclass")
+local http = require("resty.http")
+local plugin = require("bunkerweb.plugin")
+local utils = require("bunkerweb.utils")
+
+local authentik = class("authentik", plugin)
+
+local ngx = ngx
+local ngx_req = ngx.req
+local ERR = ngx.ERR
+local WARN = ngx.WARN
+local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR
+local HTTP_MOVED_TEMPORARILY = ngx.HTTP_MOVED_TEMPORARILY
+local http_new = http.new
+local has_variable = utils.has_variable
+local tostring = tostring
+local tonumber = tonumber
+local lower = string.lower
+
+-- Pure string helpers live in a sibling module so busted can unit-test them
+-- outside OpenResty (see spec/authentik_helpers_spec.lua). BunkerWeb requires
+-- plugins as "/", so the sibling resolves as "authentik/authentik_helpers".
+local helpers = require("authentik/authentik_helpers")
+local starts_with = helpers.starts_with
+local rstrip_slash = helpers.rstrip_slash
+local split_headers = helpers.split_headers
+
+function authentik:initialize(ctx)
+ plugin.initialize(self, "authentik", ctx)
+end
+
+function authentik:is_needed()
+ if self.is_loading then
+ return false
+ end
+ if self.is_request and (self.ctx.bw.server_name ~= "_") then
+ return self.variables["USE_AUTHENTIK"] == "yes" and not ngx_req.is_internal()
+ end
+ local is_needed, err = has_variable("USE_AUTHENTIK", "yes")
+ if is_needed == nil then
+ self.logger:log(ERR, "can't check USE_AUTHENTIK variable : " .. err)
+ end
+ return is_needed
+end
+
+function authentik:access()
+ if not self:is_needed() then
+ return self:ret(true, "authentik not activated")
+ end
+
+ local outpost_path = rstrip_slash(self.variables["AUTHENTIK_OUTPOST_PATH"])
+ if outpost_path == nil or outpost_path == "" then
+ outpost_path = "/outpost.goauthentik.io"
+ end
+
+ -- Outpost endpoints (start, callback, sign_out, ...) handle their own flow,
+ -- and the /auth/nginx subrequest must not loop into us. Pass through.
+ local uri = self.ctx.bw.uri or ngx.var.uri or ""
+ if uri == outpost_path or starts_with(uri, outpost_path .. "/") then
+ return self:ret(true, "outpost endpoint, no auth check")
+ end
+
+ local upstream = rstrip_slash(self.variables["AUTHENTIK_URL"])
+ if upstream == nil or upstream == "" then
+ self.logger:log(WARN, "USE_AUTHENTIK is yes but AUTHENTIK_URL is empty, denying request")
+ return self:ret(true, "AUTHENTIK_URL not configured", HTTP_INTERNAL_SERVER_ERROR)
+ end
+
+ local scheme = ngx.var.scheme
+ local host = ngx.var.http_host or ngx.var.host
+ local request_uri = ngx.var.request_uri or uri
+ local original_url = scheme .. "://" .. host .. request_uri
+
+ local headers, err = ngx_req.get_headers()
+ if err == "truncated" then
+ self.logger:log(WARN, "too many request headers, auth check may be incomplete")
+ headers = headers or {}
+ end
+
+ local fwd_headers = {
+ ["Host"] = host,
+ ["X-Original-URL"] = original_url,
+ ["X-Original-URI"] = request_uri,
+ ["X-Forwarded-For"] = self.ctx.bw.remote_addr,
+ ["X-Forwarded-Host"] = host,
+ ["X-Forwarded-Proto"] = scheme,
+ }
+ for _, h in ipairs({ "cookie", "user-agent", "accept", "accept-language", "authorization" }) do
+ if headers[h] then
+ fwd_headers[h] = headers[h]
+ end
+ end
+
+ local httpc
+ httpc, err = http_new()
+ if not httpc then
+ return self:ret(true, "failed to create http client : " .. err, HTTP_INTERNAL_SERVER_ERROR)
+ end
+ httpc:set_timeout(tonumber(self.variables["AUTHENTIK_TIMEOUT"]) or 5000)
+
+ local ssl_verify = self.variables["AUTHENTIK_SSL_VERIFY"] ~= "no"
+ local auth_url = upstream .. "/outpost.goauthentik.io/auth/nginx"
+
+ local res
+ res, err = httpc:request_uri(auth_url, {
+ method = "GET",
+ headers = fwd_headers,
+ ssl_verify = ssl_verify,
+ keepalive = true,
+ })
+ if not res then
+ return self:ret(true, "auth subrequest failed : " .. tostring(err), HTTP_INTERNAL_SERVER_ERROR)
+ end
+
+ -- Forward any Set-Cookie from Authentik back to the client so the session
+ -- cookie / refresh lands on the protected domain.
+ local set_cookie = res.headers["Set-Cookie"]
+ if set_cookie then
+ ngx.header["Set-Cookie"] = set_cookie
+ end
+
+ if res.status == 200 then
+ -- Anti-spoofing: strip EVERY client-supplied X-authentik-* request header
+ -- before the request reaches the upstream, regardless of whether we forward
+ -- Authentik's own. A client must never be able to inject its own identity.
+ -- get_headers(0) lifts the default 100-header cap so a header flood can't
+ -- hide an entry past the limit.
+ local in_headers = ngx_req.get_headers(0)
+ for name in pairs(in_headers) do
+ if type(name) == "string" and starts_with(lower(name), "x-authentik-") then
+ ngx_req.clear_header(name)
+ end
+ end
+ -- Optionally forward Authentik's identity headers to a trusted-header backend
+ -- (Grafana, Nextcloud, ...) so it knows who the user is. Only values from
+ -- Authentik's auth response are set; client copies were stripped above.
+ if self.variables["AUTHENTIK_PASS_IDENTITY_HEADERS"] == "yes" then
+ for _, h in ipairs(split_headers(self.variables["AUTHENTIK_IDENTITY_HEADERS"])) do
+ local value = res.headers[h]
+ if value then
+ ngx_req.set_header(h, value)
+ end
+ end
+ end
+ return self:ret(true, "authentik authorized request")
+ end
+
+ if res.status == 401 or res.status == 403 then
+ local redirect = outpost_path .. "/start?rd=" .. ngx.escape_uri(original_url)
+ return self:ret(true, "authentik signin redirect", HTTP_MOVED_TEMPORARILY, redirect)
+ end
+
+ return self:ret(
+ true,
+ "unexpected status from authentik outpost : " .. tostring(res.status),
+ HTTP_INTERNAL_SERVER_ERROR
+ )
+end
+
+return authentik
diff --git a/authentik/authentik_helpers.lua b/authentik/authentik_helpers.lua
new file mode 100644
index 0000000..b6f972d
--- /dev/null
+++ b/authentik/authentik_helpers.lua
@@ -0,0 +1,39 @@
+-- Pure string helpers extracted from authentik.lua so they can be unit-tested
+-- with busted outside the OpenResty runtime. No ngx/resty dependencies — see
+-- spec/authentik_helpers_spec.lua.
+local sub = string.sub
+local len = string.len
+local gmatch = string.gmatch
+
+local _M = {}
+
+function _M.starts_with(s, prefix)
+ if not s or not prefix or prefix == "" then
+ return false
+ end
+ return sub(s, 1, len(prefix)) == prefix
+end
+
+function _M.rstrip_slash(s)
+ if not s or s == "" then
+ return s
+ end
+ while sub(s, -1) == "/" do
+ s = sub(s, 1, -2)
+ end
+ return s
+end
+
+-- Split a space/comma separated header list into an array of names.
+function _M.split_headers(s)
+ local t = {}
+ if not s then
+ return t
+ end
+ for name in gmatch(s, "[^%s,]+") do
+ t[#t + 1] = name
+ end
+ return t
+end
+
+return _M
diff --git a/authentik/confs/server-http/authentik.conf b/authentik/confs/server-http/authentik.conf
new file mode 100644
index 0000000..636b0ca
--- /dev/null
+++ b/authentik/confs/server-http/authentik.conf
@@ -0,0 +1,30 @@
+{% if USE_AUTHENTIK == "yes" and AUTHENTIK_URL != "" +%}
+{% set authentik_url = AUTHENTIK_URL.rstrip("/") %}
+# === Authentik plugin ========================================================
+# Authentik responses can carry large headers (groups, jwt, ...).
+proxy_buffers {{ AUTHENTIK_PROXY_BUFFERS }};
+proxy_buffer_size {{ AUTHENTIK_PROXY_BUFFER_SIZE }};
+
+# Don't leak the upstream's local listen port into redirects.
+port_in_redirect off;
+
+# Outpost endpoints (auth, start, callback, sign_out, ...) are proxied directly
+# to the Authentik outpost on /outpost.goauthentik.io. The Lua access handler
+# (authentik.lua) skips this path so the login flow itself is not gated.
+# Header set mirrors authentik's official nginx forward-auth snippet.
+location {{ AUTHENTIK_OUTPOST_PATH }} {
+ {% if AUTHENTIK_OUTPOST_PATH == "/outpost.goauthentik.io" %}
+ proxy_pass {{ authentik_url }};
+ {% else %}
+ proxy_pass {{ authentik_url }}/outpost.goauthentik.io;
+ {% endif %}
+ {% if AUTHENTIK_SSL_VERIFY == "no" %}
+ proxy_ssl_verify off;
+ {% endif %}
+ proxy_set_header Host $host;
+ proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
+ proxy_pass_request_body off;
+ proxy_set_header Content-Length "";
+}
+# === /Authentik plugin =======================================================
+{% endif %}
diff --git a/authentik/docs/diagram.mmd b/authentik/docs/diagram.mmd
new file mode 100644
index 0000000..b3a9181
--- /dev/null
+++ b/authentik/docs/diagram.mmd
@@ -0,0 +1,38 @@
+flowchart TD
+ accTitle: BunkerWeb Authentik plugin request flow
+ accDescr: A client request first passes BunkerWeb core checks, then authentik.lua calls the Authentik outpost auth endpoint. On 200 the request reaches the upstream with optional identity headers while client-supplied identity headers are stripped. On 401 or 403 the client is redirected to the Authentik SSO login.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb access phase]
+ direction TB
+ core["1. Core checks first: rate limit, bad behavior, antibot, DNSBL, black / whitelist"]
+ lua["2. authentik.lua: GET AUTHENTIK_URL + /outpost.goauthentik.io/auth/nginx (forwards cookies)"]
+ core --> lua
+ end
+
+ verdict{"Outpost verdict"}
+ allow["Allow to upstream: forward X-authentik-* (opt-in), client X-authentik-* stripped"]
+ redirect["302 to /outpost.goauthentik.io/start"]
+ upstream([Upstream app: reverse proxy or served files])
+ outpost[["Authentik outpost: /auth/nginx, /start, /callback, /sign_out"]]
+ server([Authentik server: SSO login flow])
+
+ client -->|request| core
+ lua -.->|auth sub-request| outpost
+ outpost -.->|status| verdict
+ verdict -->|200| allow
+ verdict -->|401 or 403| redirect
+ allow --> upstream
+ redirect -->|browser follows| client
+ client -->|"/outpost.goauthentik.io/* SSO, proxied by authentik.conf"| outpost
+ outpost --- server
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef ak fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class allow,upstream ok;
+ class redirect deny;
+ class outpost,server ak;
+ class client,core,lua app;
diff --git a/authentik/plugin.json b/authentik/plugin.json
new file mode 100644
index 0000000..849b799
--- /dev/null
+++ b/authentik/plugin.json
@@ -0,0 +1,90 @@
+{
+ "id": "authentik",
+ "name": "Authentik",
+ "description": "Protect any site (reverse proxy, served files, ...) with Authentik forward authentication (auth_request).",
+ "version": "1.11",
+ "stream": "no",
+ "settings": {
+ "USE_AUTHENTIK": {
+ "context": "multisite",
+ "default": "no",
+ "help": "Activate Authentik forward authentication for this site.",
+ "id": "use-authentik",
+ "label": "Use Authentik",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "AUTHENTIK_URL": {
+ "context": "multisite",
+ "default": "",
+ "help": "Base URL of the Authentik outpost (e.g. http://authentik:9000 for the embedded outpost or http://outpost:9000 for a standalone one). The plugin calls this base URL followed by /outpost.goauthentik.io/auth/nginx for the auth check and uses the same base for the outpost proxy. Required when USE_AUTHENTIK is yes.",
+ "id": "authentik-url",
+ "label": "Authentik outpost URL",
+ "regex": "^(https?://[^ ]+)?$",
+ "type": "text"
+ },
+ "AUTHENTIK_OUTPOST_PATH": {
+ "context": "multisite",
+ "default": "/outpost.goauthentik.io",
+ "help": "Local URL path under which the Authentik outpost endpoints (auth, start, callback, sign_out, ...) are exposed on this site. Must start with /.",
+ "id": "authentik-outpost-path",
+ "label": "Outpost local path",
+ "regex": "^/[A-Za-z0-9._~/-]*[A-Za-z0-9._~-]$",
+ "type": "text"
+ },
+ "AUTHENTIK_SSL_VERIFY": {
+ "context": "multisite",
+ "default": "yes",
+ "help": "Verify the TLS certificate of the Authentik outpost (used both by the Lua auth subrequest and the outpost proxy_pass).",
+ "id": "authentik-ssl-verify",
+ "label": "Verify Authentik TLS certificate",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "AUTHENTIK_TIMEOUT": {
+ "context": "global",
+ "default": "5000",
+ "help": "Timeout (ms) for the Lua auth subrequest to the Authentik outpost.",
+ "id": "authentik-timeout",
+ "label": "Auth subrequest timeout (ms)",
+ "regex": "^[0-9]+$",
+ "type": "text"
+ },
+ "AUTHENTIK_PROXY_BUFFER_SIZE": {
+ "context": "multisite",
+ "default": "32k",
+ "help": "Value used for proxy_buffer_size on this server. Authentik sets large response headers that may overflow the default buffer.",
+ "id": "authentik-proxy-buffer-size",
+ "label": "proxy_buffer_size",
+ "regex": "^[0-9]+[kKmM]?$",
+ "type": "text"
+ },
+ "AUTHENTIK_PROXY_BUFFERS": {
+ "context": "multisite",
+ "default": "8 16k",
+ "help": "Value used for proxy_buffers on this server. Authentik sets large response headers that may overflow the default buffers.",
+ "id": "authentik-proxy-buffers",
+ "label": "proxy_buffers",
+ "regex": "^[0-9]+ +[0-9]+[kKmM]?$",
+ "type": "text"
+ },
+ "AUTHENTIK_PASS_IDENTITY_HEADERS": {
+ "context": "multisite",
+ "default": "no",
+ "help": "Forward Authentik's identity headers (X-authentik-username, -groups, -email, ...) from the auth response to the upstream. Every client-supplied X-authentik-* request header is always stripped before the request reaches the upstream, regardless of this setting, so a client can never spoof an identity. Enable only if your backend uses trusted-header authentication.",
+ "id": "authentik-pass-identity-headers",
+ "label": "Forward identity headers to upstream",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "AUTHENTIK_IDENTITY_HEADERS": {
+ "context": "multisite",
+ "default": "X-authentik-username X-authentik-groups X-authentik-entitlements X-authentik-email X-authentik-name X-authentik-uid",
+ "help": "Space/comma-separated list of identity headers to forward from Authentik's auth response when AUTHENTIK_PASS_IDENTITY_HEADERS is yes. Every X-authentik-* request header from the client is always stripped regardless of this list, to prevent spoofing; this list only selects which Authentik response headers are passed to the upstream. Defaults match Authentik's nginx forward-auth header set.",
+ "id": "authentik-identity-headers",
+ "label": "Identity headers to forward",
+ "regex": "^X-authentik-[A-Za-z0-9-]+([ ,]+X-authentik-[A-Za-z0-9-]+)*$",
+ "type": "text"
+ }
+ }
+}
diff --git a/clamav/README.md b/clamav/README.md
index 9687b5e..b916dcc 100644
--- a/clamav/README.md
+++ b/clamav/README.md
@@ -1,30 +1,120 @@
# ClamAV plugin
-
-
-
+
-This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github) plugin will automatically check if any uploaded file is detected by the ClamAV antivirus engine and deny the request if that's the case.
+```mermaid
+flowchart TD
+ accTitle: BunkerWeb ClamAV plugin request flow
+ accDescr: A multipart upload first passes BunkerWeb core checks, then clamav.lua streams each file part to the clamd daemon over the binary INSTREAM TCP protocol. A SHA-512 cache short-circuits files already scanned. A clean verdict reaches the upstream while a detection denies the request.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb access phase]
+ direction TB
+ core["1. Core checks first: rate limit, bad behavior, antibot, DNSBL, black / whitelist"]
+ lua["2. clamav.lua: parse multipart parts that have a filename (resty.upload streaming)"]
+ cache{{"SHA-512 cache hit?"}}
+ core --> lua --> cache
+ end
+
+ clamd[["clamd daemon: INSTREAM scan"]]
+ verdict{"Scan verdict"}
+ allow["Allow to upstream"]
+ deny["Deny request (get_deny_status)"]
+ upstream([Upstream app])
+
+ client -->|"file upload (multipart)"| core
+ cache -.->|miss| clamd
+ clamd -.->|"chunks framed by a 4-byte big-endian length, zero-frame end"| verdict
+ cache -->|hit| verdict
+ verdict -->|clean| allow
+ verdict -->|virus found| deny
+ allow --> upstream
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class allow,upstream ok;
+ class deny deny;
+ class clamd svc;
+ class client,core,lua,cache app;
+```
+
+This [plugin](https://www.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+streams every uploaded file to a [ClamAV](https://www.clamav.net/) `clamd`
+daemon and denies the request when the antivirus engine flags a file as
+infected. It works on top of any existing service configuration - reverse
+proxy, served files, custom location blocks - without replacing them.
+
+The scan runs from Lua during BunkerWeb's access phase, so all of BunkerWeb's
+built-in checks (rate limit, bad behavior, antibot, DNSBL, whitelist /
+blacklist, ...) run _before_ any file is handed to `clamd`. Files are streamed
+to `clamd` over its binary INSTREAM protocol on a TCP socket - not HTTP. At
+worker startup the plugin pings `clamd` to verify connectivity, and it exposes
+a `POST /clamav/ping` health endpoint used by the BunkerWeb web UI.
# Table of contents
- [ClamAV plugin](#clamav-plugin)
- [Table of contents](#table-of-contents)
+- [How it works](#how-it-works)
- [Prerequisites](#prerequisites)
- [Setup](#setup)
- [Docker](#docker)
- [Swarm](#swarm)
- [Kubernetes](#kubernetes)
- [Settings](#settings)
-- [TODO](#todo)
+- [Troubleshooting](#troubleshooting)
+- [Notes](#notes)
+
+# How it works
+
+For a `multipart/form-data` upload to a protected site:
+
+1. BunkerWeb's access-phase checks run first (rate limit, bad behavior,
+ antibot, DNSBL, blacklist, ...). If any of them deny, the request stops
+ here and `clamd` is never contacted.
+2. `clamav.lua` runs. It only acts on `multipart/form-data` requests with a
+ boundary; any other request (plain `POST`, JSON, GET, ...) is passed
+ through untouched.
+3. The handler streams the request body with `resty.upload` and inspects each
+ part. It scans **only** the parts that carry a real filename in their
+ `Content-Disposition` header - quoted (`filename="x"`), unquoted
+ (`filename=x`) and RFC 5987 extended (`filename*=...`) forms are all
+ recognized. Form fields without a filename are skipped.
+4. As each scanned file streams in, its bytes are forwarded to the `clamd`
+ daemon over the binary INSTREAM protocol on a TCP socket (each chunk framed
+ by a 4-byte big-endian length prefix) while a SHA-512 checksum is computed.
+5. When the part ends, the checksum is looked up in BunkerWeb's shared cache. On
+ a **hit** the socket is closed without finalizing the scan and the cached
+ verdict is reused. On a **miss** a zero-length frame terminates the stream,
+ `clamd` returns its verdict on that line, and the result is cached for 24h.
+6. **Clean** - the request continues to its normal destination (reverse proxy,
+ file serving, custom location). **Detection** - the request is denied with
+ BunkerWeb's deny status; the file's checksum and the matched signature name
+ are written to the log.
# Prerequisites
-Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation first.
+Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+of the BunkerWeb documentation first.
+
+You need a reachable `clamd` instance. The official `clamav/clamav` image
+exposes `clamd` on TCP port `3310` and ships `freshclam` to keep the signature
+database up to date; BunkerWeb only needs network access to that port.
# 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.
+See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+of the BunkerWeb documentation for the generic installation procedure depending
+on your integration (the short version: drop the `clamav/` directory into the
+scheduler's `/data/plugins/` and restart).
+
+`CLAMAV_HOST` / `CLAMAV_PORT` are the address BunkerWeb uses to reach `clamd` -
+typically an internal Docker network address. They are **global** settings, so
+one ClamAV backend serves every site; `USE_CLAMAV` is **multisite**, so you
+enable scanning per service.
## Docker
@@ -32,18 +122,18 @@ See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign
services:
bunkerweb:
- image: bunkerity/bunkerweb:1.6.0-rc1
+ image: bunkerity/bunkerweb:1.6.11
...
networks:
- bw-plugins
...
bw-scheduler:
- image: bunkerity/bunkerweb-scheduler:1.6.0-rc1
+ image: bunkerity/bunkerweb-scheduler:1.6.11
...
environment:
- - USE_CLAMAV=yes
- - CLAMAV_HOST=clamav
+ USE_CLAMAV: "yes"
+ CLAMAV_HOST: "clamav"
...
clamav:
@@ -66,18 +156,18 @@ networks:
services:
bunkerweb:
- image: bunkerity/bunkerweb:1.6.0-rc1
+ image: bunkerity/bunkerweb:1.6.11
...
networks:
- bw-plugins
...
bw-scheduler:
- image: bunkerity/bunkerweb-scheduler:1.6.0-rc1
+ image: bunkerity/bunkerweb-scheduler:1.6.11
...
environment:
- - USE_CLAMAV=yes
- - CLAMAV_HOST=clamav
+ USE_CLAMAV: "yes"
+ CLAMAV_HOST: "clamav"
...
clamav:
@@ -97,7 +187,7 @@ networks:
## Kubernetes
-First you will need to deploy the dependencies :
+First you will need to deploy the ClamAV dependency:
```yaml
apiVersion: apps/v1
@@ -131,7 +221,7 @@ spec:
targetPort: 3310
```
-Then you can configure the plugin :
+Then you can configure the plugin:
```yaml
apiVersion: networking.k8s.io/v1
@@ -145,15 +235,48 @@ metadata:
# Settings
-| Setting | Default | Context | Multiple | Description |
-| ---------------- | -------- | --------- | -------- | ------------------------------------------------------- |
-| `USE_CLAMAV` | `no` | multisite | no | Activate automatic scan of uploaded files with ClamAV. |
-| `CLAMAV_HOST` | `clamav` | global | no | ClamAV hostname or IP address. |
-| `CLAMAV_PORT` | `3310` | global | no | ClamAV port. |
-| `CLAMAV_TIMEOUT` | `1000` | global | no | Network timeout (in ms) when communicating with ClamAV. |
+| Setting | Default | Context | Multiple | Description |
+| ---------------- | -------- | --------- | -------- | -------------------------------------------------------------------------------------- |
+| `USE_CLAMAV` | `no` | multisite | no | Activate automatic scan of uploaded files with ClamAV. |
+| `CLAMAV_HOST` | `clamav` | global | no | ClamAV hostname or IP address. |
+| `CLAMAV_PORT` | `3310` | global | no | ClamAV port. |
+| `CLAMAV_TIMEOUT` | `1000` | global | no | Network timeout in milliseconds when communicating with ClamAV (e.g. 1000 = 1 second). |
+
+# Troubleshooting
+
+- **Large files are not scanned.** `clamd` refuses any stream above its
+ `StreamMaxLength` (set in `clamd.conf`, 25 MB by default in the official
+ image). When that limit is hit, the plugin logs `size exceeded
+StreamMaxLength in clamd.conf` and **skips** that file - it does not deny it.
+ Raise `StreamMaxLength` (and `MaxFileSize` to match) in `clamd.conf` if you
+ need to scan larger uploads.
+- **`connectivity with ClamAV failed` in the scheduler / worker log.**
+ BunkerWeb can't reach `clamd`. Check that the ClamAV service is up, on the
+ same network as BunkerWeb, and that `CLAMAV_HOST` / `CLAMAV_PORT` point at it
+ (default `clamav:3310`). The startup ping uses `CLAMAV_TIMEOUT` (default
+ `1000` ms).
+- **The first request after starting ClamAV is slow or times out.** The
+ official `clamav/clamav` image loads its signature database on boot and isn't
+ ready until `freshclam` completes the initial download. Wait for `clamd` to
+ report ready, or raise `CLAMAV_TIMEOUT`.
+- **Uploads aren't being scanned at all.** Only `multipart/form-data` requests
+ that contain a part with a `Content-Disposition` filename are scanned. Plain
+ `POST` bodies, JSON payloads, and form fields without a filename are skipped
+ silently by design.
-# TODO
+# Notes
-- Test and document clustered mode
-- Custom ClamAV configuration
-- Document Linux integration
+- **Scan results are cached for 24 hours.** Verdicts are keyed by the file's
+ SHA-512 checksum and stored in BunkerWeb's shared cache, so an identical
+ re-upload skips a fresh `clamd` scan. The cache is shared across nginx workers
+ in an instance, and across BunkerWeb instances when `USE_REDIS=yes`.
+- **Detections are fail-closed; un-scannable files are not.** A file `clamd`
+ flags as infected is denied with BunkerWeb's deny status, and its checksum
+ and signature name are logged. A file that cannot be scanned - for example
+ one larger than `StreamMaxLength` - is logged and allowed through. Scanning
+ is best-effort for the parts `clamd` can read; it is not a hard gate on
+ un-scannable uploads.
+- **Clustered / replicated `clamd` is not yet validated.** Pointing
+ `CLAMAV_HOST` at a load-balanced pool of `clamd` nodes should work, since the
+ INSTREAM protocol is self-contained per connection, but this topology is not
+ officially tested or documented yet.
diff --git a/clamav/clamav.lua b/clamav/clamav.lua
index 7d9dd22..7fed14c 100644
--- a/clamav/clamav.lua
+++ b/clamav/clamav.lua
@@ -1,3 +1,4 @@
+local clamav_helpers = require("clamav.clamav_helpers")
local class = require("middleclass")
local plugin = require("bunkerweb.plugin")
local sha512 = require("resty.sha512")
@@ -17,18 +18,9 @@ local to_hex = str.to_hex
local has_variable = utils.has_variable
local get_deny_status = utils.get_deny_status
local tonumber = tonumber
-local floor = math.floor
-
-local stream_size = function(size)
- return ("%c%c%c%c")
- :format(
- size % 0x100,
- floor(size / 0x100) % 0x100,
- floor(size / 0x10000) % 0x100,
- floor(size / 0x1000000) % 0x100
- )
- :reverse()
-end
+-- The big-endian INSTREAM length prefix lives in clamav/clamav_helpers.lua so it
+-- can be unit-tested with busted outside OpenResty (see spec/clamav_helpers_spec.lua).
+local stream_size = clamav_helpers.stream_size
local read_all = function(form)
while true do
@@ -172,7 +164,10 @@ function clamav:scan()
if typ == "header" then
local found = false
for _, header in ipairs(res) do
- if header:find('^.*filename="(.*)".*$') then
+ -- Match the filename parameter in any RFC 7578 / 2183 form :
+ -- quoted (filename="x"), unquoted (filename=x) and RFC 5987 extended (filename*=...).
+ -- %f[%a] anchors on a parameter boundary so form fields like name="myfilename" don't match.
+ if header:find("%f[%a]filename%*?%s*=") then
found = true
break
end
diff --git a/clamav/clamav_helpers.lua b/clamav/clamav_helpers.lua
new file mode 100644
index 0000000..234bc41
--- /dev/null
+++ b/clamav/clamav_helpers.lua
@@ -0,0 +1,22 @@
+-- Pure helpers extracted from clamav.lua so they can be unit-tested with busted
+-- outside the OpenResty runtime. No ngx/resty dependencies — see
+-- spec/clamav_helpers_spec.lua.
+local floor = math.floor
+
+local _M = {}
+
+-- Encode a chunk length as the 4-byte big-endian (network byte order) prefix the
+-- ClamAV INSTREAM protocol expects before each chunk. The bytes are built
+-- little-endian then reversed, so byte 1 is the most-significant.
+function _M.stream_size(size)
+ return ("%c%c%c%c")
+ :format(
+ size % 0x100,
+ floor(size / 0x100) % 0x100,
+ floor(size / 0x10000) % 0x100,
+ floor(size / 0x1000000) % 0x100
+ )
+ :reverse()
+end
+
+return _M
diff --git a/clamav/docs/diagram.drawio b/clamav/docs/diagram.drawio
deleted file mode 100644
index 73e99be..0000000
--- a/clamav/docs/diagram.drawio
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/clamav/docs/diagram.mmd b/clamav/docs/diagram.mmd
new file mode 100644
index 0000000..048648c
--- /dev/null
+++ b/clamav/docs/diagram.mmd
@@ -0,0 +1,36 @@
+flowchart TD
+ accTitle: BunkerWeb ClamAV plugin request flow
+ accDescr: A multipart upload first passes BunkerWeb core checks, then clamav.lua streams each file part to the clamd daemon over the binary INSTREAM TCP protocol. A SHA-512 cache short-circuits files already scanned. A clean verdict reaches the upstream while a detection denies the request.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb access phase]
+ direction TB
+ core["1. Core checks first: rate limit, bad behavior, antibot, DNSBL, black / whitelist"]
+ lua["2. clamav.lua: parse multipart parts that have a filename (resty.upload streaming)"]
+ cache{{"SHA-512 cache hit?"}}
+ core --> lua --> cache
+ end
+
+ clamd[["clamd daemon: INSTREAM scan"]]
+ verdict{"Scan verdict"}
+ allow["Allow to upstream"]
+ deny["Deny request (get_deny_status)"]
+ upstream([Upstream app])
+
+ client -->|"file upload (multipart)"| core
+ cache -.->|miss| clamd
+ clamd -.->|"chunks framed by a 4-byte big-endian length, zero-frame end"| verdict
+ cache -->|hit| verdict
+ verdict -->|clean| allow
+ verdict -->|virus found| deny
+ allow --> upstream
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class allow,upstream ok;
+ class deny deny;
+ class clamd svc;
+ class client,core,lua,cache app;
diff --git a/clamav/docs/diagram.svg b/clamav/docs/diagram.svg
deleted file mode 100644
index 6fdc198..0000000
--- a/clamav/docs/diagram.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/clamav/plugin.json b/clamav/plugin.json
index 0d1ed48..a87eaf7 100644
--- a/clamav/plugin.json
+++ b/clamav/plugin.json
@@ -2,7 +2,7 @@
"id": "clamav",
"name": "ClamAV",
"description": "Automatic scan of uploaded files with ClamAV antivirus engine.",
- "version": "1.10",
+ "version": "1.11",
"stream": "no",
"settings": {
"USE_CLAMAV": {
@@ -35,7 +35,7 @@
"CLAMAV_TIMEOUT": {
"context": "global",
"default": "1000",
- "help": "Network timeout (in ms) when communicating with ClamAV.",
+ "help": "Network timeout in milliseconds when communicating with ClamAV (e.g. 1000 = 1 second).",
"id": "clamav-timeout",
"label": "Network timeout",
"regex": "^.*$",
diff --git a/clamav/ui/actions.py b/clamav/ui/actions.py
index 1140ae9..ef843b3 100644
--- a/clamav/ui/actions.py
+++ b/clamav/ui/actions.py
@@ -18,10 +18,7 @@ def pre_render(**kwargs):
except BaseException as e:
logger.debug(format_exc())
logger.error(f"Failed to get clamav ping: {e}")
- ret["error"] = str(e)
-
- if "error" in ret:
- return ret
+ ret["error"] = "Could not retrieve the plugin status"
return ret
diff --git a/cloudflare/README.md b/cloudflare/README.md
new file mode 100644
index 0000000..0c1e948
--- /dev/null
+++ b/cloudflare/README.md
@@ -0,0 +1,308 @@
+# Cloudflare plugin
+
+
+
+```mermaid
+flowchart TD
+ accTitle: BunkerWeb Cloudflare plugin
+ accDescr: A visitor reaches Cloudflare's edge, which proxies to BunkerWeb. BunkerWeb restores the real client IP from CF-Connecting-IP, optionally denies any connection not coming from a Cloudflare IP and optionally verifies Cloudflare's Authenticated Origin Pull client certificate before forwarding to the upstream. The BunkerWeb scheduler runs jobs that download Cloudflare's IP ranges and the origin-pull CA, manage Origin CA certificates and push BunkerWeb bans to a Cloudflare edge IP List.
+
+ visitor([Visitor])
+ edge[[Cloudflare edge]]
+
+ subgraph bw[BunkerWeb]
+ direction TB
+ realip["Restore real IP from CF-Connecting-IP (set_real_ip_from + real_ip_header)"]
+ verdict{"cloudflare.lua: trusted peer? (+ optional mTLS origin-pull check)"}
+ realip --> verdict
+ end
+
+ allow["Forward to upstream (strip spoofed CF-* headers)"]
+ deny["Deny (403 / close)"]
+ upstream([Upstream app])
+
+ subgraph sched[BunkerWeb scheduler jobs]
+ direction TB
+ ip["cf-trusted-ips-download"]
+ cert["cf-manage-origin-certs"]
+ aop["cf-aop-ca-download"]
+ ban["cf-edge-ban-sync"]
+ end
+ cfapi[[Cloudflare API]]
+
+ visitor -->|request| edge
+ edge -->|"CF IP + CF-Connecting-IP"| realip
+ verdict -->|trusted| allow
+ verdict -->|not trusted| deny
+ allow --> upstream
+
+ cert -.->|Origin CA cert| cfapi
+ aop -.->|origin-pull CA| edge
+ ban -.->|push bans to IP List| cfapi
+ ip -.->|served via SNI / set_real_ip_from| bw
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef cf fill:#fff4e6,stroke:#f6821f,color:#7a3d00;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class allow,upstream ok;
+ class deny deny;
+ class edge,cfapi cf;
+ class visitor,realip,verdict,ip,cert,aop,ban app;
+```
+
+This [plugin](https://www.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+makes BunkerWeb a first-class origin behind Cloudflare. It restores the real
+client IP from `CF-Connecting-IP`, can deny any connection whose real peer is not
+a Cloudflare (or additional-trusted) IP, strips spoofed `CF-*` headers, verifies
+Cloudflare's Authenticated Origin Pulls (mTLS), and automatically provisions and
+serves Cloudflare Origin CA certificates. A set of scheduler jobs keeps the
+Cloudflare IP ranges, the origin-pull CA and the Origin CA certificates current,
+and can push BunkerWeb's active bans up to a Cloudflare edge IP List.
+
+The per-request trust check runs from Lua during BunkerWeb's `access` phase (and
+`preread` for stream), so all of BunkerWeb's built-in checks run as usual. The
+deny decision is taken on `realip_remote_addr` — the real TCP peer of the
+connection — so it cannot be spoofed by a forged header. The whole trust check
+**fails open**: while the trusted-IP list is still empty (for example right after
+boot, before the first download job runs) the plugin never denies, so a
+misconfiguration or a slow first job can never lock everyone out.
+
+# Table of contents
+
+- [Cloudflare plugin](#cloudflare-plugin)
+- [Table of contents](#table-of-contents)
+- [How it works](#how-it-works)
+- [Prerequisites](#prerequisites)
+ - [Cloudflare API token](#cloudflare-api-token)
+- [Setup](#setup)
+ - [Docker / Swarm](#docker--swarm)
+ - [Linux](#linux)
+- [Secrets](#secrets)
+- [Settings](#settings)
+- [Troubleshooting](#troubleshooting)
+- [Notes](#notes)
+
+# How it works
+
+The plugin has two halves: per-request Lua hooks in the BunkerWeb instance, and
+scheduled jobs in the BunkerWeb scheduler.
+
+**Per request (`cloudflare.lua`):**
+
+1. NGINX restores the real client IP. The plugin's config snippet emits
+ `set_real_ip_from` for every downloaded Cloudflare range (and any
+ `CLOUDFLARE_ADDITIONAL_TRUSTED_FROM` network). When `CLOUDFLARE_AUTO_REAL_IP`
+ is `yes` **and** the core Real IP plugin is off (`USE_REAL_IP != yes`), it
+ also emits `real_ip_header ` and
+ `real_ip_recursive off`, so the visitor IP is restored from
+ `CF-Connecting-IP` with no extra settings.
+2. In the HTTP `access` phase, if `CLOUDFLARE_AUTHENTICATED_ORIGIN_PULLS=yes`
+ (and the core mTLS plugin is off), the handler checks NGINX's
+ `$ssl_client_verify`. If the origin-pull CA isn't loaded yet it warns and
+ allows (fail open); otherwise a non-`SUCCESS` verify is treated as a
+ non-Cloudflare origin — denied in `enforce` mode, only logged in `log` mode.
+3. The handler then computes a trust verdict for `realip_remote_addr` against the
+ Cloudflare ranges plus the additional-trusted list (results cached per server
+ for 24 h). If the peer is untrusted and `CLOUDFLARE_STRIP_SPOOFED_HEADERS=yes`,
+ the client-supplied `CF-*` headers (`CF-Connecting-IP`, `CF-IPCountry`,
+ `CF-RAY`, `True-Client-IP`, ...) are stripped. If `CLOUDFLARE_DENY_NON_TRUSTED_IPS=yes`
+ and the peer is untrusted, the request is denied. The stream `preread` phase
+ enforces only the IP trust check (no header stripping, no mTLS).
+4. On a TLS handshake, the `ssl_certificate` hook serves the managed Origin CA
+ certificate/key for the requested SNI (parsed cert and key are kept in the
+ worker `internalstore`, like the core Let's Encrypt plugin, so private keys
+ never reach the API-exposed datastore). HTTPS is only advertised for a server
+ once a certificate has actually been loaded.
+
+**Scheduler jobs (declared in `plugin.json`, scripts under `jobs/`):**
+
+| Job | Schedule | What it does |
+| ------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `cf-trusted-ips-download` | daily | Downloads Cloudflare's published IPv4/IPv6 ranges to `ipv4.list`/`ipv6.list`, which feed `set_real_ip_from` and the per-request trust check. Needs no token. |
+| `cf-manage-origin-certs` | daily | Uses the official `cloudflare` Python SDK to provision/renew a per-server Origin CA certificate + key, served by the `ssl_certificate` hook. |
+| `cf-aop-ca-download` | weekly | Downloads Cloudflare's Authenticated Origin Pull CA to `aop_ca.pem`, wired into `ssl_client_certificate` for mTLS verification. |
+| `cf-edge-ban-sync` | every minute | Reads BunkerWeb's active bans from Redis and pushes up to 10,000 IPs to a Cloudflare account IP List, creating the list if it is missing. |
+
+# Prerequisites
+
+Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins) of the BunkerWeb documentation first and refer to the [Cloudflare API documentation](https://developers.cloudflare.com/api) for more information.
+
+Trusted-IP download and the deny feature need **no** API token. The other features need a token whose scope depends on what you enable:
+
+| Feature | Least-privilege token scope |
+| ----------------------------------------- | ----------------------------------------------------------------------------- |
+| Origin CA certificates | `Zone:SSL and Certificates:Edit` (+ `Zone:Zone:Read` for zone auto-discovery) |
+| Authenticated Origin Pulls (global, mTLS) | none (static CA is downloaded from a public URL) |
+| Edge ban sync | `Account:Account Filter Lists:Edit` |
+
+> [!NOTE]
+> Edge ban sync uses an **account-scoped** token, which is broader than the zone token used elsewhere — keep it in the dedicated `CLOUDFLARE_EDGE_BAN_API_TOKEN` setting for least privilege.
+
+## Cloudflare API token
+
+To create an API token for Origin CA certificate management:
+
+1. Log in to your Cloudflare account.
+2. Go to the [API Tokens](https://dash.cloudflare.com/profile/api-tokens) page.
+3. Click `Create Token` → `Create Custom Token`.
+4. Under `Permissions`, add `Zone:SSL and Certificates:Edit` (and optionally `Zone:Zone:Read`).
+5. Under `Zone Resources`, select the zone(s) you want to manage.
+6. Create the token and copy it into `CLOUDFLARE_API_TOKEN`.
+
+
+
+
+
+> [!NOTE]
+> If you don't set `CLOUDFLARE_ZONE_ID`, the plugin resolves the zone from your domains via the API (needs `Zone:Zone:Read`). For second-level ccTLDs (e.g. `example.co.uk`) set `CLOUDFLARE_ZONE_ID` explicitly.
+
+# Setup
+
+See the [plugins section](https://docs.bunkerweb.io/latest/plugins) of the BunkerWeb documentation for the installation procedure depending on your integration (the short version: drop the `cloudflare/` directory into the scheduler's `/data/plugins/` and restart).
+
+## Docker / Swarm
+
+Set the Cloudflare settings on the **scheduler** service — that is where the plugin's jobs run and where BunkerWeb reads its configuration.
+
+```yaml
+services:
+ bunkerweb:
+ image: bunkerity/bunkerweb:1.6.11
+ ...
+ networks:
+ - bw-services
+
+ bw-scheduler:
+ image: bunkerity/bunkerweb-scheduler:1.6.11
+ ...
+ environment:
+ SERVER_NAME: "app.example.com"
+ USE_REVERSE_PROXY: "yes"
+ REVERSE_PROXY_HOST: "http://app:3000"
+ REVERSE_PROXY_URL: "/"
+
+ USE_CLOUDFLARE: "yes" # Mandatory
+ CLOUDFLARE_DENY_NON_TRUSTED_IPS: "yes" # Optional: only accept connections from Cloudflare
+ # Origin CA certificate management (optional)
+ CLOUDFLARE_API_TOKEN: ""
+ CLOUDFLARE_MANAGE_ORIGIN_CERTS: "yes"
+ # Authenticated Origin Pulls (optional)
+ CLOUDFLARE_AUTHENTICATED_ORIGIN_PULLS: "yes"
+ CLOUDFLARE_AOP_MODE: "enforce" # default "log"
+
+networks:
+ bw-services:
+ name: bw-services
+```
+
+> [!NOTE]
+> You no longer need to set `USE_REAL_IP`/`REAL_IP_HEADER` manually: with `CLOUDFLARE_AUTO_REAL_IP=yes` (default) the plugin configures NGINX real IP for Cloudflare itself. Disable it if the core Real IP plugin already manages those directives.
+
+## Linux
+
+```env
+USE_CLOUDFLARE="yes"
+CLOUDFLARE_DENY_NON_TRUSTED_IPS="yes"
+CLOUDFLARE_API_TOKEN=""
+CLOUDFLARE_MANAGE_ORIGIN_CERTS="yes"
+```
+
+# Secrets
+
+`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ZONE_ID`, `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_EDGE_BAN_API_TOKEN` support the Docker-secret `_FILE` convention: set e.g. `CLOUDFLARE_API_TOKEN_FILE=/run/secrets/cf_token` and the value is read from that file.
+
+# Settings
+
+| Setting | Default | Context | Multiple | Description |
+| --------------------------------------- | ------------------------------------------------------------------------------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `USE_CLOUDFLARE` | `no` | multisite | no | Activate Cloudflare automations (real IP, trusted-IP allowlisting, Origin CA certificates, mTLS, ...). |
+| `CLOUDFLARE_API_TOKEN` | | multisite | no | Cloudflare API token to authenticate with the Cloudflare API. |
+| `CLOUDFLARE_ZONE_ID` | | multisite | no | Cloudflare Zone ID (if no zone ID is provided, the plugin will try to get it from the API). |
+| `CLOUDFLARE_MANAGE_ORIGIN_CERTS` | `yes` | multisite | no | Activate automatic management of Origin CA certificates. |
+| `CLOUDFLARE_ORIGIN_CERT_TYPE` | `rsa` | multisite | no | Signature type desired on origin CA certificates ("rsa", or "ecdsa"). |
+| `CLOUDFLARE_ORIGIN_CERT_VALIDITY` | `5475` | multisite | no | Validity period of origin CA certificates in days. |
+| `CLOUDFLARE_ADDITIONAL_TRUSTED_FROM` | | multisite | no | Additional IPs/networks to consider as trusted, separated with spaces (CIDR notation). |
+| `CLOUDFLARE_DENY_NON_TRUSTED_IPS` | `no` | multisite | no | Deny access to non-trusted IPs (the ones not in Cloudflare's official list and the additional trusted IPs). |
+| `CLOUDFLARE_API_URL` | `https://api.cloudflare.com/client/v4` | global | no | Base URL of the Cloudflare API (advanced; for a Cloudflare-compatible/proxied endpoint or testing). |
+| `CLOUDFLARE_API_TIMEOUT` | `10` | global | no | Timeout in seconds for Cloudflare API requests. |
+| `CLOUDFLARE_IPS_V4_URL` | `https://www.cloudflare.com/ips-v4/` | global | no | URL to download Cloudflare's IPv4 ranges from (advanced/testing). |
+| `CLOUDFLARE_IPS_V6_URL` | `https://www.cloudflare.com/ips-v6/` | global | no | URL to download Cloudflare's IPv6 ranges from (advanced/testing). |
+| `CLOUDFLARE_AUTO_REAL_IP` | `yes` | multisite | no | Automatically configure NGINX real_ip_header/real_ip_recursive for Cloudflare. Disable if the core Real IP plugin (USE_REAL_IP) already manages them to avoid duplicate directives. |
+| `CLOUDFLARE_REAL_IP_HEADER` | `CF-Connecting-IP` | multisite | no | Header carrying the real client IP. CF-Connecting-IP (default), True-Client-IP (Enterprise alias) or CF-Connecting-IPv6 (when Pseudo-IPv4 is enabled). |
+| `CLOUDFLARE_STRIP_SPOOFED_HEADERS` | `yes` | multisite | no | Strip client-supplied CF-\* headers (CF-Connecting-IP, CF-IPCountry, CF-RAY, True-Client-IP, ...) when the connection is not from a trusted Cloudflare IP, to prevent spoofing. |
+| `CLOUDFLARE_AUTHENTICATED_ORIGIN_PULLS` | `no` | multisite | no | Require Cloudflare Authenticated Origin Pulls (mTLS): verify the connection presents Cloudflare's origin-pull client certificate. Mutually exclusive with the core mTLS plugin on the same server. |
+| `CLOUDFLARE_AOP_MODE` | `log` | multisite | no | Authenticated Origin Pulls enforcement: 'log' only warns on connections without a valid Cloudflare client certificate, 'enforce' denies them. |
+| `CLOUDFLARE_AOP_CA_URL` | `https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem` | global | no | URL of the Cloudflare Authenticated Origin Pull CA certificate (advanced/testing). |
+| `USE_CLOUDFLARE_EDGE_BAN_SYNC` | `no` | global | no | Push BunkerWeb's active bans to a Cloudflare account IP List. Reference that list from a Cloudflare WAF custom rule to block the offenders at the edge (the plugin syncs the list, it does not create the rule). Requires USE_REDIS=yes and an account-scoped API token (Account Filter Lists:Edit). |
+| `CLOUDFLARE_ACCOUNT_ID` | | global | no | Cloudflare Account ID owning the edge ban IP List. |
+| `CLOUDFLARE_BAN_LIST_NAME` | `bunkerweb_bans` | global | no | Name of the Cloudflare account IP List used for edge ban sync (lowercase letters, digits and underscores). |
+| `CLOUDFLARE_EDGE_BAN_API_TOKEN` | | global | no | Account-scoped API token (Account Filter Lists:Edit) for edge ban sync. Falls back to CLOUDFLARE_API_TOKEN if empty. |
+
+# Troubleshooting
+
+- **No requests are denied even with `CLOUDFLARE_DENY_NON_TRUSTED_IPS=yes`.** The
+ trust check fails open until the trusted-IP list is populated. Confirm the
+ `cf-trusted-ips-download` job has run (check the scheduler logs, or `POST` to
+ `/cloudflare/ping`, which reports how many IPv4/IPv6 ranges are loaded). The
+ job runs daily; after the first scheduler start it may take a moment.
+- **Duplicate-directive errors at NGINX reload (`real_ip_header`, `ssl_verify_client`).**
+ Don't enable both this plugin and the core Real IP / core mTLS plugin as the
+ owner of the same directive on the same server. The plugin already gates its
+ config so it won't clash (real IP is only emitted when `CLOUDFLARE_AUTO_REAL_IP=yes`
+ and `USE_REAL_IP != yes`; the AOP `ssl_verify_client` only when `USE_MTLS != yes`),
+ but pick a single owner — either disable `CLOUDFLARE_AUTO_REAL_IP` or don't
+ enable the core plugin.
+- **The real client IP is still Cloudflare's edge IP.** The real-IP config is
+ only emitted once the Cloudflare ranges have been downloaded and
+ `CLOUDFLARE_AUTO_REAL_IP=yes` (with the core Real IP plugin off). Verify the
+ download job ran and that `CLOUDFLARE_REAL_IP_HEADER` matches the header
+ Cloudflare actually sends for your plan.
+- **HTTPS is never advertised for a server.** A server is only marked
+ HTTPS-configured once `cf-manage-origin-certs` has successfully obtained and
+ loaded an Origin CA certificate for it. Check that `CLOUDFLARE_API_TOKEN` is
+ set, `CLOUDFLARE_MANAGE_ORIGIN_CERTS=yes`, and the token has
+ `Zone:SSL and Certificates:Edit` (plus `Zone:Zone:Read` if you didn't set
+ `CLOUDFLARE_ZONE_ID`).
+- **Authenticated Origin Pulls denies legitimate traffic (or never enforces).**
+ In `enforce` mode the plugin denies any connection whose `$ssl_client_verify`
+ isn't `SUCCESS` — but only once `cf-aop-ca-download` has written the origin-pull
+ CA; until then it fails open and only warns. If you also run the core mTLS
+ plugin (`USE_MTLS=yes`), the AOP check is skipped because `$ssl_client_verify`
+ would then reflect the core plugin's CA, not Cloudflare's.
+- **Edge ban sync does nothing.** It requires `USE_CLOUDFLARE_EDGE_BAN_SYNC=yes`,
+ `USE_REDIS=yes` (bans are read from Redis), a `CLOUDFLARE_ACCOUNT_ID`, and an
+ account-scoped token (`CLOUDFLARE_EDGE_BAN_API_TOKEN`, falling back to
+ `CLOUDFLARE_API_TOKEN`) with `Account Filter Lists:Edit`. The job logs the
+ reason it skips.
+
+# Notes
+
+- **Fail-open by design.** Both the IP trust check and the Authenticated Origin
+ Pulls check fail open while their backing data isn't loaded yet (the trusted-IP
+ list right after boot, the origin-pull CA before `cf-aop-ca-download` runs). The
+ plugin therefore can't lock everyone out during the brief window before its
+ jobs have populated their lists.
+- **The deny decision can't be spoofed.** The trust verdict is computed against
+ `realip_remote_addr` — the real TCP peer of the connection — not against any
+ client-supplied header, so an attacker connecting directly to the origin can't
+ forge their way past it. As defence in depth, untrusted peers also have their
+ client-supplied `CF-*` headers stripped (`CLOUDFLARE_STRIP_SPOOFED_HEADERS`).
+- **One owner for real IP and mTLS.** This plugin and BunkerWeb's core Real IP /
+ mTLS plugins manage the same NGINX directives. The plugin gates its config to
+ avoid duplicate directives, but you should still let a single plugin own each
+ feature on a given server.
+- **Stream support is partial.** In the stream (`preread`) context only the IP
+ trust check runs — there is no header stripping and no mTLS, and real IP relies
+ on the PROXY protocol (`real_ip_header` is HTTP-only).
+- **Edge ban sync only maintains the IP List.** The job creates and fills the
+ Cloudflare account IP List named by `CLOUDFLARE_BAN_LIST_NAME`; it does **not**
+ create a firewall rule. To actually block the banned IPs at the edge, add a
+ Cloudflare WAF custom rule that references that IP List.
+- **Edge ban sync is capped at 10,000 IPs.** That is Cloudflare's default
+ per-account IP List capacity; if more IPs are banned, the lowest-sorted 10,000
+ are synced and the rest are skipped (with a warning in the scheduler logs).
+- **Account-scoped token is broader.** Edge ban sync needs an account-scoped
+ token, which grants more than the zone-scoped token used for Origin CA
+ certificates. Keep it in `CLOUDFLARE_EDGE_BAN_API_TOKEN` rather than reusing
+ `CLOUDFLARE_API_TOKEN`, and both support the `_FILE` secret convention.
diff --git a/cloudflare/cloudflare.lua b/cloudflare/cloudflare.lua
new file mode 100644
index 0000000..08b7346
--- /dev/null
+++ b/cloudflare/cloudflare.lua
@@ -0,0 +1,449 @@
+local class = require("middleclass")
+local cloudflare_helpers = require("cloudflare.cloudflare_helpers")
+local ipmatcher = require("resty.ipmatcher")
+local plugin = require("bunkerweb.plugin")
+local ssl = require("ngx.ssl")
+local utils = require("bunkerweb.utils")
+
+local cloudflare = class("cloudflare", plugin)
+
+local ngx = ngx
+local var = ngx.var
+local ngx_req = ngx.req
+local INFO = ngx.INFO
+local WARN = ngx.WARN
+local ERR = ngx.ERR
+local HTTP_OK = ngx.HTTP_OK
+local get_deny_status = utils.get_deny_status
+local get_phase = ngx.get_phase
+local parse_pem_cert = ssl.parse_pem_cert
+local parse_pem_priv_key = ssl.parse_pem_priv_key
+local ssl_server_name = ssl.server_name
+local get_variable = utils.get_variable
+local get_multiple_variables = utils.get_multiple_variables
+local has_variable = utils.has_variable
+local has_not_variable = utils.has_not_variable
+local read_files = utils.read_files
+local ipmatcher_new = ipmatcher.new
+local match_trusted = cloudflare_helpers.match_trusted
+local classify_cache = cloudflare_helpers.classify_cache
+local parse_additional = cloudflare_helpers.parse_additional
+local cache_key = cloudflare_helpers.cache_key
+local trusted_list_empty = cloudflare_helpers.trusted_list_empty
+local clear_header = ngx_req.clear_header
+local tostring = tostring
+local ipairs = ipairs
+local insert = table.insert
+local open = io.open
+
+-- Client-supplied request headers that an upstream might trust as coming from
+-- Cloudflare. Stripped when the connection is NOT from a trusted Cloudflare IP
+-- (CLOUDFLARE_STRIP_SPOOFED_HEADERS) so a direct-to-origin attacker can't spoof them.
+local CF_HEADERS = {
+ "CF-Connecting-IP",
+ "CF-Connecting-IPv6",
+ "True-Client-IP",
+ "CF-IPCountry",
+ "CF-RAY",
+ "CF-Visitor",
+ "CF-Worker",
+}
+
+-- Strip client-supplied Cloudflare headers (defence-in-depth when the peer is not a
+-- trusted Cloudflare IP). Module-local: it needs no instance state.
+local function strip_cf_headers()
+ for _, header in ipairs(CF_HEADERS) do
+ clear_header(header)
+ end
+end
+
+function cloudflare:initialize(ctx)
+ -- Call parent initialize
+ plugin.initialize(self, "cloudflare", ctx)
+ -- Decode trusted_ips — only in request phases that actually consume it (access/preread).
+ -- self.is_request gates out init/ssl_certificate/etc., avoiding a per-handshake build.
+ if get_phase() ~= "init" and self.is_request and self:is_needed() then
+ local trusted_ips, err = self.datastore:get("plugin_cloudflare_trusted_ips", true)
+ if not trusted_ips then
+ self.logger:log(ERR, err)
+ trusted_ips = {}
+ end
+ -- Build a FRESH per-request table. self.datastore:get(key, true) returns the
+ -- worker-LRU table by reference, so the read-only ipv4/ipv6 lists may be shared,
+ -- but "additional" (from the multisite CLOUDFLARE_ADDITIONAL_TRUSTED_FROM setting)
+ -- MUST be rebuilt each request — mutating the shared table would leak memory and
+ -- bleed one service's additional IPs into every other service.
+ self.trusted_ips = {
+ ipv4 = trusted_ips.ipv4 or {},
+ ipv6 = trusted_ips.ipv6 or {},
+ additional = parse_additional(self.variables["CLOUDFLARE_ADDITIONAL_TRUSTED_FROM"]),
+ }
+ end
+end
+
+function cloudflare:is_needed()
+ -- Loading case
+ if self.is_loading then
+ return false
+ end
+ -- Request phases
+ if self.is_request and (self.ctx.bw.server_name ~= "_") then
+ return self.variables["USE_CLOUDFLARE"] == "yes"
+ end
+ -- Other cases : at least one service uses it
+ local is_needed, err = has_variable("USE_CLOUDFLARE", "yes")
+ if is_needed == nil then
+ self.logger:log(ERR, "can't check USE_CLOUDFLARE variable : " .. err)
+ end
+ return is_needed
+end
+
+function cloudflare:set()
+ -- Check if set is needed
+ if not self:is_needed() then
+ return self:ret(true, "set not needed")
+ end
+ local https_configured = "no"
+ -- Only advertise HTTPS as configured once an origin certificate is actually
+ -- loaded for this server, otherwise BunkerWeb may enable a TLS vhost with no
+ -- usable certificate until the daily cert job catches up.
+ if self.variables["CLOUDFLARE_API_TOKEN"] ~= "" and self.variables["CLOUDFLARE_MANAGE_ORIGIN_CERTS"] == "yes" then
+ local data = self.internalstore:get("plugin_cloudflare_" .. self.ctx.bw.server_name, true)
+ if data then
+ https_configured = "yes"
+ self.ctx.bw.https_configured = "yes"
+ end
+ end
+ return self:ret(true, "set https_configured to " .. https_configured)
+end
+
+function cloudflare:init()
+ -- Check if init is needed
+ if not self:is_needed() then
+ return self:ret(true, "init not needed")
+ end
+ -- Read trusted_ips downloaded by cf-trusted-ips-download.py. "additional" comes
+ -- from the CLOUDFLARE_ADDITIONAL_TRUSTED_FROM setting (parsed per-request), no job
+ -- writes an additional.list, so only ipv4/ipv6 are read from disk here.
+ local trusted_ips = {
+ ["ipv4"] = {},
+ ["ipv6"] = {},
+ ["additional"] = {},
+ }
+ local i = 0
+ for _, kind in ipairs({ "ipv4", "ipv6" }) do
+ local f = open("/var/cache/bunkerweb/cloudflare/" .. kind .. ".list", "r")
+ if f then
+ for line in f:lines() do
+ insert(trusted_ips[kind], line)
+ i = i + 1
+ end
+ f:close()
+ end
+ end
+ -- Load them into datastore
+ local ok, err = self.datastore:set("plugin_cloudflare_trusted_ips", trusted_ips, nil, true)
+ if not ok then
+ return self:ret(false, "can't store cloudflare trusted IPs list into datastore : " .. err)
+ end
+ self.logger:log(INFO, "successfully loaded " .. tostring(i) .. " IP/network")
+
+ local ret_ok, ret_err = true, "success"
+ if
+ has_variable("USE_CLOUDFLARE", "yes")
+ and has_not_variable("CLOUDFLARE_API_TOKEN", "")
+ and has_variable("CLOUDFLARE_MANAGE_ORIGIN_CERTS", "yes")
+ then
+ local multisite
+ multisite, err = get_variable("MULTISITE", false)
+ if not multisite then
+ return self:ret(false, "can't get MULTISITE variable : " .. err)
+ end
+ if multisite == "yes" then
+ local vars
+ vars, err = get_multiple_variables({
+ "SERVER_NAME",
+ "USE_CLOUDFLARE",
+ "CLOUDFLARE_API_TOKEN",
+ "CLOUDFLARE_MANAGE_ORIGIN_CERTS",
+ })
+ if not vars then
+ return self:ret(false, "can't get SERVER_NAME variable : " .. err)
+ end
+ for server_name, multisite_vars in pairs(vars) do
+ if
+ multisite_vars["USE_CLOUDFLARE"] == "yes"
+ and multisite_vars["CLOUDFLARE_API_TOKEN"] ~= ""
+ and multisite_vars["CLOUDFLARE_MANAGE_ORIGIN_CERTS"] == "yes"
+ and server_name ~= "global"
+ then
+ local check, data = read_files({
+ "/var/cache/bunkerweb/cloudflare/" .. server_name .. "/origin_cert.pem",
+ "/var/cache/bunkerweb/cloudflare/" .. server_name .. "/private.key",
+ })
+ if not check then
+ self.logger:log(ERR, "error while reading files : " .. data)
+ ret_ok = false
+ ret_err = "error reading files"
+ else
+ check, err = self:load_data(data, multisite_vars["SERVER_NAME"])
+ if not check then
+ self.logger:log(ERR, "error while loading data : " .. err)
+ ret_ok = false
+ ret_err = "error loading data"
+ end
+ end
+ end
+ end
+ else
+ local server_name
+ server_name, err = get_variable("SERVER_NAME", false)
+ if not server_name then
+ return self:ret(false, "can't get SERVER_NAME variable : " .. err)
+ end
+ local check, data = read_files({
+ "/var/cache/bunkerweb/cloudflare/" .. server_name:match("%S+") .. "/origin_cert.pem",
+ "/var/cache/bunkerweb/cloudflare/" .. server_name:match("%S+") .. "/private.key",
+ })
+ if not check then
+ self.logger:log(ERR, "error while reading files : " .. data)
+ ret_ok = false
+ ret_err = "error reading files"
+ else
+ check, err = self:load_data(data, server_name)
+ if not check then
+ self.logger:log(ERR, "error while loading data : " .. err)
+ ret_ok = false
+ ret_err = "error loading data"
+ end
+ end
+ end
+ else
+ ret_err = "cloudflare is not used"
+ end
+ return self:ret(ret_ok, ret_err)
+end
+
+function cloudflare:ssl_certificate()
+ local server_name, err = ssl_server_name()
+ if not server_name then
+ -- No SNI is normal (ngx.ssl.server_name() returns nil, nil); don't nil-concat.
+ if err then
+ return self:ret(false, "can't get server_name : " .. err)
+ end
+ return self:ret(true, "no SNI provided")
+ end
+ local data
+ data, err = self.internalstore:get("plugin_cloudflare_" .. server_name, true)
+ if not data and err ~= "not found" then
+ return self:ret(
+ false,
+ "error while getting plugin_cloudflare_" .. server_name .. " from internalstore : " .. err
+ )
+ elseif data then
+ return self:ret(true, "certificate/key data found", data)
+ end
+ return self:ret(true, "cloudflare is not used")
+end
+
+function cloudflare:load_data(data, server_name)
+ -- Load certificate
+ local cert_chain, err = parse_pem_cert(data[1])
+ if not cert_chain then
+ return false, "error while parsing pem cert : " .. err
+ end
+ -- Load key
+ local priv_key
+ priv_key, err = parse_pem_priv_key(data[2])
+ if not priv_key then
+ return false, "error while parsing pem priv key : " .. err
+ end
+ -- Cache parsed cert/key in the internalstore (worker-local, like the letsencrypt
+ -- core plugin) so private keys never reach the API-exposed datastore.
+ for key in server_name:gmatch("%S+") do
+ local ok
+ ok, err = self.internalstore:set("plugin_cloudflare_" .. key, { cert_chain, priv_key }, nil, true)
+ if not ok then
+ return false, "error while setting data into internalstore : " .. err
+ end
+ end
+ return true
+end
+
+-- Compute (and cache) the trust verdict for an address: "ipv4"/"ipv6"/"additional"
+-- when trusted, "ko" when not. Returns nil, err on failure (callers fail open).
+function cloudflare:peer_trust(addr)
+ local ok, cached = self:is_in_cache(addr)
+ if not ok then
+ self.logger:log(ERR, "error while checking cache : " .. cached)
+ elseif classify_cache(cached) ~= "miss" then
+ return cached
+ end
+ if not self.trusted_ips then
+ return nil, "trusted_ips is nil"
+ end
+ local trusted, kind_or_err = match_trusted(self.trusted_ips, addr, ipmatcher_new)
+ if trusted == nil then
+ return nil, kind_or_err
+ end
+ local verdict = kind_or_err -- "ipv4"/"ipv6"/"additional" or "ko"
+ local err
+ ok, err = self:add_to_cache(addr, verdict)
+ if not ok then
+ self.logger:log(ERR, "error while adding element to cache : " .. err)
+ end
+ return verdict
+end
+
+function cloudflare:access()
+ -- Check if access is needed
+ if not self:is_needed() then
+ return self:ret(true, "cloudflare not activated")
+ end
+
+ -- Authenticated Origin Pulls (mTLS) — HTTP only. The connection only verifies when it
+ -- came through Cloudflare's network presenting its origin-pull client certificate.
+ -- Skipped when the core mTLS plugin owns the handshake (USE_MTLS=yes) — otherwise
+ -- $ssl_client_verify reflects *its* CA, not Cloudflare's, which would mis-judge here
+ -- (matches the gating in confs/server-http/cloudflare-ssl.conf). USE_MTLS is the core
+ -- mTLS plugin's own setting, so it's NOT in self.variables — read it request-scoped.
+ if
+ self.variables["CLOUDFLARE_AUTHENTICATED_ORIGIN_PULLS"] == "yes"
+ and (get_variable("USE_MTLS", true, self.ctx)) ~= "yes"
+ then
+ local verify = var.ssl_client_verify
+ -- An empty/nil verify means ssl_verify_client wasn't emitted (the origin-pull CA
+ -- hasn't been downloaded yet by cf-aop-ca-download.py): fail OPEN so we never deny
+ -- everyone while not ready. "NONE"/"FAILED:*" means the CA IS wired and the peer
+ -- presented no / an invalid client cert — that is a genuine non-Cloudflare origin.
+ if verify == nil or verify == "" then
+ self.logger:log(
+ WARN,
+ "Authenticated Origin Pulls enabled but the origin-pull CA isn't loaded yet, not enforcing"
+ )
+ elseif verify ~= "SUCCESS" then
+ self:set_metric("counters", "failed_cloudflare_aop", 1)
+ if self.variables["CLOUDFLARE_AOP_MODE"] == "enforce" then
+ return self:ret(
+ true,
+ "connection did not present a valid Cloudflare client certificate (ssl_client_verify="
+ .. verify
+ .. ")",
+ get_deny_status()
+ )
+ end
+ self.logger:log(
+ WARN,
+ "Authenticated Origin Pulls: ssl_client_verify=" .. verify .. " (log mode, request allowed)"
+ )
+ end
+ end
+
+ -- Trust verdict is needed for the deny feature and/or header stripping.
+ local deny = self.variables["CLOUDFLARE_DENY_NON_TRUSTED_IPS"] == "yes"
+ local strip = self.variables["CLOUDFLARE_STRIP_SPOOFED_HEADERS"] == "yes"
+ if not deny and not strip then
+ return self:ret(true, "cloudflare trust check not needed")
+ end
+
+ -- Fail open until the trusted ranges have loaded, so we never deny everyone (or
+ -- cache a bogus "ko" for a legitimate IP) during the brief window before the
+ -- cf-trusted-ips-download.py job has populated the list.
+ if trusted_list_empty(self.trusted_ips) then
+ return self:ret(true, "cloudflare trusted IP list not loaded yet, allowing")
+ end
+
+ local realip_remote_addr = var.realip_remote_addr
+ local verdict, err = self:peer_trust(realip_remote_addr)
+ if verdict == nil then
+ -- Fail open: never deny because of an internal error.
+ self.logger:log(
+ ERR,
+ "error while checking if " .. tostring(realip_remote_addr) .. " is trusted : " .. tostring(err)
+ )
+ return self:ret(true, "trust check error (fail open)")
+ end
+
+ local trusted = verdict ~= "ko"
+ if strip and not trusted then
+ strip_cf_headers()
+ end
+ if deny and not trusted then
+ self:set_metric("counters", "failed_cloudflare_trust", 1)
+ return self:ret(true, realip_remote_addr .. " is not trusted", get_deny_status())
+ end
+ if trusted then
+ return self:ret(true, realip_remote_addr .. " is trusted (type : " .. verdict .. ")")
+ end
+ return self:ret(true, "cloudflare access checks passed")
+end
+
+function cloudflare:preread()
+ -- Check if access is needed
+ if not self:is_needed() then
+ return self:ret(true, "cloudflare not activated")
+ end
+ -- Stream only enforces the IP trust check (no headers / no mTLS in preread).
+ if self.variables["CLOUDFLARE_DENY_NON_TRUSTED_IPS"] ~= "yes" then
+ return self:ret(true, "cloudflare trust check not needed")
+ end
+ -- Fail open until the trusted ranges have loaded (see access()).
+ if trusted_list_empty(self.trusted_ips) then
+ return self:ret(true, "cloudflare trusted IP list not loaded yet, allowing")
+ end
+ local realip_remote_addr = var.realip_remote_addr
+ local verdict, err = self:peer_trust(realip_remote_addr)
+ if verdict == nil then
+ self.logger:log(
+ ERR,
+ "error while checking if " .. tostring(realip_remote_addr) .. " is trusted : " .. tostring(err)
+ )
+ return self:ret(true, "trust check error (fail open)")
+ end
+ if verdict == "ko" then
+ self:set_metric("counters", "failed_cloudflare_trust", 1)
+ return self:ret(true, realip_remote_addr .. " is not trusted", get_deny_status())
+ end
+ return self:ret(true, realip_remote_addr .. " is trusted (type : " .. verdict .. ")")
+end
+
+function cloudflare:is_in_cache(ele)
+ local ok, data = self.cachestore_local:get(cache_key(self.ctx.bw.server_name, ele))
+ if not ok then
+ return false, data
+ end
+ return true, data
+end
+
+function cloudflare:add_to_cache(ele, value)
+ local ok, err = self.cachestore_local:set(cache_key(self.ctx.bw.server_name, ele), value, 86400)
+ if not ok then
+ return false, err
+ end
+ return true
+end
+
+function cloudflare:api()
+ if self.ctx.bw.uri == "/cloudflare/ping" and self.ctx.bw.request_method == "POST" then
+ local check, err = has_variable("USE_CLOUDFLARE", "yes")
+ if check == nil then
+ return self:ret(true, "error while checking variable USE_CLOUDFLARE (" .. err .. ")")
+ end
+ if not check then
+ return self:ret(true, "Cloudflare plugin not enabled")
+ end
+ -- Report how many trusted Cloudflare ranges are loaded (proves the download
+ -- job ran and the lists made it into the datastore).
+ local data = self.datastore:get("plugin_cloudflare_trusted_ips", true)
+ local n4 = (data and data.ipv4) and #data.ipv4 or 0
+ local n6 = (data and data.ipv6) and #data.ipv6 or 0
+ return self:ret(
+ true,
+ "cloudflare is up (trusted ranges: " .. tostring(n4) .. " IPv4, " .. tostring(n6) .. " IPv6)",
+ HTTP_OK
+ )
+ end
+ return self:ret(false, "success")
+end
+
+return cloudflare
diff --git a/cloudflare/cloudflare_helpers.lua b/cloudflare/cloudflare_helpers.lua
new file mode 100644
index 0000000..064d1ed
--- /dev/null
+++ b/cloudflare/cloudflare_helpers.lua
@@ -0,0 +1,75 @@
+-- Pure helpers extracted from cloudflare.lua so they can be unit-tested with
+-- busted outside the OpenResty runtime. No ngx/resty dependencies (the IP matcher
+-- is injected) — see spec/cloudflare_helpers_spec.lua.
+
+local _M = {}
+
+-- Split a space-separated string of IPs/networks into a list. Tolerates nil/empty
+-- (the setting may be absent in the phase where initialize() runs).
+function _M.parse_additional(str)
+ local list = {}
+ for data in (str or ""):gmatch("%S+") do
+ list[#list + 1] = data
+ end
+ return list
+end
+
+-- Build the per-server cache key. A separator between server_name and the element
+-- (an IP) prevents "example.com" .. "1.2.3.4" colliding with "example.com1" .. "2.3.4".
+function _M.cache_key(server_name, ele)
+ return "plugin_cloudflare_" .. tostring(server_name) .. "_" .. tostring(ele)
+end
+
+-- Map a cached trust verdict to an action. The cache stores the *string* result of
+-- match_trusted ("ipv4"/"ipv6"/"additional" when trusted, "ko" when not, nil on a
+-- miss). Returning a boolean here is what silently disabled the deny feature before
+-- (a cached "ko" took the allow branch), hence this is unit-tested.
+function _M.classify_cache(cached)
+ if cached == nil then
+ return "miss"
+ end
+ if cached == "ko" then
+ return "deny"
+ end
+ return "allow"
+end
+
+-- True when no trusted ranges are loaded yet (all categories empty/absent). The deny
+-- feature must fail OPEN in this state instead of denying everyone: the Cloudflare
+-- ranges are public and load within seconds of startup, so an empty list means
+-- "not ready", not "deny all" — and it avoids caching a bogus "ko" for legitimate IPs.
+function _M.trusted_list_empty(trusted_ips)
+ if not trusted_ips then
+ return true
+ end
+ for _, kind in ipairs({ "ipv4", "ipv6", "additional" }) do
+ local list = trusted_ips[kind]
+ if list and #list > 0 then
+ return false
+ end
+ end
+ return true
+end
+
+-- Decide whether addr is a trusted Cloudflare/additional IP. Checks ipv4, then ipv6,
+-- then additional, returning (true, "") on the first match, (false, "ko") when
+-- nothing matches, or (nil, err) if a matcher can't be built / errors. new_matcher is
+-- injected (resty.ipmatcher.new in production, a fake in tests).
+function _M.match_trusted(trusted_ips, addr, new_matcher)
+ for _, kind in ipairs({ "ipv4", "ipv6", "additional" }) do
+ local matcher, err = new_matcher(trusted_ips[kind] or {})
+ if not matcher then
+ return nil, err
+ end
+ local matched, merr = matcher:match(addr)
+ if merr then
+ return nil, merr
+ end
+ if matched then
+ return true, kind
+ end
+ end
+ return false, "ko"
+end
+
+return _M
diff --git a/cloudflare/confs/default-server-http/cloudflare.conf b/cloudflare/confs/default-server-http/cloudflare.conf
new file mode 100644
index 0000000..154e605
--- /dev/null
+++ b/cloudflare/confs/default-server-http/cloudflare.conf
@@ -0,0 +1,25 @@
+{% if USE_CLOUDFLARE == "yes" +%}
+{%- set pathlib = import("pathlib") -%}
+# Trust Cloudflare's edge IPs as real-IP sources (downloaded by cf-trusted-ips-download.py).
+{% for kind in ["ipv4", "ipv6"] %}
+ {% set list_path = pathlib.Path("/var/cache/bunkerweb/cloudflare/" + kind + ".list") %}
+ {% if list_path.is_file() %}
+ {% for element in list_path.read_text().split("\n") %}
+ {% if element != "" %}
+set_real_ip_from {{ element }};
+ {% endif %}
+ {% endfor %}
+ {% endif %}
+{% endfor %}
+{% if CLOUDFLARE_ADDITIONAL_TRUSTED_FROM != "" %}
+ {% for element in CLOUDFLARE_ADDITIONAL_TRUSTED_FROM.split(" ") %}
+ {% if element != "" %}
+set_real_ip_from {{ element }};
+ {% endif %}
+ {% endfor %}
+{% endif %}
+{% if CLOUDFLARE_AUTO_REAL_IP == "yes" and USE_REAL_IP != "yes" %}
+real_ip_header {{ CLOUDFLARE_REAL_IP_HEADER }};
+real_ip_recursive off;
+{% endif %}
+{% endif %}
diff --git a/cloudflare/confs/server-http/cloudflare-ssl.conf b/cloudflare/confs/server-http/cloudflare-ssl.conf
new file mode 100644
index 0000000..7f1291c
--- /dev/null
+++ b/cloudflare/confs/server-http/cloudflare-ssl.conf
@@ -0,0 +1,14 @@
+{% if USE_CLOUDFLARE == "yes" and CLOUDFLARE_AUTHENTICATED_ORIGIN_PULLS == "yes" and USE_MTLS != "yes" +%}
+{%- set pathlib = import("pathlib") -%}
+{% set ca_path = pathlib.Path("/var/cache/bunkerweb/cloudflare/aop_ca.pem") %}
+{% if ca_path.is_file() %}
+# Cloudflare Authenticated Origin Pulls (mTLS). The connection is asked for Cloudflare's
+# origin-pull client certificate; verification ("optional" so ACME / health checks still
+# work) is then enforced in cloudflare.lua via $ssl_client_verify according to
+# CLOUDFLARE_AOP_MODE (log|enforce). Skipped when the core mTLS plugin owns these
+# directives (USE_MTLS=yes) to avoid duplicate ssl_verify_client.
+ssl_client_certificate /var/cache/bunkerweb/cloudflare/aop_ca.pem;
+ssl_verify_client optional;
+ssl_verify_depth 1;
+{% endif %}
+{% endif %}
diff --git a/cloudflare/confs/server-http/cloudflare.conf b/cloudflare/confs/server-http/cloudflare.conf
new file mode 100644
index 0000000..b1a19c3
--- /dev/null
+++ b/cloudflare/confs/server-http/cloudflare.conf
@@ -0,0 +1,27 @@
+{% if USE_CLOUDFLARE == "yes" +%}
+{%- set pathlib = import("pathlib") -%}
+# Trust Cloudflare's edge IPs as real-IP sources (downloaded by cf-trusted-ips-download.py).
+{% for kind in ["ipv4", "ipv6"] %}
+ {% set list_path = pathlib.Path("/var/cache/bunkerweb/cloudflare/" + kind + ".list") %}
+ {% if list_path.is_file() %}
+ {% for element in list_path.read_text().split("\n") %}
+ {% if element != "" %}
+set_real_ip_from {{ element }};
+ {% endif %}
+ {% endfor %}
+ {% endif %}
+{% endfor %}
+{% if CLOUDFLARE_ADDITIONAL_TRUSTED_FROM != "" %}
+ {% for element in CLOUDFLARE_ADDITIONAL_TRUSTED_FROM.split(" ") %}
+ {% if element != "" %}
+set_real_ip_from {{ element }};
+ {% endif %}
+ {% endfor %}
+{% endif %}
+{% if CLOUDFLARE_AUTO_REAL_IP == "yes" and USE_REAL_IP != "yes" %}
+# Zero-config real IP: only emitted when the core Real IP plugin isn't already
+# managing these directives (it would be a duplicate-directive error otherwise).
+real_ip_header {{ CLOUDFLARE_REAL_IP_HEADER }};
+real_ip_recursive off;
+{% endif %}
+{% endif %}
diff --git a/cloudflare/confs/server-stream/cloudflare.conf b/cloudflare/confs/server-stream/cloudflare.conf
new file mode 100644
index 0000000..412bd59
--- /dev/null
+++ b/cloudflare/confs/server-stream/cloudflare.conf
@@ -0,0 +1,22 @@
+{% if USE_CLOUDFLARE == "yes" +%}
+{%- set pathlib = import("pathlib") -%}
+# Stream real-IP relies on PROXY protocol, so only the trusted-source list is emitted
+# here (real_ip_header is HTTP-only and not valid in the stream context).
+{% for kind in ["ipv4", "ipv6"] %}
+ {% set list_path = pathlib.Path("/var/cache/bunkerweb/cloudflare/" + kind + ".list") %}
+ {% if list_path.is_file() %}
+ {% for element in list_path.read_text().split("\n") %}
+ {% if element != "" %}
+set_real_ip_from {{ element }};
+ {% endif %}
+ {% endfor %}
+ {% endif %}
+{% endfor %}
+{% if CLOUDFLARE_ADDITIONAL_TRUSTED_FROM != "" %}
+ {% for element in CLOUDFLARE_ADDITIONAL_TRUSTED_FROM.split(" ") %}
+ {% if element != "" %}
+set_real_ip_from {{ element }};
+ {% endif %}
+ {% endfor %}
+{% endif %}
+{% endif %}
diff --git a/cloudflare/docs/api_token.png b/cloudflare/docs/api_token.png
new file mode 100644
index 0000000..903cc1c
Binary files /dev/null and b/cloudflare/docs/api_token.png differ
diff --git a/cloudflare/docs/diagram.mmd b/cloudflare/docs/diagram.mmd
new file mode 100644
index 0000000..0a58c8f
--- /dev/null
+++ b/cloudflare/docs/diagram.mmd
@@ -0,0 +1,46 @@
+flowchart TD
+ accTitle: BunkerWeb Cloudflare plugin
+ accDescr: A visitor reaches Cloudflare's edge, which proxies to BunkerWeb. BunkerWeb restores the real client IP from CF-Connecting-IP, optionally denies any connection not coming from a Cloudflare IP and optionally verifies Cloudflare's Authenticated Origin Pull client certificate before forwarding to the upstream. The BunkerWeb scheduler runs jobs that download Cloudflare's IP ranges and the origin-pull CA, manage Origin CA certificates and push BunkerWeb bans to a Cloudflare edge IP List.
+
+ visitor([Visitor])
+ edge[[Cloudflare edge]]
+
+ subgraph bw[BunkerWeb]
+ direction TB
+ realip["Restore real IP from CF-Connecting-IP (set_real_ip_from + real_ip_header)"]
+ verdict{"cloudflare.lua: trusted peer? (+ optional mTLS origin-pull check)"}
+ realip --> verdict
+ end
+
+ allow["Forward to upstream (strip spoofed CF-* headers)"]
+ deny["Deny (403 / close)"]
+ upstream([Upstream app])
+
+ subgraph sched[BunkerWeb scheduler jobs]
+ direction TB
+ ip["cf-trusted-ips-download"]
+ cert["cf-manage-origin-certs"]
+ aop["cf-aop-ca-download"]
+ ban["cf-edge-ban-sync"]
+ end
+ cfapi[[Cloudflare API]]
+
+ visitor -->|request| edge
+ edge -->|"CF IP + CF-Connecting-IP"| realip
+ verdict -->|trusted| allow
+ verdict -->|not trusted| deny
+ allow --> upstream
+
+ cert -.->|Origin CA cert| cfapi
+ aop -.->|origin-pull CA| edge
+ ban -.->|push bans to IP List| cfapi
+ ip -.->|served via SNI / set_real_ip_from| bw
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef cf fill:#fff4e6,stroke:#f6821f,color:#7a3d00;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class allow,upstream ok;
+ class deny deny;
+ class edge,cfapi cf;
+ class visitor,realip,verdict,ip,cert,aop,ban app;
diff --git a/cloudflare/jobs/cf-aop-ca-download.py b/cloudflare/jobs/cf-aop-ca-download.py
new file mode 100644
index 0000000..e612bc3
--- /dev/null
+++ b/cloudflare/jobs/cf-aop-ca-download.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+
+from os import getenv, sep
+from os.path import dirname, join
+from sys import exit as sys_exit, path as sys_path
+
+# BunkerWeb deps + this job's own directory (for cloudflare_helpers).
+sys_path.insert(0, dirname(__file__))
+for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in (("deps", "python"), ("utils",), ("db",))]:
+ if deps_path not in sys_path:
+ sys_path.append(deps_path)
+
+from cryptography import x509
+from cryptography.hazmat.backends import default_backend
+from requests import Session
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
+
+from logger import setup_logger # type: ignore
+from common_utils import bytes_hash # type: ignore
+from jobs import Job # type: ignore
+
+from cloudflare_helpers import CF_AOP_CA_DEFAULT_URL # type: ignore
+
+LOGGER = setup_logger("CLOUDFLARE.AOP-CA-DOWNLOAD", getenv("LOG_LEVEL", "INFO"))
+status = 0
+
+
+def aop_enabled() -> bool:
+ """True if any (multisite) service or the global config enables Authenticated Origin Pulls."""
+ if getenv("MULTISITE", "no") == "yes":
+ for server in getenv("SERVER_NAME", "").split():
+ if (
+ getenv(f"{server}_USE_CLOUDFLARE", getenv("USE_CLOUDFLARE", "no")) == "yes"
+ and getenv(f"{server}_CLOUDFLARE_AUTHENTICATED_ORIGIN_PULLS", getenv("CLOUDFLARE_AUTHENTICATED_ORIGIN_PULLS", "no")) == "yes"
+ ):
+ return True
+ return False
+ return getenv("USE_CLOUDFLARE", "no") == "yes" and getenv("CLOUDFLARE_AUTHENTICATED_ORIGIN_PULLS", "no") == "yes"
+
+
+try:
+ if not aop_enabled():
+ LOGGER.info("Authenticated Origin Pulls not enabled, skipping CA download...")
+ sys_exit(0)
+
+ JOB = Job(LOGGER, __file__)
+
+ if JOB.is_cached_file("aop_ca.pem", "week"):
+ LOGGER.info("Cloudflare Authenticated Origin Pull CA is already in cache, skipping download...")
+ sys_exit(0)
+
+ url = getenv("CLOUDFLARE_AOP_CA_URL", CF_AOP_CA_DEFAULT_URL)
+ try:
+ timeout = int(getenv("CLOUDFLARE_API_TIMEOUT", "10"))
+ except ValueError:
+ timeout = 10
+
+ session = Session()
+ retry = Retry(total=3, backoff_factor=0.5, status_forcelist=(429, 500, 502, 503, 504), allowed_methods=("GET",))
+ adapter = HTTPAdapter(max_retries=retry)
+ session.mount("http://", adapter)
+ session.mount("https://", adapter)
+
+ LOGGER.info(f"Downloading Cloudflare Authenticated Origin Pull CA from {url}...")
+ resp = session.get(url, timeout=timeout, allow_redirects=True, verify=True)
+ if resp.status_code != 200:
+ LOGGER.error(f"Got status code {resp.status_code} while downloading the AOP CA, skipping...")
+ sys_exit(2)
+
+ content = resp.content
+ # Validate it really is a PEM certificate before trusting it as a client CA.
+ try:
+ x509.load_pem_x509_certificate(content, default_backend())
+ except Exception as e:
+ LOGGER.error(f"Downloaded AOP CA is not a valid PEM certificate: {e}")
+ sys_exit(2)
+
+ new_hash = bytes_hash(content)
+ if new_hash == JOB.cache_hash("aop_ca.pem"):
+ LOGGER.info("AOP CA is identical to the cached one, reload is not needed")
+ sys_exit(0)
+
+ cached, err = JOB.cache_file("aop_ca.pem", content, checksum=new_hash)
+ if not cached:
+ LOGGER.error(f"Error while caching the AOP CA : {err}")
+ sys_exit(2)
+
+ LOGGER.info("🔒 Successfully downloaded the Cloudflare Authenticated Origin Pull CA ✅")
+ status = 1
+except SystemExit as e:
+ status = e.code
+except:
+ status = 2
+ LOGGER.exception("Exception while running cf-aop-ca-download.py")
+
+sys_exit(status)
diff --git a/cloudflare/jobs/cf-edge-ban-sync.py b/cloudflare/jobs/cf-edge-ban-sync.py
new file mode 100644
index 0000000..9b07b6c
--- /dev/null
+++ b/cloudflare/jobs/cf-edge-ban-sync.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+
+from os import getenv, sep
+from os.path import dirname, join
+from sys import exit as sys_exit, path as sys_path
+
+# BunkerWeb deps + this job's own directory (for cloudflare_helpers).
+sys_path.insert(0, dirname(__file__))
+for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in (("deps", "python"), ("utils",), ("db",))]:
+ if deps_path not in sys_path:
+ sys_path.append(deps_path)
+
+from cloudflare import APIError, Cloudflare # type: ignore
+
+from logger import setup_logger # type: ignore
+from common_utils import get_redis_client # type: ignore
+
+from cloudflare_helpers import CF_API_DEFAULT_URL, get_env_secret, parse_ban_key # type: ignore
+
+LOGGER = setup_logger("CLOUDFLARE.EDGE-BAN-SYNC", getenv("LOG_LEVEL", "INFO"))
+status = 0
+# Cloudflare's default per-account IP List capacity. We never push more than this.
+MAX_ITEMS = 10000
+
+try:
+ if getenv("USE_CLOUDFLARE_EDGE_BAN_SYNC", "no") != "yes":
+ LOGGER.info("Cloudflare edge ban sync is not enabled, skipping...")
+ sys_exit(0)
+
+ if getenv("USE_REDIS", "no") != "yes":
+ LOGGER.warning("Cloudflare edge ban sync requires USE_REDIS=yes (bans are read from Redis), skipping...")
+ sys_exit(0)
+
+ account_id = getenv("CLOUDFLARE_ACCOUNT_ID", "")
+ if not account_id:
+ LOGGER.error("CLOUDFLARE_ACCOUNT_ID is required for edge ban sync, skipping...")
+ sys_exit(2)
+
+ token = get_env_secret("CLOUDFLARE_EDGE_BAN_API_TOKEN", "CLOUDFLARE_API_TOKEN").strip().removeprefix("Bearer ").strip()
+ if not token:
+ LOGGER.error("No API token available for edge ban sync (set CLOUDFLARE_EDGE_BAN_API_TOKEN or CLOUDFLARE_API_TOKEN), skipping...")
+ sys_exit(2)
+
+ list_name = getenv("CLOUDFLARE_BAN_LIST_NAME", "bunkerweb_bans")
+
+ redis_client = get_redis_client(
+ use_redis=True,
+ redis_host=getenv("REDIS_HOST"),
+ redis_port=getenv("REDIS_PORT", "6379"),
+ redis_db=getenv("REDIS_DATABASE", "0"),
+ redis_timeout=getenv("REDIS_TIMEOUT", "1000"),
+ redis_keepalive_pool=getenv("REDIS_KEEPALIVE_POOL", "10"),
+ redis_ssl=getenv("REDIS_SSL", "no") == "yes",
+ redis_username=getenv("REDIS_USERNAME") or None,
+ redis_password=getenv("REDIS_PASSWORD") or None,
+ redis_sentinel_hosts=getenv("REDIS_SENTINEL_HOSTS", ""),
+ redis_sentinel_username=getenv("REDIS_SENTINEL_USERNAME") or None,
+ redis_sentinel_password=getenv("REDIS_SENTINEL_PASSWORD") or None,
+ redis_sentinel_master=getenv("REDIS_SENTINEL_MASTER", ""),
+ logger=LOGGER,
+ )
+ if redis_client is None:
+ LOGGER.error("Could not connect to Redis, skipping edge ban sync...")
+ sys_exit(2)
+
+ # Collect currently banned IPs (both global and service-scoped) from Redis.
+ banned = set()
+ for pattern in ("bans_ip_*", "bans_service_*_ip_*"):
+ for key in redis_client.scan_iter(pattern):
+ ip = parse_ban_key(key)
+ if ip:
+ banned.add(ip)
+ LOGGER.info(f"Found {len(banned)} active banned IP(s) in Redis")
+
+ if len(banned) > MAX_ITEMS:
+ LOGGER.warning(f"More than {MAX_ITEMS} banned IPs ({len(banned)}); only {MAX_ITEMS} will be synced to the Cloudflare list")
+ banned = set(sorted(banned)[:MAX_ITEMS])
+
+ try:
+ api_timeout = float(getenv("CLOUDFLARE_API_TIMEOUT", "10"))
+ except ValueError:
+ api_timeout = 10.0
+ client = Cloudflare(api_token=token, base_url=getenv("CLOUDFLARE_API_URL", CF_API_DEFAULT_URL).rstrip("/"), timeout=api_timeout)
+
+ # Find or create the account IP List.
+ list_id = None
+ try:
+ for lst in client.rules.lists.list(account_id=account_id):
+ if getattr(lst, "name", None) == list_name and getattr(lst, "kind", None) == "ip":
+ list_id = lst.id
+ break
+ if not list_id:
+ LOGGER.info(f"Creating Cloudflare IP List '{list_name}'...")
+ created = client.rules.lists.create(account_id=account_id, kind="ip", name=list_name)
+ list_id = created.id
+ except APIError as e:
+ LOGGER.error(f"Failed to find/create the Cloudflare IP List '{list_name}': {e}")
+ sys_exit(2)
+
+ # Current items in the list (ip -> item id).
+ current = {}
+ try:
+ for item in client.rules.lists.items.list(list_id=list_id, account_id=account_id):
+ ip = getattr(item, "ip", None)
+ if ip:
+ current[ip] = getattr(item, "id", None)
+ except APIError as e:
+ LOGGER.error(f"Failed to list items of the Cloudflare IP List: {e}")
+ sys_exit(2)
+
+ current_ips = set(current.keys())
+ to_add = banned - current_ips
+ to_remove = current_ips - banned
+
+ if not to_add and not to_remove:
+ LOGGER.info("Cloudflare edge IP List already in sync with BunkerWeb bans, nothing to do")
+ sys_exit(0)
+
+ try:
+ if to_add:
+ client.rules.lists.items.create(list_id=list_id, account_id=account_id, body=[{"ip": ip} for ip in to_add])
+ LOGGER.info(f"➕ Queued {len(to_add)} IP(s) to add to the Cloudflare edge IP List")
+ if to_remove:
+ items = [{"id": current[ip]} for ip in to_remove if current.get(ip)]
+ if items:
+ client.rules.lists.items.delete(list_id=list_id, account_id=account_id, items=items)
+ LOGGER.info(f"➖ Queued {len(items)} IP(s) to remove from the Cloudflare edge IP List")
+ except APIError as e:
+ LOGGER.error(f"Failed to update the Cloudflare edge IP List: {e}")
+ sys_exit(2)
+
+ LOGGER.info("☁️ Successfully synced BunkerWeb bans to the Cloudflare edge IP List ✅")
+ # 0, not 1: the sync only changes Cloudflare's edge (no local nginx config changed).
+ # The scheduler reloads nginx on a job returning 1, so success must be 0 to avoid a
+ # needless reload every run (it does NOT honor the per-job reload:false at runtime).
+ status = 0
+except SystemExit as e:
+ status = e.code
+except:
+ status = 2
+ LOGGER.exception("Exception while running cf-edge-ban-sync.py")
+
+sys_exit(status)
diff --git a/cloudflare/jobs/cf-manage-origin-certs.py b/cloudflare/jobs/cf-manage-origin-certs.py
new file mode 100644
index 0000000..7509721
--- /dev/null
+++ b/cloudflare/jobs/cf-manage-origin-certs.py
@@ -0,0 +1,404 @@
+#!/usr/bin/env python3
+
+from datetime import datetime
+from json import dumps
+from os import getenv, sep
+from os.path import dirname, join
+from pathlib import Path
+from subprocess import run
+from sys import exit as sys_exit, path as sys_path
+from typing import Dict
+
+# BunkerWeb deps + this job's own directory (for cloudflare_helpers).
+sys_path.insert(0, dirname(__file__))
+for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in (("deps", "python"), ("utils",), ("db",))]:
+ if deps_path not in sys_path:
+ sys_path.append(deps_path)
+
+from cloudflare import APIError, Cloudflare # type: ignore
+from cryptography import x509
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives.asymmetric import ec, rsa
+
+from logger import setup_logger # type: ignore
+from jobs import Job # type: ignore
+
+from cloudflare_helpers import ( # type: ignore
+ CF_API_DEFAULT_URL,
+ build_csr_config,
+ find_matching_cert,
+ get_env_secret,
+ is_expired,
+ request_type_for,
+ select_zone,
+ select_zone_name,
+)
+
+LOGGER = setup_logger("CLOUDFLARE.MANAGE-ORIGIN-CERTS", getenv("LOG_LEVEL", "INFO"))
+CLOUDFLARE_API_URL = getenv("CLOUDFLARE_API_URL", CF_API_DEFAULT_URL).rstrip("/")
+try:
+ CLOUDFLARE_API_TIMEOUT = float(getenv("CLOUDFLARE_API_TIMEOUT", "10"))
+except ValueError:
+ CLOUDFLARE_API_TIMEOUT = 10.0
+CACHE_PATH = Path(sep, "var", "cache", "bunkerweb", "cloudflare")
+status = 0
+
+# Cache one SDK client per token (multisite services may use distinct tokens).
+_clients: Dict[str, Cloudflare] = {}
+
+
+def get_client(api_token: str) -> Cloudflare:
+ if api_token not in _clients:
+ _clients[api_token] = Cloudflare(api_token=api_token, base_url=CLOUDFLARE_API_URL, timeout=CLOUDFLARE_API_TIMEOUT)
+ return _clients[api_token]
+
+
+def token_is_active(client: Cloudflare) -> bool:
+ """Verify the API token via /user/tokens/verify."""
+ try:
+ result = client.user.tokens.verify()
+ return getattr(result, "status", None) == "active"
+ except APIError as e:
+ LOGGER.error(f"Failed to verify API token: {e}")
+ return False
+
+
+def revoke_cert(client: Cloudflare, first_server: str, cert_id: str) -> bool:
+ """Revoke an Origin CA cert on Cloudflare, THEN clear the local cache.
+
+ Order matters: if we cleared the cache first and the API delete failed, the cert
+ would be orphaned on Cloudflare with no local id left to revoke it.
+ """
+ try:
+ client.origin_ca_certificates.delete(cert_id)
+ except APIError as e:
+ message = str(e)
+ if "already revoked" not in message.casefold():
+ LOGGER.error(f"Failed to revoke origin certificate {cert_id}: {e}")
+ return False
+ LOGGER.warning(f"Certificate {cert_id} was already revoked")
+
+ for name in ("cert.id", "origin_cert.pem", "private.key", "csr.pem", "csr.conf"):
+ JOB.del_cache(name, service_id=first_server)
+ return True
+
+
+try:
+ # Check if at least a server has Cloudflare activated. Keep the original case: the
+ # service id is the cache-dir name the Lua side reads back (it uses the original-case
+ # SERVER_NAME). Domains/CF hostnames are lowercased separately below.
+ servers = getenv("SERVER_NAME", "") or []
+
+ if isinstance(servers, str):
+ servers = servers.split(" ")
+
+ if not servers:
+ LOGGER.error("There are no server names, skipping generation...")
+ sys_exit(0)
+
+ cf_activated = False
+ is_multisite = getenv("MULTISITE", "no") == "yes"
+
+ # Multisite case
+ if is_multisite:
+ for first_server in servers:
+ if first_server and (
+ getenv(f"{first_server}_USE_CLOUDFLARE", getenv("USE_CLOUDFLARE", "no")) == "yes"
+ or get_env_secret(f"{first_server}_CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN")
+ ):
+ cf_activated = True
+ break
+ # Singlesite case
+ elif getenv("USE_CLOUDFLARE", "no") == "yes" or get_env_secret("CLOUDFLARE_API_TOKEN"):
+ servers = [servers[0]]
+ cf_activated = True
+
+ if not cf_activated:
+ LOGGER.info("Cloudflare is not activated, skipping origin certs generation...")
+ sys_exit(0)
+
+ JOB = Job(LOGGER, __file__)
+
+ valid_tokens = set()
+ invalid_tokens = set()
+ for first_server in servers:
+ if not first_server:
+ continue
+ service_cache_path = CACHE_PATH.joinpath(first_server)
+
+ cert_id_file = service_cache_path.joinpath("cert.id")
+ origin_cert_file = service_cache_path.joinpath("origin_cert.pem")
+ csr_file = service_cache_path.joinpath("csr.pem")
+ private_key_file = service_cache_path.joinpath("private.key")
+ csr_conf_file = service_cache_path.joinpath("csr.conf")
+
+ # * Getting all the necessary data (api_token / zone_id support the _FILE secret convention)
+ api_token = get_env_secret(f"{first_server}_CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN").strip().removeprefix("Bearer ").strip()
+ data = {
+ "use_cloudflare": getenv(f"{first_server}_USE_CLOUDFLARE", getenv("USE_CLOUDFLARE", "no")),
+ "manage_origin_certs": getenv(f"{first_server}_CLOUDFLARE_MANAGE_ORIGIN_CERTS", getenv("CLOUDFLARE_MANAGE_ORIGIN_CERTS", "yes")),
+ "api_token": api_token,
+ "domains": [
+ domain for domain in (getenv(f"{first_server}_SERVER_NAME", getenv("SERVER_NAME", "")).lower() or first_server).strip().split(" ") if domain
+ ],
+ "zone_id": get_env_secret(f"{first_server}_CLOUDFLARE_ZONE_ID", "CLOUDFLARE_ZONE_ID"),
+ "type": getenv(f"{first_server}_CLOUDFLARE_ORIGIN_CERT_TYPE", getenv("CLOUDFLARE_ORIGIN_CERT_TYPE", "rsa")),
+ "validity": getenv(f"{first_server}_CLOUDFLARE_ORIGIN_CERT_VALIDITY", getenv("CLOUDFLARE_ORIGIN_CERT_VALIDITY", "5475")),
+ }
+
+ if data["use_cloudflare"] != "yes" or data["manage_origin_certs"] != "yes":
+ LOGGER.info(f"Skipping origin certs generation for {first_server} because it is not configured to use Cloudflare or manage origin certs")
+
+ if cert_id_file.is_file():
+ if not api_token or api_token in invalid_tokens or (api_token not in valid_tokens and not token_is_active(get_client(api_token))):
+ LOGGER.warning(
+ f"API token for {first_server} is either not set or invalid, therefore we cannot revoke the existing origin certificate, check your Cloudflare account to see if the certificate isn't still active"
+ )
+ for name in ("cert.id", "origin_cert.pem", "csr.pem", "private.key", "csr.conf"):
+ JOB.del_cache(name, service_id=first_server)
+ else:
+ valid_tokens.add(api_token)
+ LOGGER.info(f"Revoking existing origin certificate for {first_server}...")
+ if revoke_cert(get_client(api_token), first_server, cert_id_file.read_text().strip()):
+ LOGGER.info(f"Successfully deleted existing origin certificate for {first_server}")
+ elif origin_cert_file.is_file() or csr_file.is_file() or private_key_file.is_file() or csr_conf_file.is_file():
+ LOGGER.warning(
+ f"Cache files found for {first_server} but no certificate ID, therefore we cannot revoke the existing origin certificate, check your Cloudflare account to see if the certificate isn't still active"
+ )
+ for name in ("origin_cert.pem", "csr.pem", "private.key", "csr.conf"):
+ JOB.del_cache(name, service_id=first_server)
+ continue
+
+ LOGGER.debug(f"Data for service {first_server}: {dumps({k: v for k, v in data.items() if k != 'api_token'})}")
+
+ # * Checking if the data is valid
+ if not data["api_token"]:
+ LOGGER.warning(f"API token for {first_server} is not set, skipping origin certs generation...")
+ status = 2
+ continue
+
+ client = get_client(data["api_token"])
+
+ # * Checking if the API token is valid (cached across services)
+ if data["api_token"] in invalid_tokens:
+ LOGGER.warning(f"API token for {first_server} is invalid, skipping origin certs generation...")
+ status = 2
+ continue
+
+ if data["api_token"] not in valid_tokens:
+ LOGGER.info(f"Checking if the API token for {first_server} is valid...")
+ if not token_is_active(client):
+ invalid_tokens.add(data["api_token"])
+ LOGGER.warning(f"API token for {first_server} is invalid or not active, skipping origin certs generation...")
+ status = 2
+ continue
+ LOGGER.info(f"🔑 API token for {first_server} is valid ✅")
+ valid_tokens.add(data["api_token"])
+
+ service_cache_path.mkdir(parents=True, exist_ok=True)
+
+ cert_id = None
+ expired = False
+ changed = False
+ # * Inspecting the locally cached cert/key/CSR config to decide if we must act
+ if cert_id_file.is_file():
+ cert_id = cert_id_file.read_text().strip()
+
+ if csr_conf_file.is_file():
+ LOGGER.info(f"CSR configuration file found for {first_server}, checking if the subdomains have changed...")
+ changed = csr_conf_file.read_text() != build_csr_config(first_server, data["domains"])
+
+ if origin_cert_file.is_file() and private_key_file.is_file():
+ LOGGER.info(f"Certificate file found for {first_server}, checking if the certificate is still valid...")
+ certificate = x509.load_pem_x509_certificate(origin_cert_file.read_bytes(), default_backend())
+ not_valid_after = certificate.not_valid_after_utc # type: ignore[attr-defined] # cryptography>=42 (image ships 49)
+ if not_valid_after < datetime.now(tz=not_valid_after.tzinfo):
+ expired = True
+
+ public_key = certificate.public_key()
+ if isinstance(public_key, rsa.RSAPublicKey) and data["type"] == "ecdsa":
+ LOGGER.warning(f"Certificate type for {first_server} does not match the one we want to generate (ECDSA vs RSA)")
+ changed = True
+ elif isinstance(public_key, ec.EllipticCurvePublicKey) and data["type"] == "rsa":
+ LOGGER.warning(f"Certificate type for {first_server} does not match the one we want to generate (RSA vs ECDSA)")
+ changed = True
+ else:
+ expired = True
+
+ try:
+ if not cert_id:
+ # * Getting the zone ID if it is not set
+ if not data["zone_id"]:
+ zone_name = select_zone_name(data["domains"])
+ LOGGER.info(f"Getting the active zone ID for {first_server} (querying zone '{zone_name}')...")
+
+ zones = [
+ {"id": z.id, "name": z.name, "type": getattr(z, "type", ""), "modified_on": str(getattr(z, "modified_on", ""))}
+ for z in client.zones.list(name=zone_name, status="active")
+ ]
+ if not zones:
+ LOGGER.error(f"No active zone found for {first_server}'s API token, skipping origin certs generation...")
+ status = 2
+ continue
+ if len(zones) > 1:
+ LOGGER.warning(f"More than one zone found for {first_server}, using the one with the most recent modification date...")
+
+ zone = select_zone(zones)
+ if not zone:
+ status = 2
+ continue
+ data["zone_id"] = zone.get("id", "")
+ if not data["zone_id"]:
+ status = 2
+ continue
+ LOGGER.info(f"🌐 Zone ID for {first_server} is {data['zone_id']} (name: {zone.get('name', '')}, type: {zone.get('type', '')})")
+
+ # * Getting all existing origin certificates for the zone (SDK auto-paginates)
+ certs = [
+ {"id": c.id, "hostnames": list(getattr(c, "hostnames", []) or []), "expires_on": getattr(c, "expires_on", "")}
+ for c in client.origin_ca_certificates.list(zone_id=data["zone_id"])
+ ]
+
+ cert_id, found, cert_expired = find_matching_cert(certs, data["domains"])
+ if cert_expired:
+ expired = True
+ if not found:
+ cert_id = None
+
+ if cert_id and not expired:
+ if cert_id_file.is_file() and origin_cert_file.is_file() and private_key_file.is_file():
+ LOGGER.info(f"Origin certificate for {','.join(data['domains'])} already exists, skipping origin certs generation...")
+ continue
+ LOGGER.info(f"Origin certificate for {','.join(data['domains'])} exists on Cloudflare's side but not locally, regenerating it...")
+ expired = True
+ elif not expired and not changed:
+ LOGGER.info(
+ f"Origin certificate for {','.join(data['domains'])} already exists and is still valid locally, checking if it is still valid on Cloudflare's side..."
+ )
+ remote = client.origin_ca_certificates.get(cert_id)
+ if is_expired(getattr(remote, "expires_on", "")):
+ expired = True
+ else:
+ LOGGER.info(f"Origin certificate for {','.join(data['domains'])} is still valid on Cloudflare's side, no need to regenerate it")
+ continue
+ except APIError as e:
+ LOGGER.error(f"Cloudflare API error while resolving certificate state for {first_server}: {e}")
+ status = 2
+ continue
+
+ if cert_id and (expired or changed):
+ LOGGER.info(f"Origin certificate for {','.join(data['domains'])} has {'expired' if expired else 'changed'}, revoking it...")
+ if revoke_cert(client, first_server, cert_id):
+ LOGGER.info(f"Successfully deleted expired origin certificate for {','.join(data['domains'])}")
+ cert_id = None
+
+ # * Generating the CSR + private key if they are missing or the subdomains changed.
+ # (CSRs have no expiry — regeneration is driven only by missing files / changes.)
+ csr_content = csr_file.read_text() if csr_file.is_file() else None
+ if not csr_content or changed:
+ if changed:
+ LOGGER.info(f"Subdomains for {first_server} have changed, generating a new Certificate Signing Request (CSR)...")
+ else:
+ LOGGER.info(f"Generating a Certificate Signing Request (CSR) for {first_server}")
+
+ if changed or not csr_conf_file.is_file():
+ content = build_csr_config(first_server, data["domains"]).encode()
+ cached, err = JOB.cache_file("csr.conf", content, service_id=first_server)
+ if not cached:
+ LOGGER.error(f"Error while caching csr.conf file for {first_server} : {err}")
+ status = 2
+ continue
+ LOGGER.info(f"🔩 Successfully generated CSR configuration file for {first_server} ✅")
+
+ command = [
+ "openssl",
+ "req",
+ "-nodes",
+ "-new",
+ "-newkey",
+ "-keyout",
+ private_key_file.as_posix(),
+ "-out",
+ csr_file.as_posix(),
+ "-config",
+ csr_conf_file.as_posix(),
+ ]
+ if data["type"] == "ecdsa":
+ command.insert(5, "ec")
+ command.insert(6, "-pkeyopt")
+ command.insert(7, "ec_paramgen_curve:prime256v1")
+ else:
+ command.insert(5, "rsa:2048")
+
+ result = run(command, capture_output=True, text=True, check=False)
+ if result.returncode != 0:
+ LOGGER.error(f"CSR generation failed for {first_server}: {result.stderr}")
+ status = 2
+ continue
+
+ cached, err = JOB.cache_file("csr.pem", csr_file, service_id=first_server, overwrite_file=False)
+ if not cached:
+ LOGGER.error(f"Error while caching csr.pem file for {first_server} : {err}")
+ status = 2
+ continue
+
+ cached, err = JOB.cache_file("private.key", private_key_file, service_id=first_server, overwrite_file=False)
+ if not cached:
+ LOGGER.error(f"Error while caching private.key file for {first_server} : {err}")
+ status = 2
+ continue
+
+ LOGGER.info(f"🔐 Successfully generated CSR for {first_server} ✅")
+ csr_content = csr_file.read_text()
+ else:
+ LOGGER.info(f"Certificate Signing Request (CSR) for {first_server} is still valid, no need to regenerate it")
+
+ # * Generating a new origin certificate
+ LOGGER.info(f"Generating a new origin certificate for {','.join(data['domains'])}...")
+ try:
+ created = client.origin_ca_certificates.create(
+ csr=csr_content,
+ hostnames=data["domains"],
+ request_type=request_type_for(data["type"]),
+ requested_validity=int(data["validity"]),
+ )
+ except APIError as e:
+ LOGGER.error(f"Failed to generate origin certificate for {','.join(data['domains'])}: {e}")
+ status = 2
+ continue
+
+ # Tolerate whitespace/newline normalization by the API (the CSR is "newline-encoded")
+ # so a successfully issued (already-billed) cert isn't discarded over a trailing \n.
+ if (getattr(created, "csr", None) or "").strip() != csr_content.strip():
+ LOGGER.error("CSR of generated certificate does not match the one we sent")
+ status = 2
+ continue
+
+ cert_id = getattr(created, "id", None)
+ cert_content = getattr(created, "certificate", None)
+ if not cert_id or not cert_content:
+ LOGGER.error("No certificate ID or content received from Cloudflare")
+ status = 2
+ continue
+
+ cached, err = JOB.cache_file("origin_cert.pem", cert_content.encode(), service_id=first_server)
+ if not cached:
+ LOGGER.error(f"Error while caching origin_cert.pem file for {first_server} : {err}")
+ status = 2
+ continue
+
+ cached, err = JOB.cache_file("cert.id", cert_id.encode(), service_id=first_server)
+ if not cached:
+ LOGGER.error(f"Error while caching cert.id file for {first_server} : {err}")
+ status = 2
+ continue
+
+ LOGGER.info(f"📜 Successfully generated origin certificate for {','.join(data['domains'])} ✅")
+ status = status or 1
+except SystemExit as e:
+ status = e.code
+except:
+ status = 2
+ LOGGER.exception("Exception while running cf-manage-origin-certs.py")
+
+sys_exit(status)
diff --git a/cloudflare/jobs/cf-trusted-ips-download.py b/cloudflare/jobs/cf-trusted-ips-download.py
new file mode 100755
index 0000000..dabca48
--- /dev/null
+++ b/cloudflare/jobs/cf-trusted-ips-download.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+
+from os import getenv, sep
+from os.path import dirname, join
+from sys import exit as sys_exit, path as sys_path
+
+# BunkerWeb deps + this job's own directory (for cloudflare_helpers).
+sys_path.insert(0, dirname(__file__))
+for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in (("deps", "python"), ("utils",), ("db",))]:
+ if deps_path not in sys_path:
+ sys_path.append(deps_path)
+
+from requests import Session
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
+
+from logger import setup_logger # type: ignore
+from common_utils import bytes_hash # type: ignore
+from jobs import Job # type: ignore
+
+from cloudflare_helpers import CF_IPS_V4_DEFAULT_URL, CF_IPS_V6_DEFAULT_URL, check_line # type: ignore
+
+LOGGER = setup_logger("CLOUDFLARE.TRUSTED-IPS-DOWNLOAD", getenv("LOG_LEVEL", "INFO"))
+try:
+ _timeout = int(getenv("CLOUDFLARE_API_TIMEOUT", "10"))
+except ValueError:
+ _timeout = 10
+status = 0
+
+
+def make_session() -> Session:
+ """A requests Session with retry/backoff on transient errors."""
+ session = Session()
+ retry = Retry(total=3, backoff_factor=0.5, status_forcelist=(429, 500, 502, 503, 504), allowed_methods=("GET",))
+ adapter = HTTPAdapter(max_retries=retry)
+ session.mount("http://", adapter)
+ session.mount("https://", adapter)
+ return session
+
+
+try:
+ # Check if at least a server has Cloudflare activated
+ cf_activated = False
+ # Multisite case
+ if getenv("MULTISITE", "no") == "yes":
+ servers = getenv("SERVER_NAME", [])
+
+ if isinstance(servers, str):
+ servers = servers.split(" ")
+
+ for first_server in servers:
+ if getenv(f"{first_server}_USE_CLOUDFLARE", getenv("USE_CLOUDFLARE", "no")) == "yes":
+ cf_activated = True
+ break
+ # Singlesite case
+ elif getenv("USE_CLOUDFLARE", "no") == "yes":
+ cf_activated = True
+
+ if not cf_activated:
+ LOGGER.info("Cloudflare is not activated, skipping trusted IPs download...")
+ sys_exit(0)
+
+ JOB = Job(LOGGER, __file__)
+
+ # Don't go further if the cache is fresh (the job runs daily; CF ranges change rarely)
+ if JOB.is_cached_file("ipv4.list", "day") and JOB.is_cached_file("ipv6.list", "day"):
+ LOGGER.info("Cloudflare's IPv4 and IPv6 trusted IPs/nets lists are already in cache, skipping download...")
+ sys_exit(0)
+
+ # URLs are overridable (testing / Cloudflare-compatible mirrors); each pair carries
+ # its own type so an override URL is never mislabelled by sniffing its suffix.
+ sources = (
+ (getenv("CLOUDFLARE_IPS_V4_URL", CF_IPS_V4_DEFAULT_URL), "ipv4"),
+ (getenv("CLOUDFLARE_IPS_V6_URL", CF_IPS_V6_DEFAULT_URL), "ipv6"),
+ )
+
+ session = make_session()
+
+ # Download and write data to cache
+ for url, _type in sources:
+ i = 0
+ content = b""
+ try:
+ LOGGER.info(f"Downloading Cloudflare's {_type} list from {url}...")
+ resp = session.get(url, stream=True, timeout=_timeout, allow_redirects=True, verify=True)
+
+ if resp.status_code != 200:
+ LOGGER.warning(f"Got status code {resp.status_code}, skipping {_type} list download...")
+ status = 2
+ continue
+
+ for line in resp.iter_lines():
+ line = line.strip().split(b" ")[0]
+
+ if not line or line.startswith((b"#", b";")):
+ continue
+
+ ok, data = check_line(line)
+ if ok:
+ content += data + b"\n"
+ i += 1
+
+ if not content:
+ LOGGER.warning(f"No valid {_type} IPs/nets found at {url}, skipping...")
+ status = 2
+ continue
+
+ # Check if file has changed
+ new_hash = bytes_hash(content)
+ old_hash = JOB.cache_hash(f"{_type}.list")
+ if new_hash == old_hash:
+ LOGGER.info(f"New {_type}.list file is identical to cache file, reload is not needed")
+ continue
+
+ # Put file in cache
+ cached, err = JOB.cache_file(f"{_type}.list", content, checksum=new_hash)
+ if not cached:
+ LOGGER.error(f"Error while caching {_type} list : {err}")
+ status = 2
+ continue
+
+ LOGGER.info(f"Downloaded {i} trusted {_type} IPs/nets")
+
+ status = status or 1
+ except BaseException as e:
+ status = 2
+ LOGGER.error(f"Exception while getting Cloudflare {_type} list from {url} :\n{e}")
+except SystemExit as e:
+ status = e.code
+except:
+ status = 2
+ LOGGER.exception("Exception while running cf-trusted-ips-download.py")
+
+sys_exit(status)
diff --git a/cloudflare/jobs/cloudflare_helpers.py b/cloudflare/jobs/cloudflare_helpers.py
new file mode 100644
index 0000000..04ea244
--- /dev/null
+++ b/cloudflare/jobs/cloudflare_helpers.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+"""Pure, dependency-free helpers shared by the Cloudflare plugin jobs.
+
+Kept free of any BunkerWeb (`/usr/share/bunkerweb/...`) or third-party imports so the
+logic can be unit-tested with pytest outside the scheduler image (see tests/). The job
+scripts import these and keep all the I/O (requests / Cloudflare SDK / JOB cache) to
+themselves.
+"""
+
+from contextlib import suppress
+from datetime import datetime, timezone
+from ipaddress import ip_address, ip_network
+from os import getenv, sep
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+CF_API_DEFAULT_URL = "https://api.cloudflare.com/client/v4"
+CF_IPS_V4_DEFAULT_URL = "https://www.cloudflare.com/ips-v4/"
+CF_IPS_V6_DEFAULT_URL = "https://www.cloudflare.com/ips-v6/"
+# Static, well-known Cloudflare Authenticated Origin Pull CA (shared across all CF
+# customers — proves "came through Cloudflare", same trust level as IP allowlisting
+# but cryptographic). NOT the Origin CA cert.
+CF_AOP_CA_DEFAULT_URL = "https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem"
+
+
+def get_env_secret(primary: str, fallback: str = "", default: str = "") -> str:
+ """Resolve a secret value, supporting the Docker-secret ``_FILE`` convention.
+
+ For each candidate env name (``primary`` then ``fallback``) we try ``_FILE``
+ (read the file), then ```` (raw value). BunkerWeb's scheduler does not apply
+ the ``_FILE`` convention to job env itself, so the plugin does it here. Returns
+ ``default`` when nothing is set.
+ """
+ for name in (primary, fallback):
+ if not name:
+ continue
+ file_path = getenv(f"{name}_FILE")
+ if file_path:
+ with suppress(OSError):
+ value = Path(file_path).read_text(encoding="utf-8").strip()
+ if value:
+ return value
+ value = getenv(name)
+ if value:
+ return value.strip() if isinstance(value, str) else value
+ return default
+
+
+def read_run_secret(name: str) -> Optional[str]:
+ """Read ``/run/secrets/`` (lowercased) if present, else None."""
+ secret_path = Path(sep, "run", "secrets", name.lower())
+ if secret_path.is_file():
+ with suppress(OSError):
+ return secret_path.read_text(encoding="utf-8").strip()
+ return None
+
+
+def parse_ban_key(key) -> Optional[str]:
+ """Extract the banned IP from a BunkerWeb Redis ban key.
+
+ Global bans use ``bans_ip_``; service bans use ``bans_service__ip_``.
+ Returns the IP string, or None if the key is not a ban key.
+ """
+ if isinstance(key, bytes):
+ key = key.decode("utf-8", "replace")
+ if key.startswith("bans_service_") and "_ip_" in key:
+ return key.rsplit("_ip_", 1)[1] or None
+ if key.startswith("bans_ip_"):
+ return key.removeprefix("bans_ip_") or None
+ return None
+
+
+def check_line(line: bytes) -> Tuple[bool, bytes]:
+ """Validate a single IP / CIDR line from a Cloudflare IP-range list."""
+ with suppress(ValueError):
+ if b"/" in line:
+ ip_network(line.decode())
+ return True, line
+ ip_address(line.decode())
+ return True, line
+ return False, b""
+
+
+def build_csr_config(first_server: str, domains: List[str]) -> str:
+ """Render the OpenSSL CSR config for a service (no Jinja / no template file).
+
+ A plugin's ``templates/`` directory is reserved by BunkerWeb for JSON config
+ templates, so the CSR config is built here in pure Python and cached as ``csr.conf``.
+ Deterministic so the daily job can diff it to detect domain changes.
+ """
+ lines = [
+ "[req]",
+ "default_bits = 2048",
+ "distinguished_name = req_distinguished_name",
+ "req_extensions = req_ext",
+ "prompt = no",
+ "",
+ "[req_distinguished_name]",
+ "C = AU",
+ "ST = Some-State",
+ "O = Internet Widgits Pty Ltd",
+ "OU = IT Department",
+ f"CN = {first_server}",
+ f"emailAddress = contact@{first_server}",
+ "",
+ "[req_ext]",
+ "subjectAltName = @alt_names",
+ "",
+ "[alt_names]",
+ ]
+ for index, domain in enumerate(domains, start=1):
+ lines.append(f"DNS.{index} = {domain}")
+ return "\n".join(lines) + "\n"
+
+
+def request_type_for(cert_type: str) -> str:
+ """Map the plugin's cert type to the Cloudflare Origin CA ``request_type``."""
+ return "origin-ecc" if cert_type == "ecdsa" else "origin-rsa"
+
+
+def select_zone_name(domains: List[str]) -> str:
+ """Pick the most likely zone name to query from a service's domains.
+
+ Strips the first label of multi-label domains to a registrable-ish suffix, then
+ returns the shortest candidate. NOTE: this is public-suffix-naive — for second-level
+ ccTLDs (e.g. ``a.co.uk`` -> ``co.uk``) set ``CLOUDFLARE_ZONE_ID`` explicitly.
+ """
+ wildcards = set()
+ for domain in domains:
+ parts = domain.split(".")
+ if len(parts) > 2:
+ wildcards.add(".".join(parts[1:]))
+ else:
+ wildcards.add(domain)
+ if not wildcards:
+ return ""
+ return min(wildcards, key=len)
+
+
+def select_zone(zones: List[Dict]) -> Optional[Dict]:
+ """From candidate zones, pick the one with the most recent ``modified_on``."""
+ if not zones:
+ return None
+ return max(zones, key=lambda z: z.get("modified_on") or "1970-01-01T00:00:00Z")
+
+
+def parse_expires_on(value) -> datetime:
+ """Parse a Cloudflare ``expires_on`` timestamp into a tz-aware datetime.
+
+ Origin CA returns the Go style ``2024-01-01 00:00:00 +0000 UTC``; we also accept
+ RFC3339 in case the API changes. Falls back to the epoch (treated as expired) for
+ anything non-string (the SDK exposes ``expires_on`` as Optional[str], i.e. may be None).
+ """
+ if not isinstance(value, str):
+ return datetime(1970, 1, 1, tzinfo=timezone.utc)
+ for fmt in ("%Y-%m-%d %H:%M:%S %z %Z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%SZ"):
+ with suppress(ValueError):
+ parsed = datetime.strptime(value, fmt)
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed
+ return datetime(1970, 1, 1, tzinfo=timezone.utc)
+
+
+def is_expired(expires_on: str, now: Optional[datetime] = None) -> bool:
+ """True if the given ``expires_on`` is at or before ``now`` (default: utcnow)."""
+ now = now or datetime.now(timezone.utc)
+ return now >= parse_expires_on(expires_on)
+
+
+def hostnames_match(cert_hostnames: List[str], domains: List[str]) -> bool:
+ """True if a certificate's hostname set exactly matches the service's domains."""
+ return set(cert_hostnames) == set(domains)
+
+
+def find_matching_cert(certs: List[Dict], domains: List[str], now: Optional[datetime] = None) -> Tuple[Optional[str], bool, bool]:
+ """Find an existing Origin CA cert whose hostnames match the service's domains.
+
+ Returns ``(cert_id, found, expired)``. ``cert_id``/``found`` describe the match;
+ ``expired`` is True when the matched cert is past its ``expires_on``.
+ """
+ now = now or datetime.now(timezone.utc)
+ for cert in certs:
+ if hostnames_match(cert.get("hostnames", []), domains):
+ return cert.get("id"), True, is_expired(cert.get("expires_on", ""), now)
+ return None, False, False
diff --git a/cloudflare/plugin.json b/cloudflare/plugin.json
new file mode 100644
index 0000000..b378aee
--- /dev/null
+++ b/cloudflare/plugin.json
@@ -0,0 +1,241 @@
+{
+ "id": "cloudflare",
+ "name": "Cloudflare",
+ "description": "Effortlessly configure Cloudflare's trusted IPs and manage Origin CA certificates.",
+ "version": "1.11",
+ "stream": "partial",
+ "settings": {
+ "USE_CLOUDFLARE": {
+ "context": "multisite",
+ "default": "no",
+ "help": "Activate Cloudflare automations (real IP, trusted-IP allowlisting, Origin CA certificates, mTLS, ...).",
+ "id": "use-cloudflare",
+ "label": "Use Cloudflare automations",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "CLOUDFLARE_API_TOKEN": {
+ "context": "multisite",
+ "default": "",
+ "help": "Cloudflare API token to authenticate with the Cloudflare API.",
+ "id": "cloudflare-api-token",
+ "label": "Cloudflare API token",
+ "regex": "^.*$",
+ "type": "password"
+ },
+ "CLOUDFLARE_ZONE_ID": {
+ "context": "multisite",
+ "default": "",
+ "help": "Cloudflare Zone ID (if no zone ID is provided, the plugin will try to get it from the API).",
+ "id": "cloudflare-zone-id",
+ "label": "Cloudflare Zone ID",
+ "regex": "^.*$",
+ "type": "text"
+ },
+ "CLOUDFLARE_MANAGE_ORIGIN_CERTS": {
+ "context": "multisite",
+ "default": "yes",
+ "help": "Activate automatic management of Origin CA certificates.",
+ "id": "cloudflare-manage-origin-certs",
+ "label": "Automatic management of Origin CA certificates",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "CLOUDFLARE_ORIGIN_CERT_TYPE": {
+ "context": "multisite",
+ "default": "rsa",
+ "help": "Signature type desired on origin CA certificates (\"rsa\", or \"ecdsa\").",
+ "id": "cloudflare-origin-certs-type",
+ "label": "Origin CA certificates type",
+ "regex": "^(rsa|ecdsa)$",
+ "type": "select",
+ "select": ["rsa", "ecdsa"]
+ },
+ "CLOUDFLARE_ORIGIN_CERT_VALIDITY": {
+ "context": "multisite",
+ "default": "5475",
+ "help": "Validity period of origin CA certificates in days.",
+ "id": "cloudflare-origin-certs-validity",
+ "label": "Origin CA certificates validity",
+ "regex": "^(7|30|90|365|730|1095|5475)$",
+ "type": "select",
+ "select": ["7", "30", "90", "365", "730", "1095", "5475"]
+ },
+ "CLOUDFLARE_ADDITIONAL_TRUSTED_FROM": {
+ "context": "multisite",
+ "default": "",
+ "help": "Additional IPs/networks to consider as trusted, separated with spaces (CIDR notation).",
+ "id": "cloudflare-additional-trusted-ips",
+ "label": "Additional trusted IPs",
+ "regex": "^(?! )( *(((\\b25[0-5]|\\b2[0-4]\\d|\\b[01]?\\d\\d?)(\\.(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)){3})(\\/([1-2][0-9]?|3[0-2]?|[04-9]))?|(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{1,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?\\d)?\\d)\\.){3}(25[0-5]|(2[0-4]|1?\\d)?\\d)|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?\\d)?\\d)\\.){3}(25[0-5]|(2[0-4]|1?\\d)?\\d))(\\/(12[0-8]|1[01][0-9]|[0-9][0-9]?))?)(?!.*\\D\\2([^\\d\\/]|$)) *)*$",
+ "type": "multivalue",
+ "separator": " "
+ },
+ "CLOUDFLARE_DENY_NON_TRUSTED_IPS": {
+ "context": "multisite",
+ "default": "no",
+ "help": "Deny access to non-trusted IPs (the ones not in Cloudflare's official list and the additional trusted IPs).",
+ "id": "cloudflare-deny-non-trusted-ips",
+ "label": "Deny non-trusted IPs",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "CLOUDFLARE_API_URL": {
+ "context": "global",
+ "default": "https://api.cloudflare.com/client/v4",
+ "help": "Base URL of the Cloudflare API (advanced; for a Cloudflare-compatible/proxied endpoint or testing).",
+ "id": "cloudflare-api-url",
+ "label": "Cloudflare API URL",
+ "regex": "^https?://.*$",
+ "type": "text"
+ },
+ "CLOUDFLARE_API_TIMEOUT": {
+ "context": "global",
+ "default": "10",
+ "help": "Timeout in seconds for Cloudflare API requests.",
+ "id": "cloudflare-api-timeout",
+ "label": "Cloudflare API timeout (s)",
+ "regex": "^[0-9]+$",
+ "type": "number"
+ },
+ "CLOUDFLARE_IPS_V4_URL": {
+ "context": "global",
+ "default": "https://www.cloudflare.com/ips-v4/",
+ "help": "URL to download Cloudflare's IPv4 ranges from (advanced/testing).",
+ "id": "cloudflare-ips-v4-url",
+ "label": "Cloudflare IPv4 list URL",
+ "regex": "^https?://.*$",
+ "type": "text"
+ },
+ "CLOUDFLARE_IPS_V6_URL": {
+ "context": "global",
+ "default": "https://www.cloudflare.com/ips-v6/",
+ "help": "URL to download Cloudflare's IPv6 ranges from (advanced/testing).",
+ "id": "cloudflare-ips-v6-url",
+ "label": "Cloudflare IPv6 list URL",
+ "regex": "^https?://.*$",
+ "type": "text"
+ },
+ "CLOUDFLARE_AUTO_REAL_IP": {
+ "context": "multisite",
+ "default": "yes",
+ "help": "Automatically configure NGINX real_ip_header/real_ip_recursive for Cloudflare. Disable if the core Real IP plugin (USE_REAL_IP) already manages them to avoid duplicate directives.",
+ "id": "cloudflare-auto-real-ip",
+ "label": "Auto-configure real IP",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "CLOUDFLARE_REAL_IP_HEADER": {
+ "context": "multisite",
+ "default": "CF-Connecting-IP",
+ "help": "Header carrying the real client IP. CF-Connecting-IP (default), True-Client-IP (Enterprise alias) or CF-Connecting-IPv6 (when Pseudo-IPv4 is enabled).",
+ "id": "cloudflare-real-ip-header",
+ "label": "Real IP header",
+ "regex": "^(CF-Connecting-IP|True-Client-IP|CF-Connecting-IPv6)$",
+ "type": "select",
+ "select": ["CF-Connecting-IP", "True-Client-IP", "CF-Connecting-IPv6"]
+ },
+ "CLOUDFLARE_STRIP_SPOOFED_HEADERS": {
+ "context": "multisite",
+ "default": "yes",
+ "help": "Strip client-supplied CF-* headers (CF-Connecting-IP, CF-IPCountry, CF-RAY, True-Client-IP, ...) when the connection is not from a trusted Cloudflare IP, to prevent spoofing.",
+ "id": "cloudflare-strip-spoofed-headers",
+ "label": "Strip spoofed Cloudflare headers",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "CLOUDFLARE_AUTHENTICATED_ORIGIN_PULLS": {
+ "context": "multisite",
+ "default": "no",
+ "help": "Require Cloudflare Authenticated Origin Pulls (mTLS): verify the connection presents Cloudflare's origin-pull client certificate. Mutually exclusive with the core mTLS plugin on the same server.",
+ "id": "cloudflare-authenticated-origin-pulls",
+ "label": "Authenticated Origin Pulls (mTLS)",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "CLOUDFLARE_AOP_MODE": {
+ "context": "multisite",
+ "default": "log",
+ "help": "Authenticated Origin Pulls enforcement: 'log' only warns on connections without a valid Cloudflare client certificate, 'enforce' denies them.",
+ "id": "cloudflare-aop-mode",
+ "label": "Authenticated Origin Pulls mode",
+ "regex": "^(log|enforce)$",
+ "type": "select",
+ "select": ["log", "enforce"]
+ },
+ "CLOUDFLARE_AOP_CA_URL": {
+ "context": "global",
+ "default": "https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem",
+ "help": "URL of the Cloudflare Authenticated Origin Pull CA certificate (advanced/testing).",
+ "id": "cloudflare-aop-ca-url",
+ "label": "Authenticated Origin Pull CA URL",
+ "regex": "^https?://.*$",
+ "type": "text"
+ },
+ "USE_CLOUDFLARE_EDGE_BAN_SYNC": {
+ "context": "global",
+ "default": "no",
+ "help": "Push BunkerWeb's active bans to a Cloudflare account IP List. Reference that list from a Cloudflare WAF custom rule to block the offenders at the edge (the plugin syncs the list, it does not create the rule). Requires USE_REDIS=yes and an account-scoped API token (Account Filter Lists:Edit).",
+ "id": "use-cloudflare-edge-ban-sync",
+ "label": "Sync bans to Cloudflare edge",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "CLOUDFLARE_ACCOUNT_ID": {
+ "context": "global",
+ "default": "",
+ "help": "Cloudflare Account ID owning the edge ban IP List.",
+ "id": "cloudflare-account-id",
+ "label": "Cloudflare Account ID",
+ "regex": "^[a-zA-Z0-9]*$",
+ "type": "text"
+ },
+ "CLOUDFLARE_BAN_LIST_NAME": {
+ "context": "global",
+ "default": "bunkerweb_bans",
+ "help": "Name of the Cloudflare account IP List used for edge ban sync (lowercase letters, digits and underscores).",
+ "id": "cloudflare-ban-list-name",
+ "label": "Edge ban IP List name",
+ "regex": "^[a-z0-9_]{1,50}$",
+ "type": "text"
+ },
+ "CLOUDFLARE_EDGE_BAN_API_TOKEN": {
+ "context": "global",
+ "default": "",
+ "help": "Account-scoped API token (Account Filter Lists:Edit) for edge ban sync. Falls back to CLOUDFLARE_API_TOKEN if empty.",
+ "id": "cloudflare-edge-ban-api-token",
+ "label": "Edge ban API token",
+ "regex": "^.*$",
+ "type": "password"
+ }
+ },
+ "jobs": [
+ {
+ "name": "cf-trusted-ips-download",
+ "file": "cf-trusted-ips-download.py",
+ "every": "day",
+ "reload": true,
+ "async": true
+ },
+ {
+ "name": "cf-manage-origin-certs",
+ "file": "cf-manage-origin-certs.py",
+ "every": "day",
+ "reload": true
+ },
+ {
+ "name": "cf-aop-ca-download",
+ "file": "cf-aop-ca-download.py",
+ "every": "week",
+ "reload": true,
+ "async": true
+ },
+ {
+ "name": "cf-edge-ban-sync",
+ "file": "cf-edge-ban-sync.py",
+ "every": "minute",
+ "reload": false,
+ "async": true
+ }
+ ]
+}
diff --git a/cloudflare/ui/actions.py b/cloudflare/ui/actions.py
new file mode 100644
index 0000000..751c365
--- /dev/null
+++ b/cloudflare/ui/actions.py
@@ -0,0 +1,33 @@
+from logging import getLogger
+from traceback import format_exc
+
+
+def pre_render(**kwargs):
+ """Build the Cloudflare plugin status card shown on the BunkerWeb web UI.
+
+ Reflects the result of the Lua `api()` ping (`POST /cloudflare/ping`), which reports
+ that the plugin is up and how many Cloudflare trusted ranges are loaded.
+ """
+ logger = getLogger("UI")
+ ret = {
+ "ping_status": {
+ "title": "CLOUDFLARE STATUS",
+ "value": "error",
+ "col-size": "col-12 col-md-6",
+ "card-classes": "h-100",
+ },
+ }
+ try:
+ ping_data = kwargs["bw_instances_utils"].get_ping("cloudflare")
+ ret["ping_status"]["value"] = ping_data["status"]
+ except BaseException as e:
+ logger.debug(format_exc())
+ logger.error(f"Failed to get cloudflare ping: {e}")
+ # Never surface the raw exception (it may contain internal URLs / details).
+ ret["error"] = "Could not retrieve the plugin status"
+
+ return ret
+
+
+def cloudflare(**kwargs):
+ pass
diff --git a/coraza/README.md b/coraza/README.md
index 6242bce..58380ad 100644
--- a/coraza/README.md
+++ b/coraza/README.md
@@ -1,67 +1,208 @@
# Coraza plugin
-
-
-
+
+
+```mermaid
+flowchart TD
+ accTitle: BunkerWeb Coraza plugin request flow
+ accDescr: A client request first passes BunkerWeb core checks, then coraza.lua forwards the request metadata and body as JSON over HTTP to the Coraza Go sidecar. The sidecar evaluates the OWASP Core Rule Set and returns a verdict. A disrupted verdict denies the request, otherwise it reaches the upstream.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb access phase]
+ direction TB
+ core["1. Core checks first: rate limit, bad behavior, antibot, DNSBL, black / whitelist"]
+ lua["2. coraza.lua: read body, build X-Coraza-* JSON, POST CORAZA_API + /request"]
+ core --> lua
+ end
+
+ subgraph sidecar[Coraza Go service - coraza/api]
+ direction TB
+ api[["HTTP API: /ping (health), /request"]]
+ crs[("OWASP CRS vendored at build")]
+ api --- crs
+ end
+
+ verdict{"disrupted?"}
+ allow["Allow to upstream"]
+ deny["Deny request (get_deny_status)"]
+ upstream([Upstream app])
+
+ client -->|request| core
+ lua -.->|"request JSON over HTTP"| api
+ api -.->|verdict| verdict
+ verdict -->|"yes (rule matched)"| deny
+ verdict -->|no| allow
+ allow --> upstream
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class allow,upstream ok;
+ class deny deny;
+ class api,crs svc;
+ class client,core,lua app;
+```
-This [Plugin](https://www.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) will act as a Library of rule that aim to detect and deny malicious requests
+This [plugin](https://www.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+runs the [Coraza](https://coraza.io/) web application firewall - a Go
+reimplementation of the ModSecurity engine - loaded with the
+[OWASP Core Rule Set](https://coreruleset.org/) (CRS), to detect and block
+malicious requests before they reach the upstream. Unlike every other plugin
+in this repository, Coraza relies on an **external sidecar**: a standalone Go
+HTTP service (the `bunkerity/bunkerweb-coraza` image, built from `coraza/api/`)
+that wraps `corazawaf/coraza/v3` and bakes the CRS in at build time.
+
+The inspection runs from Lua during BunkerWeb's access phase, so all of
+BunkerWeb's built-in checks (rate limit, bad behavior, antibot, DNSBL,
+whitelist / blacklist, ...) run _before_ the request is handed to Coraza.
+For each request `coraza.lua` reads the full body, builds a set of
+`X-Coraza-*` metadata headers, and `POST`s the request to the sidecar's
+`/request` endpoint; the sidecar evaluates the request headers and body
+against the CRS and returns a `{"deny": bool, "msg": string}` verdict. A
+disrupting verdict denies the request with BunkerWeb's deny status.
# Table of contents
- [Coraza plugin](#coraza-plugin)
- [Table of contents](#table-of-contents)
+- [How it works](#how-it-works)
+- [Prerequisites](#prerequisites)
- [Setup](#setup)
- - [Docker/Swarm](#dockerswarm)
+ - [Docker / Swarm](#docker--swarm)
- [Settings](#settings)
-- [TODO](#todo)
+- [Troubleshooting](#troubleshooting)
+- [Notes](#notes)
+
+# How it works
+
+1. BunkerWeb's access-phase checks run (rate limit, bad behavior, antibot,
+ DNSBL, blacklist, ...). If any of them deny, the request stops here and
+ Coraza is never consulted.
+2. At worker startup, `init_worker` sends `GET /ping` as a health
+ check. If the sidecar does not answer with a valid `pong` JSON, the failure
+ is logged.
+3. On each request, `coraza.lua` reads the full request body and builds the
+ metadata headers `X-Coraza-Version`, `X-Coraza-Method`, `X-Coraza-Ip`,
+ `X-Coraza-Id` (a random transaction id) and `X-Coraza-Uri`, plus every
+ incoming request header re-emitted as `X-Coraza-Header-`. It then
+ `POST`s the body to `/request`.
+4. The sidecar opens a Coraza transaction and evaluates two phases - first the
+ request headers, then the request body - against, in order, `coraza.conf`,
+ `bunkerweb.conf`, `/rules-before/*.conf`, the CRS (`crs-setup.conf.example`
+ then `rules/*.conf`), and `/rules-after/*.conf`. It replies with
+ `{"deny": bool, "msg": string}`.
+5. If the verdict is `deny: true` (a rule triggered a disrupting action -
+ `block`, `deny`, `drop`, `redirect` or `reject`), the request is denied with
+ `utils.get_deny_status()` and the rule message is attached. Otherwise the
+ request continues to its normal destination.
+6. If the sidecar is unreachable or returns a non-`200` status, the request is
+ denied with HTTP `500` and the error is logged - Coraza **fails closed**.
+
+# Prerequisites
+
+The Coraza sidecar must be deployed and reachable from BunkerWeb at the URL
+configured in `CORAZA_API`. Use the official `bunkerity/bunkerweb-coraza`
+image and attach the sidecar to a network shared with the BunkerWeb
+container (the examples below use a dedicated `bw-plugins` network). The
+sidecar listens on port `8080` and bundles the OWASP CRS, so no separate rule
+download is needed.
# 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.
+See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+of the BunkerWeb documentation for the generic plugin installation procedure
+(the short version: drop the `coraza/` directory into the scheduler's
+`/data/plugins/` and restart).
+
+## Docker / Swarm
-## Docker/Swarm
+`CORAZA_API` is the URL BunkerWeb uses to reach the sidecar - typically an
+internal Docker network address. Keep `HTTP2` disabled and the core
+ModSecurity WAF (`USE_MODSECURITY`) off so you don't run two WAFs at once.
```yaml
services:
bunkerweb:
- image: bunkerity/bunkerweb:1.6.0-rc1
+ image: bunkerity/bunkerweb:1.6.11
...
networks:
+ - bw-services
- bw-plugins
...
bw-scheduler:
- image: bunkerity/bunkerweb-scheduler:1.6.0-rc1
+ image: bunkerity/bunkerweb-scheduler:1.6.11
...
environment:
- HTTP2: "no" # The Coraza plugin doesn't support HTTP2 yet
- USE_MODSECURITY: "no" # We don't need ModSecurity anymore
- USE_CORAZA: "yes"
- CORAZA_API: "http://bw-coraza:8080" # This is the address of the coraza container in the same network
+ SERVER_NAME: "app.example.com"
+ USE_REVERSE_PROXY: "yes"
+ REVERSE_PROXY_HOST: "http://app:3000"
+ REVERSE_PROXY_URL: "/"
- ...
+ HTTP2: "no" # The Coraza plugin does not support HTTP/2 yet
+ USE_MODSECURITY: "no" # Run Coraza instead of the core ModSecurity WAF
+ USE_CORAZA: "yes"
+ # Internal URL - what BunkerWeb uses to call the Coraza sidecar:
+ CORAZA_API: "http://bw-coraza:8080"
bw-coraza:
- image: bunkerity/bunkerweb-coraza:1.6.0-rc1
+ image: bunkerity/bunkerweb-coraza:1.6.11
networks:
- bw-plugins
networks:
- # BunkerWeb networks
- ...
+ bw-services:
+ name: bw-services
bw-plugins:
name: bw-plugins
```
# Settings
-| Setting | Default | Context | Multiple | Description |
-| ------------ | ----------------------- | --------- | -------- | --------------------------- |
-| `USE_CORAZA` | `no` | multisite | no | Activate Coraza library |
-| `CORAZA_API` | `http://bw-coraza:8080` | global | no | hostname of the CORAZA API. |
-
-# TODO
-
-- Don't use API container
-- More documentation
+| Setting | Default | Context | Multiple | Description |
+| ------------ | ----------------------- | --------- | -------- | -------------------------------------------------------------------------------------- |
+| `USE_CORAZA` | `no` | multisite | no | Activate the Coraza WAF (OWASP Core Rule Set evaluation) for this site. |
+| `CORAZA_API` | `http://bw-coraza:8080` | global | no | Base URL (scheme + host + port) of the Coraza WAF sidecar, e.g. http://bw-coraza:8080. |
+
+# Troubleshooting
+
+- **Every request returns HTTP 500.** The sidecar is unreachable or answering
+ with a non-`200` status. Coraza fails closed, so a down sidecar blocks all
+ traffic. Check that `bw-coraza` is running, on the shared network, and that
+ `CORAZA_API` points at it (scheme + host + port `8080`). The scheduler log
+ shows the underlying error.
+- **HTTP/2 sites misbehave.** The plugin does not support HTTP/2 yet; keep
+ `HTTP2: "no"`.
+- **Requests are inspected twice / unexpected WAF blocks.** You are likely
+ running both Coraza and the core ModSecurity WAF. Set `USE_MODSECURITY: "no"`
+ so only Coraza evaluates the request.
+- **A legitimate request is blocked by a CRS rule.** Add your own rule
+ overrides (e.g. `SecRuleRemoveById`, exclusions, paranoia-level tuning) as
+ `.conf` files and mount them into the sidecar at `/rules-before/` (evaluated
+ before the CRS) or `/rules-after/` (evaluated after it). The rule message in
+ the deny reason identifies which rule fired.
+- **CRS feels outdated.** The Core Rule Set is pinned and baked into the image
+ at build time. Updating it means rebuilding `bunkerity/bunkerweb-coraza`, not
+ restarting BunkerWeb (see Notes).
+
+# Notes
+
+- **External sidecar required.** This is the only plugin that depends on a
+ separate service. The `bunkerity/bunkerweb-coraza` container must be deployed
+ alongside BunkerWeb and reachable at `CORAZA_API`. There is no in-process
+ fallback.
+- **Fail-closed by design.** If the sidecar cannot be reached or returns a
+ non-`200` response, the request is denied (HTTP `500`). This avoids letting
+ traffic through unscanned, at the cost of coupling availability to the
+ sidecar - keep it healthy and close to BunkerWeb (same Docker network is
+ ideal).
+- **CRS is pinned and baked into the image.** The OWASP Core Rule Set is
+ vendored at build time by `coraza/api/crs.sh`, pinned to a commit hash with
+ its `.git` stripped, and compiled into the image. Bumping the CRS version
+ means bumping that hash and rebuilding `bunkerity/bunkerweb-coraza` - a plain
+ BunkerWeb restart will not pick up a newer rule set.
+- **HTTP/2 not yet supported.** Disable HTTP/2 on protected sites
+ (`HTTP2: "no"`) while using this plugin.
diff --git a/coraza/api/Dockerfile b/coraza/api/Dockerfile
index e17e9ae..dd35183 100644
--- a/coraza/api/Dockerfile
+++ b/coraza/api/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.23-alpine@sha256:04ec5618ca64098b8325e064aa1de2d3efbbd022a3ac5554d49d5ece99d41ad5 AS builder
+FROM golang:1.26-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648 AS builder
WORKDIR /usr/src/app
@@ -11,7 +11,7 @@ COPY --chmod=644 crs.sh .
RUN apk add bash git && \
bash crs.sh Download
-FROM golang:1.23-alpine@sha256:04ec5618ca64098b8325e064aa1de2d3efbbd022a3ac5554d49d5ece99d41ad5
+FROM golang:1.26-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648
COPY --from=builder --chown=0:0 /usr/local/bin/bw-coraza /usr/local/bin/bw-coraza
diff --git a/coraza/api/crs.sh b/coraza/api/crs.sh
index 7248b5a..4575f5c 100755
--- a/coraza/api/crs.sh
+++ b/coraza/api/crs.sh
@@ -66,7 +66,7 @@ echo "ℹ️ Download CRS or Remove CRS"
if [[ "$1" == "Remove" ]]; then
remove_coreruleset
elif [[ "$1" == "Download" ]]; then
- git_secure_clone "https://github.com/coreruleset/coreruleset.git" "23196d6a8b3ee2b668bbc26750954501342bfee4" # v4.10.0
+ git_secure_clone "https://github.com/coreruleset/coreruleset.git" "aabf675fcfc5e489b424b844e6c9f1b39802df69" # v4.25.0
else
echo "❌ Error wrong argument : $1 try Remove or Download"
fi
diff --git a/coraza/api/go.mod b/coraza/api/go.mod
index 19d91f2..495a2d2 100644
--- a/coraza/api/go.mod
+++ b/coraza/api/go.mod
@@ -1,22 +1,31 @@
module bw-coraza
-go 1.23
+go 1.25.0
require (
- github.com/corazawaf/coraza/v3 v3.3.2
+ github.com/corazawaf/coraza/v3 v3.7.0
github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1
)
require (
- github.com/corazawaf/libinjection-go v0.2.2 // indirect
+ github.com/corazawaf/libinjection-go v0.3.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
- github.com/magefile/mage v1.15.0 // indirect
- github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/goccy/go-yaml v1.18.0 // indirect
+ github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
+ github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect
+ github.com/kaptinlin/go-i18n v0.1.4 // indirect
+ github.com/kaptinlin/jsonschema v0.4.6 // indirect
+ github.com/magefile/mage v1.17.0 // indirect
+ github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
- golang.org/x/net v0.34.0 // indirect
- golang.org/x/sync v0.10.0 // indirect
+ github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
+ golang.org/x/net v0.52.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
rsc.io/binaryregexp v0.2.0 // indirect
)
diff --git a/coraza/api/main.go b/coraza/api/main.go
index 37ba27b..e3e9837 100644
--- a/coraza/api/main.go
+++ b/coraza/api/main.go
@@ -18,9 +18,9 @@ import (
)
var (
- InfoLogger *log.Logger
- WarningLogger *log.Logger
- ErrorLogger *log.Logger
+ InfoLogger = log.New(os.Stdout, "INFO: ", log.LstdFlags)
+ WarningLogger = log.New(os.Stdout, "WARNING: ", log.LstdFlags)
+ ErrorLogger = log.New(os.Stdout, "ERROR: ", log.LstdFlags)
)
type Pong struct {
@@ -42,7 +42,7 @@ func processInterruption(w http.ResponseWriter, tx types.Transaction, it *types.
for _, rule := range rules {
if rule.Message() != "" {
- WarningLogger.Printf(rule.AuditLog())
+ WarningLogger.Printf("%s", rule.AuditLog())
}
}
@@ -194,9 +194,6 @@ func loggingMiddleware(next http.Handler) http.Handler {
}
func main() {
- InfoLogger = log.New(os.Stdout, "INFO: ", log.LstdFlags)
- WarningLogger = log.New(os.Stdout, "WARNING: ", log.LstdFlags)
- ErrorLogger = log.New(os.Stdout, "ERROR: ", log.LstdFlags)
var err error
waf, err = coraza.NewWAF(
coraza.NewWAFConfig().
diff --git a/coraza/api/main_test.go b/coraza/api/main_test.go
new file mode 100644
index 0000000..9e313d4
--- /dev/null
+++ b/coraza/api/main_test.go
@@ -0,0 +1,194 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/corazawaf/coraza/v3"
+)
+
+// errReader fails on the first Read, to exercise the body-read error path.
+type errReader struct{}
+
+func (errReader) Read([]byte) (int, error) { return 0, fmt.Errorf("forced read error") }
+
+// newTestWAF builds a self-contained WAF from inline directives so tests never
+// depend on the vendored coreruleset/ (gitignored, only present after a Docker
+// build).
+func newTestWAF(t *testing.T, directives string) coraza.WAF {
+ t.Helper()
+ w, err := coraza.NewWAF(coraza.NewWAFConfig().WithDirectives(directives))
+ if err != nil {
+ t.Fatalf("failed to build test WAF: %v", err)
+ }
+ return w
+}
+
+func corazaHeaders(req *http.Request, id, method, uri string) {
+ req.Header.Set("X-Coraza-Version", "HTTP/1.1")
+ req.Header.Set("X-Coraza-Method", method)
+ req.Header.Set("X-Coraza-Ip", "127.0.0.1")
+ req.Header.Set("X-Coraza-Id", id)
+ req.Header.Set("X-Coraza-Uri", uri)
+}
+
+func decodeResp(t *testing.T, rec *httptest.ResponseRecorder) Resp {
+ t.Helper()
+ var resp Resp
+ if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
+ t.Fatalf("invalid JSON response %q: %v", rec.Body.String(), err)
+ }
+ return resp
+}
+
+const argsRule = `
+SecRuleEngine On
+SecRequestBodyAccess On
+SecRule ARGS "@contains attackpattern" "id:1,phase:2,deny,status:403,msg:'args rule'"
+`
+
+const headerRule = `
+SecRuleEngine On
+SecRule REQUEST_HEADERS:X-Test "@streq bad" "id:2,phase:1,deny,status:403,msg:'header rule'"
+`
+
+const bodyRule = `
+SecRuleEngine On
+SecRequestBodyAccess On
+SecRule ARGS_POST:payload "@contains attackpattern" "id:3,phase:2,deny,status:403,msg:'body rule'"
+`
+
+func TestHandlePing(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/ping", nil)
+ rec := httptest.NewRecorder()
+ handlePing(rec, req)
+
+ var pong Pong
+ if err := json.NewDecoder(rec.Body).Decode(&pong); err != nil {
+ t.Fatalf("invalid JSON response %q: %v", rec.Body.String(), err)
+ }
+ if pong.Pong != "ok" {
+ t.Fatalf("expected pong=ok, got %q", pong.Pong)
+ }
+}
+
+func TestHandleRequest_Benign(t *testing.T) {
+ waf = newTestWAF(t, argsRule)
+ req := httptest.NewRequest(http.MethodGet, "/request", nil)
+ corazaHeaders(req, "benign", "GET", "/?q=hello")
+ rec := httptest.NewRecorder()
+ handleRequest(rec, req)
+
+ resp := decodeResp(t, rec)
+ if resp.Deny {
+ t.Fatalf("expected deny=false for benign request, got %+v", resp)
+ }
+}
+
+func TestHandleRequest_ArgsDeny(t *testing.T) {
+ waf = newTestWAF(t, argsRule)
+ req := httptest.NewRequest(http.MethodGet, "/request", nil)
+ corazaHeaders(req, "args-deny", "GET", "/?q=attackpattern")
+ rec := httptest.NewRecorder()
+ handleRequest(rec, req)
+
+ resp := decodeResp(t, rec)
+ if !resp.Deny {
+ t.Fatalf("expected deny=true for malicious args, got %+v", resp)
+ }
+ if !strings.Contains(resp.Msg, "rule ID 1") {
+ t.Fatalf("expected msg to mention rule ID 1, got %q", resp.Msg)
+ }
+}
+
+func TestHandleRequest_HeaderDeny(t *testing.T) {
+ waf = newTestWAF(t, headerRule)
+ req := httptest.NewRequest(http.MethodGet, "/request", nil)
+ corazaHeaders(req, "header-deny", "GET", "/")
+ // X-Coraza-Header-* are stripped of the prefix and added as request headers.
+ req.Header.Set("X-Coraza-Header-X-Test", "bad")
+ rec := httptest.NewRecorder()
+ handleRequest(rec, req)
+
+ resp := decodeResp(t, rec)
+ if !resp.Deny {
+ t.Fatalf("expected deny=true for phase-1 header match, got %+v", resp)
+ }
+}
+
+func TestHandleRequest_BodyDeny(t *testing.T) {
+ waf = newTestWAF(t, bodyRule)
+ req := httptest.NewRequest(http.MethodPost, "/request", strings.NewReader("payload=attackpattern"))
+ corazaHeaders(req, "body-deny", "POST", "/")
+ // BunkerWeb forwards the real request headers prefixed with X-Coraza-Header-;
+ // coraza needs the Content-Type to pick the urlencoded body processor.
+ req.Header.Set("X-Coraza-Header-Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handleRequest(rec, req)
+
+ resp := decodeResp(t, rec)
+ if !resp.Deny {
+ t.Fatalf("expected deny=true for malicious body, got %+v", resp)
+ }
+}
+
+func TestHandleRequest_RuleEngineOff(t *testing.T) {
+ waf = newTestWAF(t, "SecRuleEngine Off")
+ req := httptest.NewRequest(http.MethodGet, "/request", nil)
+ corazaHeaders(req, "engine-off", "GET", "/?q=attackpattern")
+ rec := httptest.NewRecorder()
+ handleRequest(rec, req)
+
+ resp := decodeResp(t, rec)
+ if resp.Deny {
+ t.Fatalf("expected deny=false when rule engine off, got %+v", resp)
+ }
+ if resp.Msg != "rule engine is set to off" {
+ t.Fatalf("expected rule-engine-off message, got %q", resp.Msg)
+ }
+}
+
+func TestHandleRequest_BodyReadError(t *testing.T) {
+ waf = newTestWAF(t, "SecRuleEngine On\nSecRequestBodyAccess On")
+ req := httptest.NewRequest(http.MethodPost, "/request", errReader{})
+ corazaHeaders(req, "body-error", "POST", "/")
+ req.Header.Set("X-Coraza-Header-Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handleRequest(rec, req)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("expected 500 on body read error, got %d (%q)", rec.Code, rec.Body.String())
+ }
+}
+
+const limitRule = `
+SecRuleEngine On
+SecRequestBodyAccess On
+SecRequestBodyLimit 128
+SecRequestBodyLimitAction Reject
+`
+
+func TestHandleRequest_OversizedBody(t *testing.T) {
+ waf = newTestWAF(t, limitRule)
+ big := strings.Repeat("A", 4096)
+ req := httptest.NewRequest(http.MethodPost, "/request", strings.NewReader("payload="+big))
+ corazaHeaders(req, "oversized", "POST", "/")
+ req.Header.Set("X-Coraza-Header-Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handleRequest(rec, req)
+
+ // Must not panic and must return a well-formed result (deny or 500), never a
+ // silent pass of an over-limit body.
+ if rec.Code == http.StatusOK {
+ resp := decodeResp(t, rec)
+ if !resp.Deny {
+ t.Fatalf("expected an over-limit body to be denied, got %+v", resp)
+ }
+ } else if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("expected 200(deny) or 500 for oversized body, got %d", rec.Code)
+ }
+}
diff --git a/coraza/docs/diagram.drawio b/coraza/docs/diagram.drawio
deleted file mode 100644
index b945cca..0000000
--- a/coraza/docs/diagram.drawio
+++ /dev/null
@@ -1,70 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/coraza/docs/diagram.mmd b/coraza/docs/diagram.mmd
new file mode 100644
index 0000000..f446be8
--- /dev/null
+++ b/coraza/docs/diagram.mmd
@@ -0,0 +1,40 @@
+flowchart TD
+ accTitle: BunkerWeb Coraza plugin request flow
+ accDescr: A client request first passes BunkerWeb core checks, then coraza.lua forwards the request metadata and body as JSON over HTTP to the Coraza Go sidecar. The sidecar evaluates the OWASP Core Rule Set and returns a verdict. A disrupted verdict denies the request, otherwise it reaches the upstream.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb access phase]
+ direction TB
+ core["1. Core checks first: rate limit, bad behavior, antibot, DNSBL, black / whitelist"]
+ lua["2. coraza.lua: read body, build X-Coraza-* JSON, POST CORAZA_API + /request"]
+ core --> lua
+ end
+
+ subgraph sidecar[Coraza Go service - coraza/api]
+ direction TB
+ api[["HTTP API: /ping (health), /request"]]
+ crs[("OWASP CRS vendored at build")]
+ api --- crs
+ end
+
+ verdict{"disrupted?"}
+ allow["Allow to upstream"]
+ deny["Deny request (get_deny_status)"]
+ upstream([Upstream app])
+
+ client -->|request| core
+ lua -.->|"request JSON over HTTP"| api
+ api -.->|verdict| verdict
+ verdict -->|"yes (rule matched)"| deny
+ verdict -->|no| allow
+ allow --> upstream
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class allow,upstream ok;
+ class deny deny;
+ class api,crs svc;
+ class client,core,lua app;
diff --git a/coraza/docs/diagram.svg b/coraza/docs/diagram.svg
deleted file mode 100644
index b34b0a1..0000000
--- a/coraza/docs/diagram.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/coraza/plugin.json b/coraza/plugin.json
index afaf418..4982134 100755
--- a/coraza/plugin.json
+++ b/coraza/plugin.json
@@ -1,14 +1,14 @@
{
"id": "coraza",
"name": "Coraza",
- "description": "Use Coraza as a library to inspect client request.",
- "version": "1.10",
+ "description": "Inspect and block malicious requests with the Coraza WAF engine running the OWASP Core Rule Set.",
+ "version": "1.11",
"stream": "no",
"settings": {
"USE_CORAZA": {
"context": "multisite",
"default": "no",
- "help": "Activate Coraza library",
+ "help": "Activate the Coraza WAF (OWASP Core Rule Set evaluation) for this site.",
"id": "use coraza library",
"label": "Use coraza",
"regex": "^(no|yes)$",
@@ -17,7 +17,7 @@
"CORAZA_API": {
"context": "global",
"default": "http://bw-coraza:8080",
- "help": "hostname of the CORAZA API.",
+ "help": "Base URL (scheme + host + port) of the Coraza WAF sidecar, e.g. http://bw-coraza:8080.",
"id": "coraza-api",
"label": "Coraza Api",
"regex": "^.*$",
diff --git a/coraza/ui/actions.py b/coraza/ui/actions.py
index e021797..3a6eeeb 100644
--- a/coraza/ui/actions.py
+++ b/coraza/ui/actions.py
@@ -18,10 +18,7 @@ def pre_render(**kwargs):
except BaseException as e:
logger.debug(format_exc())
logger.error(f"Failed to get coraza ping: {e}")
- ret["error"] = str(e)
-
- if "error" in ret:
- return ret
+ ret["error"] = "Could not retrieve the plugin status"
return ret
diff --git a/discord/README.md b/discord/README.md
index 0d20b18..61a2f74 100644
--- a/discord/README.md
+++ b/discord/README.md
@@ -1,61 +1,130 @@
# Discord plugin
-
-
-
+
+
+```mermaid
+flowchart TD
+ accTitle: BunkerWeb Discord plugin notification flow
+ accDescr: The plugin does not block traffic. When BunkerWeb denies a request, discord.lua runs on the log phase, builds a JSON embed, and schedules an async ngx.timer so the HTTP POST to the Discord webhook happens after the response, leaving request latency unaffected. A 429 rate-limit response is retried after its Retry-After delay.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb]
+ direction TB
+ decision{"Request denied?"}
+ log["discord.lua (log phase): build JSON embed (request, reason, headers)"]
+ timer["ngx.timer.at(0): async, after response"]
+ decision -->|yes| log --> timer
+ end
+
+ discord[["Discord webhook DISCORD_WEBHOOK_URL"]]
+ served([Response already returned to client])
+
+ client -->|request| decision
+ decision -->|no| served
+ timer -.->|"HTTP POST (async)"| discord
+ discord -.->|"429 -> retry after Retry-After"| timer
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class served ok;
+ class log,timer deny;
+ class discord svc;
+ class client,decision app;
+```
+
+This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github)
+plugin sends an attack notification to a Discord channel of your choice through
+a webhook. It is a notifier only: it never blocks, delays, or alters traffic -
+the decision to deny a request is made entirely by BunkerWeb's other security
+features, and this plugin merely reports on it.
-This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github) plugin will automatically send you attack notifications on a Discord channel of your choice using a webhook.
+The handler runs on BunkerWeb's `log` phase, after the response has been
+returned to the client. It only fires for requests that another plugin has
+already denied (it reads the denial reason from BunkerWeb's request context and
+returns immediately when there is none). The webhook `POST` itself is dispatched
+from an `ngx.timer.at(0, ...)` callback, so it happens off the request path and
+adds no latency to the client.
# Table of contents
- [Discord plugin](#discord-plugin)
- [Table of contents](#table-of-contents)
+- [How it works](#how-it-works)
- [Prerequisites](#prerequisites)
- [Setup](#setup)
- [Docker](#docker)
- [Swarm](#swarm)
- [Kubernetes](#kubernetes)
- [Settings](#settings)
-- [TODO](#todo)
+- [Troubleshooting](#troubleshooting)
+- [Notes](#notes)
+
+# How it works
+
+For each request BunkerWeb serves:
+
+1. The `log` phase runs `discord.lua`. If `USE_DISCORD` is not `yes` for the
+ matched site, the plugin returns immediately and does nothing. (A companion
+ `log_default` hook covers the default server when it is disabled via
+ `DISABLE_DEFAULT_SERVER`.)
+2. The plugin reads the denial reason from BunkerWeb's request context. If the
+ request was **not** denied by any security feature, it stops here - allowed
+ traffic never produces a notification.
+3. For a denied request, it builds a Discord embed describing the event: the
+ request line, the denial reason and reason data, and the request headers.
+ Embed field values are truncated to Discord's 1024-character limit. When the
+ request carries many headers, they are folded into a code block in the embed
+ description instead of one field each. Sensitive headers (`Authorization`,
+ `Cookie`, `X-Api-Key`, ...) are redacted before the payload is built.
+4. The HTTP `POST` to `DISCORD_WEBHOOK_URL` is scheduled with
+ `ngx.timer.at(0, ...)`, so it is sent asynchronously after the response has
+ already gone back to the client. The request's latency is unaffected.
+5. If Discord replies `429 Too Many Requests` and `DISCORD_RETRY_IF_LIMITED` is
+ `yes`, the timer is rescheduled using the `Retry-After` delay returned by
+ Discord. Otherwise the `429` (or any non-2xx status) is logged and the
+ notification is dropped. Webhook failures only ever touch the logs; they
+ never affect the client.
# Prerequisites
-Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation first.
+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 to setup a Discord webhook URL, you will find more information [here](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
+You will need a Discord webhook URL for the target channel - see Discord's
+[Intro to Webhooks](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks)
+for how to create one.
# 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 services to setup besides the plugin itself.
+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 extra service to deploy beyond the plugin itself.
## Docker
```yaml
services:
-
bw-scheduler:
- image: bunkerity/bunkerweb-scheduler:1.6.0-rc1
+ image: bunkerity/bunkerweb-scheduler:1.6.11
...
environment:
- - USE_DISCORD=yes
- - DISCORD_WEBHOOK_URL=https://discordapp.com/api/webhooks/...
- ...
+ USE_DISCORD: "yes"
+ DISCORD_WEBHOOK_URL: "https://discordapp.com/api/webhooks/..."
```
## Swarm
```yaml
services:
-
bw-scheduler:
- image: bunkerity/bunkerweb-scheduler:1.6.0-rc1
+ image: bunkerity/bunkerweb-scheduler:1.6.11
...
environment:
- - USE_DISCORD=yes
- - DISCORD_WEBHOOK_URL=https://discordapp.com/api/webhooks/...
- ...
+ USE_DISCORD: "yes"
+ DISCORD_WEBHOOK_URL: "https://discordapp.com/api/webhooks/..."
networks:
- bw-plugins
...
@@ -65,7 +134,6 @@ networks:
driver: overlay
attachable: true
name: bw-plugins
-...
```
## Kubernetes
@@ -86,17 +154,34 @@ metadata:
| -------------------------- | ----------------------------------------- | --------- | -------- | ---------------------------------------------------------------------------------------------- |
| `USE_DISCORD` | `no` | multisite | no | Enable sending alerts to a Discord channel. |
| `DISCORD_WEBHOOK_URL` | `https://discordapp.com/api/webhooks/...` | global | no | Address of the Discord Webhook. |
-| |
| `DISCORD_RETRY_IF_LIMITED` | `no` | global | no | Retry to send the request if Discord API is rate limiting us (may consume a lot of resources). |
-# TODO
-
-- Add more info in notification :
- - Date
- - Country of IP
- - ASN of IP
- - ...
-- Add settings to control what details to send :
- - Anonymize IP
- - Add body
- - Add headers
+# Troubleshooting
+
+- **No notifications arrive.** Confirm `USE_DISCORD=yes` is set for the site and
+ that `DISCORD_WEBHOOK_URL` is a valid, reachable webhook. Remember that only
+ **denied** requests notify - if nothing is being blocked, nothing is sent.
+- **Test connectivity end to end.** Send a `POST` to `/discord/ping` through
+ BunkerWeb. The plugin's API hook posts a test embed to the configured webhook
+ and returns the upstream result, so you can confirm the webhook works without
+ waiting for a real attack.
+- **Notifications stop under heavy load.** When Discord rate-limits the webhook
+ (`429`), notifications are dropped unless `DISCORD_RETRY_IF_LIMITED=yes`.
+ Enabling retries makes the plugin honor the `Retry-After` delay, at the cost
+ of more scheduled timers.
+- **Webhook errors in the scheduler logs.** Any non-2xx response from Discord is
+ logged and the notification is discarded. This is by design and never affects
+ the client; check the logged status code and the webhook URL.
+
+# Notes
+
+- **Only denied requests are reported.** This plugin never blocks legitimate
+ traffic; it reacts to denials made by BunkerWeb's other security features.
+- **Zero added latency.** The webhook `POST` runs from an asynchronous
+ `ngx.timer` after the response is sent, so notification delivery never slows
+ down the client request.
+- **Sensitive headers are redacted.** Credential-bearing headers
+ (`Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`,
+ `X-Csrf-Token`, `X-Auth-Token`, `X-Access-Token`, ...) are replaced with
+ `[REDACTED]` before the payload leaves BunkerWeb, so secrets are not forwarded
+ to Discord.
diff --git a/discord/discord.lua b/discord/discord.lua
index 7723881..3b8c02f 100644
--- a/discord/discord.lua
+++ b/discord/discord.lua
@@ -1,5 +1,6 @@
local cjson = require("cjson")
local class = require("middleclass")
+local discord_helpers = require("discord.discord_helpers")
local http = require("resty.http")
local plugin = require("bunkerweb.plugin")
local utils = require("bunkerweb.utils")
@@ -20,12 +21,11 @@ local has_variable = utils.has_variable
local get_variable = utils.get_variable
local get_reason = utils.get_reason
local tostring = tostring
-local len = string.len
-local sub = string.sub
local format = string.format
local encode = cjson.encode
local floor = math.floor
local date = os.date
+local redact_header = discord_helpers.redact_header
function discord:initialize(ctx)
-- Call parent initialize
@@ -48,13 +48,9 @@ function discord:log(bypass_use_discord)
local timestamp = ngx_req.start_time()
local formattedTimestamp = date("!%Y-%m-%dT%H:%M:%S", timestamp)
local milliseconds = floor((timestamp - floor(timestamp)) * 1000)
- local formatField = function(inputString)
- if len(inputString) <= 1021 then
- return inputString
- else
- return sub(inputString, 1, 1021) .. "..."
- end
- end
+ -- Discord caps embed field values at 1024 chars; truncation lives in
+ -- discord/discord_helpers.lua (see spec/discord_helpers_spec.lua).
+ local formatField = discord_helpers.format_field
local data = {
username = "BunkerWeb",
@@ -103,14 +99,18 @@ function discord:log(bypass_use_discord)
if count > 23 then
data.embeds[1].description = "Headers :\n```"
for header, value in pairs(headers) do
- data.embeds[1].description = data.embeds[1].description .. header .. ": " .. value .. "\n"
+ data.embeds[1].description = data.embeds[1].description
+ .. header
+ .. ": "
+ .. redact_header(header, value)
+ .. "\n"
end
data.embeds[1].description = data.embeds[1].description .. "```"
else
for header, value in pairs(headers) do
table.insert(data.embeds[1].fields, {
name = header,
- value = formatField(value),
+ value = formatField(redact_header(header, value)),
inline = true,
})
end
@@ -130,6 +130,7 @@ function discord.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
local res, err_http = httpc:request_uri(self.variables["DISCORD_WEBHOOK_URL"], {
method = "POST",
@@ -141,6 +142,7 @@ function discord.send(premature, self, data)
httpc:close()
if not res then
self.logger:log(ERR, "error while sending request : " .. err_http)
+ return
end
if self.variables["DISCORD_RETRY_IF_LIMITED"] == "yes" and res.status == 429 and res.headers["Retry-After"] then
self.logger:log(WARN, "Discord API is rate-limiting us, retrying in " .. res.headers["Retry-After"] .. "s")
@@ -216,6 +218,7 @@ function discord:api()
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(self.variables["DISCORD_WEBHOOK_URL"], {
method = "POST",
diff --git a/discord/discord_helpers.lua b/discord/discord_helpers.lua
new file mode 100644
index 0000000..2f15ad5
--- /dev/null
+++ b/discord/discord_helpers.lua
@@ -0,0 +1,57 @@
+-- Pure helpers extracted from discord.lua so they can be unit-tested with busted
+-- outside the OpenResty runtime. No ngx/resty dependencies — see
+-- spec/discord_helpers_spec.lua.
+local len = string.len
+local sub = string.sub
+local lower = string.lower
+local concat = table.concat
+local tostring = tostring
+local type = type
+
+local _M = {}
+
+-- Request headers that carry credentials/secrets. Their values are never
+-- forwarded to the third-party notification service. Keys are lowercase so the
+-- lookup is case-insensitive (HTTP header names are case-insensitive).
+local SENSITIVE_HEADERS = {
+ ["authorization"] = true,
+ ["proxy-authorization"] = true,
+ ["cookie"] = true,
+ ["set-cookie"] = true,
+ ["x-api-key"] = true,
+ ["x-csrf-token"] = true,
+ ["x-xsrf-token"] = true,
+ ["x-auth-token"] = true,
+ ["x-access-token"] = true,
+ ["x-session-token"] = true,
+ ["x-amz-security-token"] = true,
+}
+
+-- Discord embed field values are capped at 1024 characters. Truncate to 1021 and
+-- append "..." so the value always fits, leaving shorter strings untouched.
+function _M.format_field(input_string)
+ if len(input_string) <= 1021 then
+ return input_string
+ end
+ return sub(input_string, 1, 1021) .. "..."
+end
+
+-- Repeated headers are returned by ngx.req.get_headers() as an array table.
+-- Flatten to a single string so downstream concatenation never fails on a table.
+function _M.flatten_header_value(value)
+ if type(value) == "table" then
+ return concat(value, ", ")
+ end
+ return tostring(value)
+end
+
+-- Return a notification-safe value for a header: "[REDACTED]" for sensitive
+-- headers, otherwise the flattened value.
+function _M.redact_header(name, value)
+ if SENSITIVE_HEADERS[lower(name)] then
+ return "[REDACTED]"
+ end
+ return _M.flatten_header_value(value)
+end
+
+return _M
diff --git a/discord/docs/diagram.drawio b/discord/docs/diagram.drawio
deleted file mode 100644
index 045b966..0000000
--- a/discord/docs/diagram.drawio
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/discord/docs/diagram.mmd b/discord/docs/diagram.mmd
new file mode 100644
index 0000000..d01f149
--- /dev/null
+++ b/discord/docs/diagram.mmd
@@ -0,0 +1,30 @@
+flowchart TD
+ accTitle: BunkerWeb Discord plugin notification flow
+ accDescr: The plugin does not block traffic. When BunkerWeb denies a request, discord.lua runs on the log phase, builds a JSON embed, and schedules an async ngx.timer so the HTTP POST to the Discord webhook happens after the response, leaving request latency unaffected. A 429 rate-limit response is retried after its Retry-After delay.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb]
+ direction TB
+ decision{"Request denied?"}
+ log["discord.lua (log phase): build JSON embed (request, reason, headers)"]
+ timer["ngx.timer.at(0): async, after response"]
+ decision -->|yes| log --> timer
+ end
+
+ discord[["Discord webhook DISCORD_WEBHOOK_URL"]]
+ served([Response already returned to client])
+
+ client -->|request| decision
+ decision -->|no| served
+ timer -.->|"HTTP POST (async)"| discord
+ discord -.->|"429 -> retry after Retry-After"| timer
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class served ok;
+ class log,timer deny;
+ class discord svc;
+ class client,decision app;
diff --git a/discord/docs/diagram.svg b/discord/docs/diagram.svg
deleted file mode 100644
index 4543af3..0000000
--- a/discord/docs/diagram.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/discord/plugin.json b/discord/plugin.json
index af1c117..eeff70f 100644
--- a/discord/plugin.json
+++ b/discord/plugin.json
@@ -2,7 +2,7 @@
"id": "discord",
"name": "Discord",
"description": "Send alerts to a Discord channel (using webhooks).",
- "version": "1.10",
+ "version": "1.11",
"stream": "yes",
"settings": {
"USE_DISCORD": {
diff --git a/discord/ui/actions.py b/discord/ui/actions.py
index e79c305..81f1a2b 100644
--- a/discord/ui/actions.py
+++ b/discord/ui/actions.py
@@ -18,10 +18,7 @@ def pre_render(**kwargs):
except BaseException as e:
logger.debug(format_exc())
logger.error(f"Failed to get discord ping: {e}")
- ret["error"] = str(e)
-
- if "error" in ret:
- return ret
+ ret["error"] = "Could not retrieve the plugin status"
return ret
diff --git a/matrix/README.md b/matrix/README.md
new file mode 100644
index 0000000..812b631
--- /dev/null
+++ b/matrix/README.md
@@ -0,0 +1,250 @@
+# Matrix Notification Plugin
+
+
+
+```mermaid
+flowchart TD
+ accTitle: BunkerWeb Matrix plugin notification flow
+ accDescr: The plugin does not block traffic. When BunkerWeb denies a request, matrix.lua runs on the log phase, builds an HTML and plain-text message with a unique transaction id, and schedules an async ngx.timer so the HTTP PUT to the Matrix room happens after the response, leaving request latency unaffected. The transaction id makes the send idempotent across retries.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb]
+ direction TB
+ decision{"Request denied?"}
+ log["matrix.lua (log phase): build HTML + plain body, unique txn_id"]
+ timer["ngx.timer.at(0): async, after response"]
+ decision -->|yes| log --> timer
+ end
+
+ matrix[["Matrix homeserver: PUT /_matrix/client/r0/rooms/ {room}/send/m.room.message/{txn_id} (Bearer token)"]]
+ served([Response already returned to client])
+
+ client -->|request| decision
+ decision -->|no| served
+ timer -.->|"HTTP PUT (async)"| matrix
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class served ok;
+ class log,timer deny;
+ class matrix svc;
+ class client,decision app;
+```
+
+This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github)
+plugin sends an attack notification to a Matrix room every time BunkerWeb denies
+a request. It runs entirely on BunkerWeb's **log phase** (`log` / `log_default`
+hooks), so it fires after the verdict has already been reached: it only reports,
+it **never blocks or delays traffic**. Requests that are allowed produce no
+notification.
+
+When a request is denied, `matrix.lua` builds an HTML-formatted and a plain-text
+message describing the request - method, client IP, GeoIP country, ASN and AS
+organization, the target host and URI, and the deny reason - then schedules an
+async `ngx.timer.at(0)` callback that issues an HTTP `PUT` to the Matrix
+client-server API once the response has already been returned. Each message
+carries a unique transaction id, so the send is idempotent: Matrix silently
+drops duplicate transaction ids, and a retried message is never double-posted.
+
+# Table of contents
+
+- [Matrix Notification Plugin](#matrix-notification-plugin)
+- [Table of contents](#table-of-contents)
+- [How it works](#how-it-works)
+- [Prerequisites](#prerequisites)
+- [Setup](#setup)
+ - [Docker](#docker)
+ - [Swarm](#swarm)
+ - [Kubernetes](#kubernetes)
+- [Settings](#settings)
+- [Troubleshooting](#troubleshooting)
+- [Notes](#notes)
+
+# How it works
+
+1. A client request reaches BunkerWeb and goes through all of BunkerWeb's
+ normal checks. The Matrix plugin does not participate in this decision.
+2. If the request is **allowed**, the response is returned to the client and
+ nothing else happens - no message is sent.
+3. If the request is **denied**, `matrix.lua` runs on the log phase. It reads the
+ deny reason, then enriches the event with the client IP's GeoIP country, ASN
+ and AS organization (each falls back to an `... unknown` label if the lookup
+ fails). It assembles two payloads: an `org.matrix.custom.html`
+ `formatted_body` and a plain-text `body`. All user-controlled values (IP,
+ host, URI, reason, header names and values) are HTML-escaped.
+4. If `MATRIX_INCLUDE_HEADERS=yes`, the request headers are appended as a table;
+ credential-bearing headers (`Authorization`, `Cookie`, `X-Api-Key`, ...) are
+ replaced with `[REDACTED]`. If `MATRIX_ANONYMIZE_IP=yes`, the client IP is
+ masked in both payloads before sending.
+5. The handler schedules `ngx.timer.at(0, ...)` and returns immediately, so the
+ client's response is not delayed by the network round-trip to Matrix.
+6. The async callback sends an HTTP `PUT` to
+ `/_matrix/client/r0/rooms//send/m.room.message/`
+ with an `Authorization: Bearer ` header. The room id is
+ percent-encoded into the path, and `` is a unique
+ `milliseconds_pid_counter` value to guarantee idempotency.
+7. The plugin also exposes an internal `POST /matrix/ping` API endpoint (used by
+ the BunkerWeb web UI's test button), which sends a plain-text
+ `Test message from bunkerweb.` to the configured room to validate the
+ credentials and membership. When the default server is disabled
+ (`DISABLE_DEFAULT_SERVER=yes`), the `log_default` hook reports denials hitting
+ the default server as well.
+
+# 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 homeserver base URL (e.g. `https://matrix.org`).
+- A valid **access token** for the Matrix user the notifications are sent from
+ (an access token, not the account password).
+- The internal **room id** (`!id:server` form, e.g. `!abcdefg:matrix.org`) of the
+ room to post into. The sending user must already be a **member** of that room.
+
+Refer to your homeserver's documentation if you need help obtaining these.
+
+# Setup
+
+See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+of the BunkerWeb documentation for the generic plugin installation procedure
+(the short version: drop the `matrix/` directory into the scheduler's
+`/data/plugins/` and restart). There is no additional service to deploy - the
+plugin talks directly to your Matrix homeserver.
+
+The Matrix settings are read by the scheduler, so set them on the
+**bw-scheduler** service.
+
+## Docker
+
+```yaml
+services:
+
+ bunkerweb:
+ image: bunkerity/bunkerweb:1.6.11
+ ...
+
+ bw-scheduler:
+ image: bunkerity/bunkerweb-scheduler:1.6.11
+ ...
+ environment:
+ USE_MATRIX: "yes"
+ MATRIX_BASE_URL: "https://matrix.org"
+ MATRIX_ROOM_ID: "!yourRoomID:matrix.org"
+ MATRIX_ACCESS_TOKEN: "your-access-token"
+```
+
+## Swarm
+
+```yaml
+services:
+
+ bunkerweb:
+ image: bunkerity/bunkerweb:1.6.11
+ ...
+
+ bw-scheduler:
+ image: bunkerity/bunkerweb-scheduler:1.6.11
+ ...
+ environment:
+ USE_MATRIX: "yes"
+ MATRIX_BASE_URL: "https://matrix.org"
+ MATRIX_ROOM_ID: "!yourRoomID:matrix.org"
+ MATRIX_ACCESS_TOKEN: "your-access-token"
+ deploy:
+ mode: replicated
+ replicas: 1
+```
+
+## Kubernetes
+
+`USE_MATRIX` is a multisite setting (it can be enabled per service via Ingress
+annotations), but `MATRIX_BASE_URL`, `MATRIX_ROOM_ID` and `MATRIX_ACCESS_TOKEN`
+are **global** settings - set them as environment variables on the scheduler
+Deployment:
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: bunkerweb-scheduler
+spec:
+ replicas: 1
+ template:
+ spec:
+ containers:
+ - name: bunkerweb-scheduler
+ image: bunkerity/bunkerweb-scheduler:1.6.11
+ env:
+ - name: USE_MATRIX
+ value: "yes"
+ - name: MATRIX_BASE_URL
+ value: "https://matrix.org"
+ - name: MATRIX_ROOM_ID
+ value: "!yourRoomID:matrix.org"
+ - name: MATRIX_ACCESS_TOKEN
+ value: "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. |
+
+# Troubleshooting
+
+- **No message arrives, scheduler log shows `request returned status 401`.**
+ `MATRIX_ACCESS_TOKEN` is wrong or expired. It must be an **access token**, not
+ the account password (in Element: _Settings → Help & About → Access Token_, or
+ obtain one via the login API).
+- **`request returned status 403` (`M_FORBIDDEN`).** The sending user is not a
+ member of the target room. Join the room with that user first; the homeserver
+ rejects sends from non-members.
+- **Message goes nowhere / wrong room.** `MATRIX_ROOM_ID` must be the internal
+ `!id:server` form (e.g. `!abcdefg:matrix.org`), not the human-readable alias
+ `#room:server`. Resolve the alias to its internal id and use that.
+- **Nothing is ever sent, even under attack.** Only **denied** requests trigger a
+ notification - the plugin reports on the log phase and never blocks. Confirm
+ the request was actually denied by another BunkerWeb feature; an allowed
+ (`200`) request will not produce a message by design.
+- **`can't get Country/ASN/Organization of IP ...` in the logs.** The GeoIP/ASN
+ MMDB databases are not loaded. The notification is still sent, with the
+ affected field shown as `Country unknown` / `ASN unknown` /
+ `AS Organization unknown`.
+- **Verify the configuration end to end.** Use the web UI's Matrix test/ping
+ action (internally `POST /matrix/ping`) - it sends a
+ `Test message from bunkerweb.` to the room and surfaces a non-`2xx` Matrix
+ response as an error.
+
+# Notes
+
+- **Reporting only, never blocking.** This plugin runs on the log phase. It does
+ not deny, delay, or otherwise alter requests - it only reports requests that
+ BunkerWeb has already denied. It does not affect request latency: the actual
+ HTTP `PUT` to Matrix happens in an `ngx.timer.at(0)` callback after the
+ response has been returned to the client.
+- **Idempotent sends.** Every message uses a unique transaction id
+ (`milliseconds_pid_counter`). Matrix drops duplicate transaction ids, so a
+ retried or replayed send is never posted twice.
+- **Sensitive data handling.** All user-controlled content placed in the message
+ is HTML-escaped to keep attacker-supplied values from breaking the Matrix HTML
+ markup. When `MATRIX_INCLUDE_HEADERS=yes`, credential-bearing request headers
+ (`Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`,
+ `X-Csrf-Token`, `X-Auth-Token`, ...) are replaced with `[REDACTED]` before the
+ payload leaves the instance.
+- **IP anonymization.** With `MATRIX_ANONYMIZE_IP=yes`, the client IP is masked
+ before sending: for IPv4 the last two octets become `xxx.xxx` (the first two
+ octets are kept), and for IPv6 the first three groups are kept and the
+ remainder is replaced with `:xxxx`.
+- **Stream support.** The plugin supports stream (TCP/UDP) servers as well as
+ HTTP servers.
diff --git a/matrix/docs/diagram.mmd b/matrix/docs/diagram.mmd
new file mode 100644
index 0000000..4296960
--- /dev/null
+++ b/matrix/docs/diagram.mmd
@@ -0,0 +1,29 @@
+flowchart TD
+ accTitle: BunkerWeb Matrix plugin notification flow
+ accDescr: The plugin does not block traffic. When BunkerWeb denies a request, matrix.lua runs on the log phase, builds an HTML and plain-text message with a unique transaction id, and schedules an async ngx.timer so the HTTP PUT to the Matrix room happens after the response, leaving request latency unaffected. The transaction id makes the send idempotent across retries.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb]
+ direction TB
+ decision{"Request denied?"}
+ log["matrix.lua (log phase): build HTML + plain body, unique txn_id"]
+ timer["ngx.timer.at(0): async, after response"]
+ decision -->|yes| log --> timer
+ end
+
+ matrix[["Matrix homeserver: PUT /_matrix/client/r0/rooms/ {room}/send/m.room.message/{txn_id} (Bearer token)"]]
+ served([Response already returned to client])
+
+ client -->|request| decision
+ decision -->|no| served
+ timer -.->|"HTTP PUT (async)"| matrix
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class served ok;
+ class log,timer deny;
+ class matrix svc;
+ class client,decision app;
diff --git a/matrix/matrix.lua b/matrix/matrix.lua
new file mode 100644
index 0000000..7b05e41
--- /dev/null
+++ b/matrix/matrix.lua
@@ -0,0 +1,277 @@
+local cjson = require("cjson")
+local class = require("middleclass")
+local http = require("resty.http")
+local matrix_helpers = require("matrix.matrix_helpers")
+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
+
+-- Pure string helpers live in matrix/matrix_helpers.lua so they can be unit-tested
+-- with busted outside the OpenResty runtime (see spec/matrix_helpers_spec.lua).
+local html_escape = matrix_helpers.html_escape
+local escape_pattern = matrix_helpers.escape_pattern
+local anonymize_ip = matrix_helpers.anonymize_ip
+local redact_header = matrix_helpers.redact_header
+
+-- 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"] = "
"
+ 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"] .. "
Header
Value
"
+ data["body"] = data["body"] .. "\n\n"
+ for header, value in pairs(headers) do
+ -- Redact credential headers and flatten repeated headers (which arrive
+ -- as a table from ngx.req.get_headers()) before leaving the instance.
+ local header_value = redact_header(header, value)
+ data["formatted_body"] = data["formatted_body"]
+ .. "
"
+ 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,
+ },
+ })
+ 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
+ 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
diff --git a/matrix/matrix_helpers.lua b/matrix/matrix_helpers.lua
new file mode 100644
index 0000000..e3492cd
--- /dev/null
+++ b/matrix/matrix_helpers.lua
@@ -0,0 +1,72 @@
+-- Pure helpers extracted from matrix.lua so they can be unit-tested with busted
+-- outside the OpenResty runtime. No ngx/resty dependencies — see
+-- spec/matrix_helpers_spec.lua.
+local gsub = string.gsub
+local find = string.find
+local match = string.match
+local lower = string.lower
+local concat = table.concat
+local tostring = tostring
+local type = type
+
+local _M = {}
+
+-- Request headers that carry credentials/secrets. Their values are never
+-- forwarded to the third-party notification service. Keys are lowercase so the
+-- lookup is case-insensitive (HTTP header names are case-insensitive).
+local SENSITIVE_HEADERS = {
+ ["authorization"] = true,
+ ["proxy-authorization"] = true,
+ ["cookie"] = true,
+ ["set-cookie"] = true,
+ ["x-api-key"] = true,
+ ["x-csrf-token"] = true,
+ ["x-xsrf-token"] = true,
+ ["x-auth-token"] = true,
+ ["x-access-token"] = true,
+ ["x-session-token"] = true,
+ ["x-amz-security-token"] = true,
+}
+
+-- Repeated headers are returned by ngx.req.get_headers() as an array table.
+-- Flatten to a single string so downstream concatenation never fails on a table.
+function _M.flatten_header_value(value)
+ if type(value) == "table" then
+ return concat(value, ", ")
+ end
+ return tostring(value)
+end
+
+-- Return a notification-safe value for a header: "[REDACTED]" for sensitive
+-- headers, otherwise the flattened value. Caller is responsible for any further
+-- escaping (e.g. html_escape) required by the destination format.
+function _M.redact_header(name, value)
+ if SENSITIVE_HEADERS[lower(name)] then
+ return "[REDACTED]"
+ end
+ return _M.flatten_header_value(value)
+end
+
+-- 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.
+function _M.html_escape(str)
+ return (gsub(tostring(str), "[&<>]", { ["&"] = "&", ["<"] = "<", [">"] = ">" }))
+end
+
+-- Escape Lua pattern magic characters so a literal string can be used as a gsub pattern.
+function _M.escape_pattern(str)
+ return (gsub(str, "([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1"))
+end
+
+-- Mask an IP for notifications. Handles both IPv4 and IPv6.
+function _M.anonymize_ip(ip)
+ if find(ip, ":", 1, true) then
+ -- IPv6: keep the first three hextets, mask the remainder
+ local prefix = match(ip, "^(%x*:%x*:%x*):")
+ return prefix and (prefix .. ":xxxx") or "xxxx::xxxx"
+ end
+ -- IPv4: mask the last two octets
+ return (gsub(ip, "%d+%.%d+$", "xxx.xxx"))
+end
+
+return _M
diff --git a/matrix/plugin.json b/matrix/plugin.json
new file mode 100644
index 0000000..2bd7a87
--- /dev/null
+++ b/matrix/plugin.json
@@ -0,0 +1,63 @@
+{
+ "id": "matrix",
+ "name": "Matrix",
+ "description": "Send alerts to a Matrix room via the Matrix API.",
+ "version": "1.11",
+ "stream": "yes",
+ "settings": {
+ "USE_MATRIX": {
+ "context": "multisite",
+ "default": "no",
+ "help": "Enable sending alerts to a Matrix room.",
+ "id": "use-matrix",
+ "label": "Use Matrix",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "MATRIX_BASE_URL": {
+ "context": "global",
+ "default": "https://matrix.org",
+ "help": "Base URL of the Matrix server (e.g., https://matrix.org).",
+ "id": "matrix-base-url",
+ "label": "Matrix Base URL",
+ "regex": "^.*$",
+ "type": "text"
+ },
+ "MATRIX_ROOM_ID": {
+ "context": "global",
+ "default": "!yourRoomID:matrix.org",
+ "help": "Room ID of the Matrix room to send notifications to.",
+ "id": "matrix-room-id",
+ "label": "Matrix Room ID",
+ "regex": "^.*$",
+ "type": "text"
+ },
+ "MATRIX_ACCESS_TOKEN": {
+ "context": "global",
+ "default": "",
+ "help": "Access token to authenticate with the Matrix server.",
+ "id": "matrix-access-token",
+ "label": "Matrix Access Token",
+ "regex": "^.*$",
+ "type": "password"
+ },
+ "MATRIX_ANONYMIZE_IP": {
+ "context": "global",
+ "default": "no",
+ "help": "Mask the IP address in notifications.",
+ "id": "matrix-anonymize-ip",
+ "label": "Anonymize IP",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "MATRIX_INCLUDE_HEADERS": {
+ "context": "global",
+ "default": "no",
+ "help": "Include request headers in notifications.",
+ "id": "matrix-include-headers",
+ "label": "Include Headers",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ }
+ }
+}
diff --git a/matrix/ui/actions.py b/matrix/ui/actions.py
new file mode 100644
index 0000000..615688b
--- /dev/null
+++ b/matrix/ui/actions.py
@@ -0,0 +1,27 @@
+from logging import getLogger
+from traceback import format_exc
+
+
+def pre_render(**kwargs):
+ logger = getLogger("UI")
+ ret = {
+ "ping_status": {
+ "title": "MATRIX STATUS",
+ "value": "error",
+ "col-size": "col-12 col-md-6",
+ "card-classes": "h-100",
+ },
+ }
+ try:
+ ping_data = kwargs["bw_instances_utils"].get_ping("matrix")
+ ret["ping_status"]["value"] = ping_data["status"]
+ except BaseException as e:
+ logger.debug(format_exc())
+ logger.error(f"Failed to get matrix ping: {e}")
+ ret["error"] = "Could not retrieve the plugin status"
+
+ return ret
+
+
+def matrix(**kwargs):
+ pass
diff --git a/matrix/utils.lua b/matrix/utils.lua
new file mode 100644
index 0000000..1f311bd
--- /dev/null
+++ b/matrix/utils.lua
@@ -0,0 +1,21 @@
+local mmdb = require("bunkerweb.mmdb")
+
+local _utils = {}
+
+_utils.get_asn_org = function(ip)
+ -- Check if mmdb is loaded
+ if not mmdb.asn_db then
+ return false, "mmdb asn not loaded"
+ end
+ -- Perform lookup
+ local ok, result, err = pcall(mmdb.asn_db.lookup, mmdb.asn_db, ip)
+ if not ok then
+ return nil, result
+ end
+ if not result then
+ return nil, err
+ end
+ return result.autonomous_system_organization, "success"
+end
+
+return _utils
diff --git a/misc/update_version.sh b/misc/update_version.sh
index e114a22..7db9c6b 100755
--- a/misc/update_version.sh
+++ b/misc/update_version.sh
@@ -15,4 +15,9 @@ fi
echo "Updating version of plugins to \"$1\""
find . -type f -name "plugin.json" -exec sed -i 's@"version": "[0-9].*"@"version": "'"$1"'"@' {} \;
-sed -i 's@"bunkerweb_plugins\-[0-9].*\-blue"@"bunkerweb_plugins-'"$1"'-blue@' README.md
+# Anchor on the shields.io URL path (/badge/), not a leading quote — the badge
+# is inside src="…/badge/bunkerweb_plugins--blue", so the old `"bunkerweb…`
+# pattern never matched and the badge silently went stale. Covers the root
+# README and every plugin README (each carries the same badge); the sed is a
+# no-op on READMEs that don't have the badge.
+find . -type f -name "README.md" -exec sed -i 's@/badge/bunkerweb_plugins-[0-9][0-9.]*-blue@/badge/bunkerweb_plugins-'"$1"'-blue@' {} \;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..dd1325d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,31 @@
+{
+ "name": "bunkerweb-plugins",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "bunkerweb-plugins",
+ "version": "1.0.0",
+ "devDependencies": {
+ "prettier": "3.8.4"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.8.4",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
+ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..aa69573
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "bunkerweb-plugins",
+ "version": "1.0.0",
+ "description": "Tooling dependencies (code formatting) for the BunkerWeb external plugins repository",
+ "private": true,
+ "scripts": {
+ "format": "prettier --write .",
+ "format:check": "prettier --check ."
+ },
+ "devDependencies": {
+ "prettier": "3.8.4"
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
index 378009a..f2e7e45 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,3 +14,8 @@ exclude = '''
| env
)/
'''
+
+[tool.refurb]
+# FURB124 rewrites `a == "yes" and b == "yes"` into a chained `a == "yes" == b`,
+# which reads as "a and b equal each other" and hurts clarity in the CF env-gate logic.
+ignore = ["FURB124"]
diff --git a/slack/README.md b/slack/README.md
index 1449a33..731356a 100644
--- a/slack/README.md
+++ b/slack/README.md
@@ -1,34 +1,109 @@
# Slack plugin
-
-
-
+
+
+```mermaid
+flowchart TD
+ accTitle: BunkerWeb Slack plugin notification flow
+ accDescr: The plugin does not block traffic. When BunkerWeb denies a request, slack.lua runs on the log phase, builds a plain-text message, and schedules an async ngx.timer so the HTTP POST to the Slack webhook happens after the response, leaving request latency unaffected. A 429 rate-limit response is retried after its Retry-After delay.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb]
+ direction TB
+ decision{"Request denied?"}
+ log["slack.lua (log phase): build text message (IP, reason, request, headers)"]
+ timer["ngx.timer.at(0): async, after response"]
+ decision -->|yes| log --> timer
+ end
+
+ slack[["Slack webhook SLACK_WEBHOOK_URL"]]
+ served([Response already returned to client])
+
+ client -->|request| decision
+ decision -->|no| served
+ timer -.->|"HTTP POST text (async)"| slack
+ slack -.->|"429 -> retry after Retry-After"| timer
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class served ok;
+ class log,timer deny;
+ class slack svc;
+ class client,decision app;
+```
+
+This [plugin](https://www.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+posts a Slack message to an incoming webhook every time BunkerWeb denies a
+request. It is a notifier only: it never blocks, filters, or alters traffic
+itself - it simply reports the denials made by BunkerWeb's own security
+checks (rate limit, bad behavior, antibot, DNSBL, blacklist, ...).
-This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github) plugin will automatically send you attack notifications on a Slack channel of your choice using a webhook.
+The notification is built and sent from Lua during BunkerWeb's **log phase**,
+after the response has already been returned to the client. The HTTP `POST`
+to Slack runs inside an `ngx.timer.at(0)` timer, so it adds no latency to the
+request. Each message carries the offending IP, the deny reason, the request
+line, and the request headers - with sensitive headers redacted - inside a
+Slack code block.
# Table of contents
- [Slack plugin](#slack-plugin)
- [Table of contents](#table-of-contents)
+- [How it works](#how-it-works)
- [Prerequisites](#prerequisites)
- [Setup](#setup)
- [Docker](#docker)
- [Swarm](#swarm)
- [Kubernetes](#kubernetes)
- [Settings](#settings)
-- [TODO](#todo)
+- [Troubleshooting](#troubleshooting)
+- [Notes](#notes)
+
+# How it works
+
+1. A client request is processed normally. BunkerWeb's security checks decide
+ whether to allow or deny it; this plugin does not take part in that
+ decision.
+2. On the log phase, `slack.lua` runs. If `USE_SLACK` is not `yes`, or the
+ request was **not** denied, it returns immediately and does nothing.
+3. For a denied request it builds a plain-text message: a code block
+ containing `Denied request for IP `, the deny reason and reason data,
+ the request line (`ngx.var.request`), and every request header. Sensitive
+ headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie`,
+ `X-Api-Key`, `X-Csrf-Token`, `X-Auth-Token`, ...) are replaced with
+ `[REDACTED]` before they leave BunkerWeb.
+4. The message is handed to an `ngx.timer.at(0)` timer, so the `POST` to
+ `SLACK_WEBHOOK_URL` happens asynchronously after the response is sent -
+ request latency is unaffected.
+5. If Slack replies `429` and `SLACK_RETRY_IF_LIMITED` is `yes`, the timer
+ reschedules itself after the `Retry-After` delay; otherwise the message is
+ dropped. Any other webhook error is logged only and never reaches the
+ client.
+
+Denials hitting the default server are also reported when
+`DISABLE_DEFAULT_SERVER=yes` (handled by the `log_default` hook). A test
+message can be sent on demand with a `POST` to `/slack/ping`.
# Prerequisites
-Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation first.
+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 to setup a Slack webhook URL, you will find more information [here](https://api.slack.com/messaging/webhooks).
+You will need a Slack incoming webhook URL (of the form
+`https://hooks.slack.com/services/...`). See Slack's
+[webhooks documentation](https://api.slack.com/messaging/webhooks) for how to
+create one. There is no additional service to run besides the plugin itself.
# 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 services to setup besides the plugin itself.
+See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+of the BunkerWeb documentation for the generic plugin installation procedure
+(the short version: drop the `slack/` directory into the scheduler's
+`/data/plugins/` and restart). Set the plugin environment variables on the
+`bw-scheduler` service.
## Docker
@@ -36,11 +111,11 @@ There is no additional services to setup besides the plugin itself.
services:
bw-scheduler:
- image: bunkerity/bunkerweb-scheduler:1.6.0-rc1
+ image: bunkerity/bunkerweb-scheduler:1.6.11
...
environment:
- - USE_SLACK=yes
- - SLACK_WEBHOOK_URL=https://api.slack.com/messaging/webhooks/...
+ USE_SLACK: "yes"
+ SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/..."
...
```
@@ -50,11 +125,11 @@ services:
services:
bw-scheduler:
- image: bunkerity/bunkerweb-scheduler:1.6.0-rc1
+ image: bunkerity/bunkerweb-scheduler:1.6.11
...
environment:
- - USE_SLACK=yes
- - SLACK_WEBHOOK_URL=https://api.slack.com/messaging/webhooks/...
+ USE_SLACK: "yes"
+ SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/..."
...
```
@@ -67,7 +142,7 @@ metadata:
name: ingress
annotations:
bunkerweb.io/USE_SLACK: "yes"
- bunkerweb.io/SLACK_WEBHOOK_URL: "https://api.slack.com/messaging/webhooks/..."
+ bunkerweb.io/SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/..."
```
# Settings
@@ -76,17 +151,40 @@ metadata:
| ------------------------ | -------------------------------------- | --------- | -------- | -------------------------------------------------------------------------------------------- |
| `USE_SLACK` | `no` | multisite | no | Enable sending alerts to a Slack channel. |
| `SLACK_WEBHOOK_URL` | `https://hooks.slack.com/services/...` | global | no | Address of the Slack Webhook. |
-| |
| `SLACK_RETRY_IF_LIMITED` | `no` | global | no | Retry to send the request if Slack API is rate limiting us (may consume a lot of resources). |
-# TODO
-
-- Add more info in notification :
- - Date
- - Country of IP
- - ASN of IP
- - ...
-- Add settings to control what details to send :
- - Anonymize IP
- - Add body
- - Add headers
+# Troubleshooting
+
+- **No notifications arrive.** Confirm `USE_SLACK=yes` is set for the relevant
+ site and that `SLACK_WEBHOOK_URL` is a valid incoming-webhook URL
+ (`https://hooks.slack.com/services/...`). Trigger a test message with a
+ `POST` to `/slack/ping` and check the response status.
+- **`POST /slack/ping` returns 500.** The webhook request failed (Slack
+ unreachable or a non-2xx reply). Verify the URL and that BunkerWeb has
+ outbound network access to `hooks.slack.com`.
+- **Webhook failures don't affect users - by design.** Send errors are logged
+ only. Look in the BunkerWeb / scheduler logs for `error while sending
+request` or `request returned status ...`; the client is never impacted.
+- **Slack rate-limiting.** A log line `slack API is rate-limiting us` means
+ Slack returned `429`. Set `SLACK_RETRY_IF_LIMITED=yes` to retry after the
+ `Retry-After` delay (note the resource-usage warning in the settings).
+- **Only denials are reported.** Allowed traffic never generates a message. If
+ you expected a notification for a request that was ultimately allowed, that
+ is expected behavior.
+
+# Notes
+
+- **Notifier only - never blocks traffic.** This plugin reports denials made by
+ BunkerWeb's other security checks; it does not deny, filter, or modify any
+ request itself.
+- **Denied requests only.** A message is sent solely when a request is denied
+ (and, with `DISABLE_DEFAULT_SERVER=yes`, for denials on the default server).
+- **Zero added latency.** The webhook `POST` runs in an `ngx.timer.at(0)`
+ timer on the log phase, after the response has been returned to the client.
+- **Sensitive headers are redacted.** Credential-bearing headers
+ (`Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`, ...)
+ are replaced with `[REDACTED]` before the message leaves BunkerWeb.
+- **Rate-limit retry trade-off.** `SLACK_RETRY_IF_LIMITED=yes` keeps
+ rescheduling timers under sustained `429` responses, which can consume
+ resources during a flood of denials. Leave it `no` if you would rather drop
+ notifications than queue retries.
diff --git a/slack/docs/diagram.drawio b/slack/docs/diagram.drawio
deleted file mode 100644
index 25eae02..0000000
--- a/slack/docs/diagram.drawio
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/slack/docs/diagram.mmd b/slack/docs/diagram.mmd
new file mode 100644
index 0000000..ce967b3
--- /dev/null
+++ b/slack/docs/diagram.mmd
@@ -0,0 +1,30 @@
+flowchart TD
+ accTitle: BunkerWeb Slack plugin notification flow
+ accDescr: The plugin does not block traffic. When BunkerWeb denies a request, slack.lua runs on the log phase, builds a plain-text message, and schedules an async ngx.timer so the HTTP POST to the Slack webhook happens after the response, leaving request latency unaffected. A 429 rate-limit response is retried after its Retry-After delay.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb]
+ direction TB
+ decision{"Request denied?"}
+ log["slack.lua (log phase): build text message (IP, reason, request, headers)"]
+ timer["ngx.timer.at(0): async, after response"]
+ decision -->|yes| log --> timer
+ end
+
+ slack[["Slack webhook SLACK_WEBHOOK_URL"]]
+ served([Response already returned to client])
+
+ client -->|request| decision
+ decision -->|no| served
+ timer -.->|"HTTP POST text (async)"| slack
+ slack -.->|"429 -> retry after Retry-After"| timer
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class served ok;
+ class log,timer deny;
+ class slack svc;
+ class client,decision app;
diff --git a/slack/docs/diagram.svg b/slack/docs/diagram.svg
deleted file mode 100644
index 27d683c..0000000
--- a/slack/docs/diagram.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/slack/plugin.json b/slack/plugin.json
index 914504e..770cca8 100644
--- a/slack/plugin.json
+++ b/slack/plugin.json
@@ -2,7 +2,7 @@
"id": "slack",
"name": "Slack",
"description": "Send alerts to a Slack channel (using webhooks).",
- "version": "1.10",
+ "version": "1.11",
"stream": "partial",
"settings": {
"USE_SLACK": {
diff --git a/slack/slack.lua b/slack/slack.lua
index 6afdb92..72929a5 100644
--- a/slack/slack.lua
+++ b/slack/slack.lua
@@ -2,6 +2,7 @@ local cjson = require("cjson")
local class = require("middleclass")
local http = require("resty.http")
local plugin = require("bunkerweb.plugin")
+local slack_helpers = require("slack.slack_helpers")
local utils = require("bunkerweb.utils")
local slack = class("slack", plugin)
@@ -21,6 +22,7 @@ local get_variable = utils.get_variable
local get_reason = utils.get_reason
local tostring = tostring
local encode = cjson.encode
+local redact_header = slack_helpers.redact_header
function slack:initialize(ctx)
-- Call parent initialize
@@ -55,7 +57,7 @@ function slack:log(bypass_use_slack)
data.text = data.text .. "error while getting headers : " .. err
else
for header, value in pairs(headers) do
- data.text = data.text .. header .. ": " .. value .. "\n"
+ data.text = data.text .. header .. ": " .. redact_header(header, value) .. "\n"
end
end
data.text = data.text .. "```"
@@ -73,6 +75,7 @@ function slack.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
local res, err_http = httpc:request_uri(self.variables["SLACK_WEBHOOK_URL"], {
method = "POST",
@@ -84,6 +87,7 @@ function slack.send(premature, self, data)
httpc:close()
if not res then
self.logger:log(ERR, "error while sending request : " .. err_http)
+ return
end
if self.variables["SLACK_RETRY_IF_LIMITED"] == "yes" and res.status == 429 and res.headers["Retry-After"] then
self.logger:log(WARN, "slack API is rate-limiting us, retrying in " .. res.headers["Retry-After"] .. "s")
@@ -143,6 +147,7 @@ function slack:api()
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(self.variables["SLACK_WEBHOOK_URL"], {
method = "POST",
@@ -153,7 +158,7 @@ function slack:api()
})
httpc:close()
if not res then
- self.logger:log(ERR, "error while sending request : " .. err_http)
+ return self:ret(true, "error while sending request : " .. err_http, HTTP_INTERNAL_SERVER_ERROR)
end
if self.variables["SLACK_RETRY_IF_LIMITED"] == "yes" and res.status == 429 and res.headers["Retry-After"] then
return self:ret(
diff --git a/slack/slack_helpers.lua b/slack/slack_helpers.lua
new file mode 100644
index 0000000..f795edf
--- /dev/null
+++ b/slack/slack_helpers.lua
@@ -0,0 +1,46 @@
+-- Pure helpers extracted from slack.lua so they can be unit-tested with busted
+-- outside the OpenResty runtime. No ngx/resty dependencies — see
+-- spec/slack_helpers_spec.lua.
+local lower = string.lower
+local concat = table.concat
+local tostring = tostring
+local type = type
+
+local _M = {}
+
+-- Request headers that carry credentials/secrets. Their values are never
+-- forwarded to the third-party notification service. Keys are lowercase so the
+-- lookup is case-insensitive (HTTP header names are case-insensitive).
+local SENSITIVE_HEADERS = {
+ ["authorization"] = true,
+ ["proxy-authorization"] = true,
+ ["cookie"] = true,
+ ["set-cookie"] = true,
+ ["x-api-key"] = true,
+ ["x-csrf-token"] = true,
+ ["x-xsrf-token"] = true,
+ ["x-auth-token"] = true,
+ ["x-access-token"] = true,
+ ["x-session-token"] = true,
+ ["x-amz-security-token"] = true,
+}
+
+-- Repeated headers are returned by ngx.req.get_headers() as an array table.
+-- Flatten to a single string so downstream concatenation never fails on a table.
+function _M.flatten_header_value(value)
+ if type(value) == "table" then
+ return concat(value, ", ")
+ end
+ return tostring(value)
+end
+
+-- Return a notification-safe value for a header: "[REDACTED]" for sensitive
+-- headers, otherwise the flattened value.
+function _M.redact_header(name, value)
+ if SENSITIVE_HEADERS[lower(name)] then
+ return "[REDACTED]"
+ end
+ return _M.flatten_header_value(value)
+end
+
+return _M
diff --git a/slack/ui/actions.py b/slack/ui/actions.py
index 9cea10b..789297e 100644
--- a/slack/ui/actions.py
+++ b/slack/ui/actions.py
@@ -18,10 +18,7 @@ def pre_render(**kwargs):
except BaseException as e:
logger.debug(format_exc())
logger.error(f"Failed to get slack ping: {e}")
- ret["error"] = str(e)
-
- if "error" in ret:
- return ret
+ ret["error"] = "Could not retrieve the plugin status"
return ret
diff --git a/spec/authentik_helpers_spec.lua b/spec/authentik_helpers_spec.lua
new file mode 100644
index 0000000..5eae8d1
--- /dev/null
+++ b/spec/authentik_helpers_spec.lua
@@ -0,0 +1,46 @@
+-- luacheck: std min+busted
+local helpers = require("authentik/authentik_helpers")
+
+describe("authentik helpers", function()
+ describe("starts_with", function()
+ it("matches a prefix", function()
+ assert.is_true(helpers.starts_with("x-authentik-username", "x-authentik-"))
+ end)
+ it("rejects a non-match", function()
+ assert.is_false(helpers.starts_with("authorization", "x-authentik-"))
+ end)
+ it("is safe on nil / empty inputs", function()
+ assert.is_false(helpers.starts_with(nil, "x-"))
+ assert.is_false(helpers.starts_with("x", ""))
+ assert.is_false(helpers.starts_with("x", nil))
+ end)
+ end)
+
+ describe("rstrip_slash", function()
+ it("strips a single trailing slash", function()
+ assert.equals("/outpost", helpers.rstrip_slash("/outpost/"))
+ end)
+ it("strips repeated trailing slashes", function()
+ assert.equals("/a", helpers.rstrip_slash("/a///"))
+ end)
+ it("leaves a slash-free string untouched", function()
+ assert.equals("http://x:9000", helpers.rstrip_slash("http://x:9000"))
+ end)
+ it("passes nil / empty through", function()
+ assert.is_nil(helpers.rstrip_slash(nil))
+ assert.equals("", helpers.rstrip_slash(""))
+ end)
+ end)
+
+ describe("split_headers", function()
+ it("splits on spaces and commas", function()
+ assert.same({ "X-A", "X-B", "X-C" }, helpers.split_headers("X-A, X-B X-C"))
+ end)
+ it("returns an empty table for nil", function()
+ assert.same({}, helpers.split_headers(nil))
+ end)
+ it("handles a single header", function()
+ assert.same({ "X-Only" }, helpers.split_headers("X-Only"))
+ end)
+ end)
+end)
diff --git a/spec/clamav_helpers_spec.lua b/spec/clamav_helpers_spec.lua
new file mode 100644
index 0000000..13d2a17
--- /dev/null
+++ b/spec/clamav_helpers_spec.lua
@@ -0,0 +1,38 @@
+-- luacheck: std min+busted
+local helpers = require("clamav/clamav_helpers")
+
+describe("clamav helpers", function()
+ describe("stream_size", function()
+ -- The ClamAV INSTREAM protocol prefixes each chunk with its length as a
+ -- 4-byte unsigned integer in network byte order (big-endian, MSB first).
+ local function bytes(s)
+ local out = {}
+ for i = 1, #s do
+ out[i] = string.byte(s, i)
+ end
+ return out
+ end
+
+ it("encodes zero as four zero bytes", function()
+ assert.same({ 0, 0, 0, 0 }, bytes(helpers.stream_size(0)))
+ end)
+ it("encodes a one-byte value big-endian", function()
+ assert.same({ 0, 0, 0, 1 }, bytes(helpers.stream_size(1)))
+ assert.same({ 0, 0, 0, 255 }, bytes(helpers.stream_size(255)))
+ end)
+ it("carries into the second byte", function()
+ assert.same({ 0, 0, 1, 0 }, bytes(helpers.stream_size(256)))
+ end)
+ it("carries into the third byte", function()
+ assert.same({ 0, 1, 0, 0 }, bytes(helpers.stream_size(65536)))
+ end)
+ it("encodes a value spanning all four bytes", function()
+ -- 0x01020304 -> {1, 2, 3, 4}
+ assert.same({ 1, 2, 3, 4 }, bytes(helpers.stream_size(0x01020304)))
+ end)
+ it("always returns exactly four bytes", function()
+ assert.equals(4, #helpers.stream_size(0))
+ assert.equals(4, #helpers.stream_size(0xFFFFFFFF))
+ end)
+ end)
+end)
diff --git a/spec/cloudflare_helpers_spec.lua b/spec/cloudflare_helpers_spec.lua
new file mode 100644
index 0000000..e8d9f0b
--- /dev/null
+++ b/spec/cloudflare_helpers_spec.lua
@@ -0,0 +1,95 @@
+-- luacheck: std min+busted
+local fake = require("spec/helpers/fake_ipmatcher")
+local helpers = require("cloudflare/cloudflare_helpers")
+
+describe("cloudflare helpers", function()
+ describe("parse_additional", function()
+ it("returns an empty list for nil or empty input", function()
+ assert.same({}, helpers.parse_additional(nil))
+ assert.same({}, helpers.parse_additional(""))
+ assert.same({}, helpers.parse_additional(" "))
+ end)
+ it("splits on arbitrary whitespace", function()
+ assert.same({ "1.2.3.4", "5.6.7.0/24", "::1" }, helpers.parse_additional(" 1.2.3.4 5.6.7.0/24\t::1 "))
+ end)
+ end)
+
+ describe("cache_key", function()
+ it("separates server_name and element so they can't collide", function()
+ -- Without the separator "example.com" .. "1.2.3.4" would collide with
+ -- "example.com1" .. ".2.3.4".
+ assert.equals("plugin_cloudflare_example.com_1.2.3.4", helpers.cache_key("example.com", "1.2.3.4"))
+ assert.are_not.equals(
+ helpers.cache_key("example.com", "1.2.3.4"),
+ helpers.cache_key("example.com1", ".2.3.4")
+ )
+ end)
+ end)
+
+ describe("classify_cache", function()
+ it("maps a miss (nil) to 'miss'", function()
+ assert.equals("miss", helpers.classify_cache(nil))
+ end)
+ it("maps a cached 'ko' to 'deny' (regression: was wrongly allowed)", function()
+ -- This is the bug that silently disabled CLOUDFLARE_DENY_NON_TRUSTED_IPS:
+ -- a cached not-trusted verdict must deny, not allow.
+ assert.equals("deny", helpers.classify_cache("ko"))
+ end)
+ it("maps a cached trust type to 'allow'", function()
+ assert.equals("allow", helpers.classify_cache("ipv4"))
+ assert.equals("allow", helpers.classify_cache("ipv6"))
+ assert.equals("allow", helpers.classify_cache("additional"))
+ end)
+ end)
+
+ describe("trusted_list_empty", function()
+ it("is true for nil / all-empty categories", function()
+ assert.is_true(helpers.trusted_list_empty(nil))
+ assert.is_true(helpers.trusted_list_empty({}))
+ assert.is_true(helpers.trusted_list_empty({ ipv4 = {}, ipv6 = {}, additional = {} }))
+ end)
+ it("is false when any category has an entry", function()
+ assert.is_false(helpers.trusted_list_empty({ ipv4 = { "1.2.3.4" } }))
+ assert.is_false(helpers.trusted_list_empty({ ipv6 = {}, additional = { "9.9.9.0/24" } }))
+ end)
+ end)
+
+ describe("match_trusted", function()
+ local function ips(t)
+ return { ipv4 = t.ipv4 or {}, ipv6 = t.ipv6 or {}, additional = t.additional or {} }
+ end
+
+ it("matches an ipv4 address and labels it", function()
+ assert.same({ true, "ipv4" }, { helpers.match_trusted(ips({ ipv4 = { "1.2.3.4" } }), "1.2.3.4", fake.new) })
+ end)
+ it("matches ipv6 and labels it", function()
+ assert.same({ true, "ipv6" }, { helpers.match_trusted(ips({ ipv6 = { "::1" } }), "::1", fake.new) })
+ end)
+ it("falls back to the additional list", function()
+ assert.same(
+ { true, "additional" },
+ { helpers.match_trusted(ips({ additional = { "9.9.9.9" } }), "9.9.9.9", fake.new) }
+ )
+ end)
+ it("checks ipv4 before additional (order matters)", function()
+ local t = ips({ ipv4 = { "1.2.3.4" }, additional = { "1.2.3.4" } })
+ assert.same({ true, "ipv4" }, { helpers.match_trusted(t, "1.2.3.4", fake.new) })
+ end)
+ it("returns false/'ko' when nothing matches", function()
+ assert.same({ false, "ko" }, { helpers.match_trusted(ips({ ipv4 = { "1.2.3.4" } }), "8.8.8.8", fake.new) })
+ end)
+ it("treats missing categories as empty (no nil index)", function()
+ assert.same({ false, "ko" }, { helpers.match_trusted({}, "8.8.8.8", fake.new) })
+ end)
+ it("propagates a matcher construction error", function()
+ local trusted, err = helpers.match_trusted(ips({}), "1.2.3.4", fake.new_err)
+ assert.is_nil(trusted)
+ assert.equals("construction boom", err)
+ end)
+ it("propagates a matcher :match error", function()
+ local trusted, err = helpers.match_trusted(ips({ ipv4 = { "1.2.3.4" } }), "1.2.3.4", fake.new_match_err)
+ assert.is_nil(trusted)
+ assert.equals("match boom", err)
+ end)
+ end)
+end)
diff --git a/spec/discord_helpers_spec.lua b/spec/discord_helpers_spec.lua
new file mode 100644
index 0000000..152d9df
--- /dev/null
+++ b/spec/discord_helpers_spec.lua
@@ -0,0 +1,60 @@
+-- luacheck: std min+busted
+local helpers = require("discord/discord_helpers")
+
+describe("discord helpers", function()
+ describe("format_field", function()
+ it("leaves a short string untouched", function()
+ assert.equals("hello", helpers.format_field("hello"))
+ end)
+ it("leaves a string of exactly 1021 chars untouched", function()
+ local s = string.rep("a", 1021)
+ assert.equals(s, helpers.format_field(s))
+ end)
+ it("truncates a 1022-char string to 1021 chars plus an ellipsis", function()
+ local s = string.rep("a", 1022)
+ local out = helpers.format_field(s)
+ assert.equals(1024, #out)
+ assert.equals(string.rep("a", 1021) .. "...", out)
+ end)
+ it("truncates a very long string to 1024 chars total", function()
+ local out = helpers.format_field(string.rep("b", 5000))
+ assert.equals(1024, #out)
+ assert.equals(string.rep("b", 1021) .. "...", out)
+ end)
+ end)
+
+ describe("flatten_header_value", function()
+ it("returns a plain string unchanged", function()
+ assert.equals("text/html", helpers.flatten_header_value("text/html"))
+ end)
+ it("joins a repeated-header table with a comma", function()
+ assert.equals("a, b, c", helpers.flatten_header_value({ "a", "b", "c" }))
+ end)
+ it("coerces a non-string scalar via tostring", function()
+ assert.equals("123", helpers.flatten_header_value(123))
+ end)
+ end)
+
+ describe("redact_header", function()
+ it("redacts the value of sensitive headers", function()
+ assert.equals("[REDACTED]", helpers.redact_header("authorization", "Bearer secret"))
+ assert.equals("[REDACTED]", helpers.redact_header("cookie", "session=abc"))
+ assert.equals("[REDACTED]", helpers.redact_header("set-cookie", "session=abc"))
+ assert.equals("[REDACTED]", helpers.redact_header("x-api-key", "k"))
+ assert.equals("[REDACTED]", helpers.redact_header("x-csrf-token", "t"))
+ assert.equals("[REDACTED]", helpers.redact_header("x-xsrf-token", "t"))
+ assert.equals("[REDACTED]", helpers.redact_header("proxy-authorization", "Basic x"))
+ end)
+ it("matches sensitive header names case-insensitively", function()
+ assert.equals("[REDACTED]", helpers.redact_header("Authorization", "Bearer secret"))
+ assert.equals("[REDACTED]", helpers.redact_header("COOKIE", "session=abc"))
+ end)
+ it("redacts even when a sensitive header is repeated (table value)", function()
+ assert.equals("[REDACTED]", helpers.redact_header("cookie", { "a=1", "b=2" }))
+ end)
+ it("passes non-sensitive headers through, flattening tables", function()
+ assert.equals("example.com", helpers.redact_header("host", "example.com"))
+ assert.equals("gzip, br", helpers.redact_header("accept-encoding", { "gzip", "br" }))
+ end)
+ end)
+end)
diff --git a/spec/helpers/fake_ipmatcher.lua b/spec/helpers/fake_ipmatcher.lua
new file mode 100644
index 0000000..e321bd6
--- /dev/null
+++ b/spec/helpers/fake_ipmatcher.lua
@@ -0,0 +1,39 @@
+-- Minimal stand-in for resty.ipmatcher used by the busted specs. The real matcher
+-- needs OpenResty; the helper under test only relies on the (new -> :match) contract,
+-- so an exact-membership fake is enough to exercise the ordering / sentinel logic.
+local _M = {}
+
+local matcher = {}
+matcher.__index = matcher
+
+function matcher:match(addr)
+ for _, ip in ipairs(self.list) do
+ if ip == addr then
+ return true
+ end
+ end
+ return false
+end
+
+-- Normal factory: build a matcher over an exact-match list.
+function _M.new(list)
+ return setmetatable({ list = list }, matcher)
+end
+
+-- Factory that fails to build (drives the (nil, err) construction-error path).
+function _M.new_err()
+ return nil, "construction boom"
+end
+
+-- Factory whose :match errors (drives the (nil, err) match-error path).
+function _M.new_match_err(list)
+ return setmetatable({ list = list }, {
+ __index = {
+ match = function()
+ return nil, "match boom"
+ end,
+ },
+ })
+end
+
+return _M
diff --git a/spec/helpers/fake_ngx.lua b/spec/helpers/fake_ngx.lua
new file mode 100644
index 0000000..b59c6ed
--- /dev/null
+++ b/spec/helpers/fake_ngx.lua
@@ -0,0 +1,38 @@
+-- Skeleton fake ngx/resty harness for FUTURE whole-plugin busted specs
+-- (Strategy 2). Not wired up yet: today we unit-test the pure helper module
+-- directly (spec/authentik_helpers_spec.lua). Flesh this out when adding
+-- coverage for hook methods that touch the OpenResty runtime (sockets, http,
+-- ngx.req, ...). Kept minimal on purpose so it does not pretend to cover more
+-- than it does.
+local _M = {}
+
+-- Install a minimal global `ngx` table good enough to `require` a plugin file
+-- without erroring at load time. Returns the installed table.
+function _M.install()
+ _G.ngx = _G.ngx
+ or {
+ ERR = 1,
+ WARN = 2,
+ INFO = 3,
+ HTTP_INTERNAL_SERVER_ERROR = 500,
+ HTTP_MOVED_TEMPORARILY = 302,
+ req = {
+ get_headers = function()
+ return {}
+ end,
+ set_header = function() end,
+ clear_header = function() end,
+ is_internal = function()
+ return false
+ end,
+ },
+ log = function() end,
+ var = {},
+ escape_uri = function(s)
+ return s
+ end,
+ }
+ return _G.ngx
+end
+
+return _M
diff --git a/spec/matrix_helpers_spec.lua b/spec/matrix_helpers_spec.lua
new file mode 100644
index 0000000..6a9fe29
--- /dev/null
+++ b/spec/matrix_helpers_spec.lua
@@ -0,0 +1,84 @@
+-- luacheck: std min+busted
+local helpers = require("matrix/matrix_helpers")
+
+describe("matrix helpers", function()
+ describe("html_escape", function()
+ it("escapes the three markup-significant characters", function()
+ assert.equals("&<>", helpers.html_escape("&<>"))
+ end)
+ it("escapes inside a larger string", function()
+ assert.equals("a <b> & c", helpers.html_escape("a & c"))
+ end)
+ it("leaves a plain string untouched", function()
+ assert.equals("plain text 1.2.3.4", helpers.html_escape("plain text 1.2.3.4"))
+ end)
+ it("coerces non-string input via tostring", function()
+ assert.equals("123", helpers.html_escape(123))
+ end)
+ end)
+
+ describe("escape_pattern", function()
+ it("escapes every Lua pattern magic character", function()
+ assert.equals("%(%)%.%%%+%-%*%?%[%]%^%$", helpers.escape_pattern("().%+-*?[]^$"))
+ end)
+ it("leaves a magic-free string untouched", function()
+ assert.equals("abc123", helpers.escape_pattern("abc123"))
+ end)
+ it("produces a string usable as a literal gsub pattern", function()
+ local ip = "1.2.3.4"
+ local escaped = helpers.escape_pattern(ip)
+ -- With escaping the dots match literally, so only the exact IP is replaced.
+ assert.equals("X and 1a2a3a4", (string.gsub("1.2.3.4 and 1a2a3a4", escaped, "X")))
+ end)
+ end)
+
+ describe("anonymize_ip", function()
+ it("masks the last two octets of an IPv4 address", function()
+ assert.equals("1.2.xxx.xxx", helpers.anonymize_ip("1.2.3.4"))
+ end)
+ it("masks all but the first three hextets of an IPv6 address", function()
+ assert.equals("2001:db8:1:xxxx", helpers.anonymize_ip("2001:db8:1:2::5"))
+ end)
+ it("masks an IPv4-mapped IPv6 address via the IPv6 branch", function()
+ assert.equals("::ffff:xxxx", helpers.anonymize_ip("::ffff:1.2.3.4"))
+ end)
+ it("falls back to a generic mask for a degenerate IPv6 address", function()
+ assert.equals("xxxx::xxxx", helpers.anonymize_ip("::1"))
+ end)
+ end)
+
+ describe("flatten_header_value", function()
+ it("returns a plain string unchanged", function()
+ assert.equals("text/html", helpers.flatten_header_value("text/html"))
+ end)
+ it("joins a repeated-header table with a comma", function()
+ assert.equals("a, b, c", helpers.flatten_header_value({ "a", "b", "c" }))
+ end)
+ it("coerces a non-string scalar via tostring", function()
+ assert.equals("123", helpers.flatten_header_value(123))
+ end)
+ end)
+
+ describe("redact_header", function()
+ it("redacts the value of sensitive headers", function()
+ assert.equals("[REDACTED]", helpers.redact_header("authorization", "Bearer secret"))
+ assert.equals("[REDACTED]", helpers.redact_header("cookie", "session=abc"))
+ assert.equals("[REDACTED]", helpers.redact_header("set-cookie", "session=abc"))
+ assert.equals("[REDACTED]", helpers.redact_header("x-api-key", "k"))
+ assert.equals("[REDACTED]", helpers.redact_header("x-csrf-token", "t"))
+ assert.equals("[REDACTED]", helpers.redact_header("x-xsrf-token", "t"))
+ assert.equals("[REDACTED]", helpers.redact_header("proxy-authorization", "Basic x"))
+ end)
+ it("matches sensitive header names case-insensitively", function()
+ assert.equals("[REDACTED]", helpers.redact_header("Authorization", "Bearer secret"))
+ assert.equals("[REDACTED]", helpers.redact_header("COOKIE", "session=abc"))
+ end)
+ it("redacts even when a sensitive header is repeated (table value)", function()
+ assert.equals("[REDACTED]", helpers.redact_header("cookie", { "a=1", "b=2" }))
+ end)
+ it("passes non-sensitive headers through, flattening tables", function()
+ assert.equals("example.com", helpers.redact_header("host", "example.com"))
+ assert.equals("gzip, br", helpers.redact_header("accept-encoding", { "gzip", "br" }))
+ end)
+ end)
+end)
diff --git a/spec/slack_helpers_spec.lua b/spec/slack_helpers_spec.lua
new file mode 100644
index 0000000..2e350ba
--- /dev/null
+++ b/spec/slack_helpers_spec.lua
@@ -0,0 +1,39 @@
+-- luacheck: std min+busted
+local helpers = require("slack/slack_helpers")
+
+describe("slack helpers", function()
+ describe("flatten_header_value", function()
+ it("returns a plain string unchanged", function()
+ assert.equals("text/html", helpers.flatten_header_value("text/html"))
+ end)
+ it("joins a repeated-header table with a comma", function()
+ assert.equals("a, b, c", helpers.flatten_header_value({ "a", "b", "c" }))
+ end)
+ it("coerces a non-string scalar via tostring", function()
+ assert.equals("123", helpers.flatten_header_value(123))
+ end)
+ end)
+
+ describe("redact_header", function()
+ it("redacts the value of sensitive headers", function()
+ assert.equals("[REDACTED]", helpers.redact_header("authorization", "Bearer secret"))
+ assert.equals("[REDACTED]", helpers.redact_header("cookie", "session=abc"))
+ assert.equals("[REDACTED]", helpers.redact_header("set-cookie", "session=abc"))
+ assert.equals("[REDACTED]", helpers.redact_header("x-api-key", "k"))
+ assert.equals("[REDACTED]", helpers.redact_header("x-csrf-token", "t"))
+ assert.equals("[REDACTED]", helpers.redact_header("x-xsrf-token", "t"))
+ assert.equals("[REDACTED]", helpers.redact_header("proxy-authorization", "Basic x"))
+ end)
+ it("matches sensitive header names case-insensitively", function()
+ assert.equals("[REDACTED]", helpers.redact_header("Authorization", "Bearer secret"))
+ assert.equals("[REDACTED]", helpers.redact_header("COOKIE", "session=abc"))
+ end)
+ it("redacts even when a sensitive header is repeated (table value)", function()
+ assert.equals("[REDACTED]", helpers.redact_header("cookie", { "a=1", "b=2" }))
+ end)
+ it("passes non-sensitive headers through, flattening tables", function()
+ assert.equals("example.com", helpers.redact_header("host", "example.com"))
+ assert.equals("gzip, br", helpers.redact_header("accept-encoding", { "gzip", "br" }))
+ end)
+ end)
+end)
diff --git a/spec/virustotal_helpers_spec.lua b/spec/virustotal_helpers_spec.lua
new file mode 100644
index 0000000..b156eb6
--- /dev/null
+++ b/spec/virustotal_helpers_spec.lua
@@ -0,0 +1,23 @@
+-- luacheck: std min+busted
+local helpers = require("virustotal/virustotal_helpers")
+
+describe("virustotal helpers", function()
+ describe("evaluate", function()
+ it("returns clean when both counts are within thresholds", function()
+ assert.equals("clean", helpers.evaluate(0, 0, 0, 0))
+ assert.equals("clean", helpers.evaluate(3, 1, 5, 2))
+ end)
+ it("treats a count equal to its threshold as clean (strict >)", function()
+ assert.equals("clean", helpers.evaluate(5, 2, 5, 2))
+ end)
+ it("flags when suspicious exceeds its threshold", function()
+ assert.equals("6 suspicious and 0 malicious", helpers.evaluate(6, 0, 5, 2))
+ end)
+ it("flags when malicious exceeds its threshold", function()
+ assert.equals("0 suspicious and 3 malicious", helpers.evaluate(0, 3, 5, 2))
+ end)
+ it("flags when both exceed", function()
+ assert.equals("10 suspicious and 4 malicious", helpers.evaluate(10, 4, 5, 2))
+ end)
+ end)
+end)
diff --git a/spec/webhook_helpers_spec.lua b/spec/webhook_helpers_spec.lua
new file mode 100644
index 0000000..c36fadf
--- /dev/null
+++ b/spec/webhook_helpers_spec.lua
@@ -0,0 +1,39 @@
+-- luacheck: std min+busted
+local helpers = require("webhook/webhook_helpers")
+
+describe("webhook helpers", function()
+ describe("flatten_header_value", function()
+ it("returns a plain string unchanged", function()
+ assert.equals("text/html", helpers.flatten_header_value("text/html"))
+ end)
+ it("joins a repeated-header table with a comma", function()
+ assert.equals("a, b, c", helpers.flatten_header_value({ "a", "b", "c" }))
+ end)
+ it("coerces a non-string scalar via tostring", function()
+ assert.equals("123", helpers.flatten_header_value(123))
+ end)
+ end)
+
+ describe("redact_header", function()
+ it("redacts the value of sensitive headers", function()
+ assert.equals("[REDACTED]", helpers.redact_header("authorization", "Bearer secret"))
+ assert.equals("[REDACTED]", helpers.redact_header("cookie", "session=abc"))
+ assert.equals("[REDACTED]", helpers.redact_header("set-cookie", "session=abc"))
+ assert.equals("[REDACTED]", helpers.redact_header("x-api-key", "k"))
+ assert.equals("[REDACTED]", helpers.redact_header("x-csrf-token", "t"))
+ assert.equals("[REDACTED]", helpers.redact_header("x-xsrf-token", "t"))
+ assert.equals("[REDACTED]", helpers.redact_header("proxy-authorization", "Basic x"))
+ end)
+ it("matches sensitive header names case-insensitively", function()
+ assert.equals("[REDACTED]", helpers.redact_header("Authorization", "Bearer secret"))
+ assert.equals("[REDACTED]", helpers.redact_header("COOKIE", "session=abc"))
+ end)
+ it("redacts even when a sensitive header is repeated (table value)", function()
+ assert.equals("[REDACTED]", helpers.redact_header("cookie", { "a=1", "b=2" }))
+ end)
+ it("passes non-sensitive headers through, flattening tables", function()
+ assert.equals("example.com", helpers.redact_header("host", "example.com"))
+ assert.equals("gzip, br", helpers.redact_header("accept-encoding", { "gzip", "br" }))
+ end)
+ end)
+end)
diff --git a/templates/README.md b/templates/README.md
new file mode 100644
index 0000000..44b0184
--- /dev/null
+++ b/templates/README.md
@@ -0,0 +1,439 @@
+# BunkerWeb Plugin Generator
+
+A comprehensive shell script for generating BunkerWeb plugin templates with complete structure, documentation, and optional components.
+
+## Overview
+
+The `create_bunkerweb_plugin.sh` script automates the creation of BunkerWeb plugins with proper directory structure, configuration files, and comprehensive documentation. It supports generating plugins with various optional components including web UI, scheduled jobs, NGINX configurations, and configuration templates.
+
+## Features
+
+- **Complete Plugin Structure**: Generates all necessary files and directories
+- **Multisite Support**: All generated plugins use multisite context by default
+- **Optional Components**: Choose what to include in your plugin
+- **Comprehensive Documentation**: Auto-generates detailed README files
+- **Template Files**: Creates example configurations and templates
+- **Validation**: Validates plugin names and parameters
+- **Flexible Configuration**: Supports various stream modes
+
+## Requirements
+
+- `bash` and GNU coreutils (`mkdir`, `cat`, `sed`, `grep`, `tr`, `awk`, `find`)
+- `python3` (used to safely JSON-escape the description)
+- Write permissions in the target directory
+
+## Installation
+
+1. **Clone the repository and run from `templates/`:**
+ ```bash
+ git clone https://github.com/bunkerity/bunkerweb-plugins
+ cd bunkerweb-plugins/templates/
+ chmod +x create_bunkerweb_plugin.sh
+ ```
+
+## Usage
+
+### Basic Syntax
+
+```bash
+./create_bunkerweb_plugin.sh [OPTIONS] PLUGIN_NAME
+```
+
+### Required Parameters
+
+- `PLUGIN_NAME`: Name of the plugin (alphanumeric, hyphens, underscores only)
+- `-d, --description TEXT`: Plugin description (required)
+
+### Options
+
+| Option | Description | Default |
+| ------------------------ | -------------------------------------- | --------------------- |
+| `-h, --help` | Show help message | - |
+| `-d, --description TEXT` | Plugin description | Required |
+| `-v, --version VERSION` | Plugin version | 1.0.0 |
+| `-o, --output DIR` | Output directory | .. (parent directory) |
+| `--stream MODE` | Stream support: no/partial/yes | partial |
+| `--with-ui` | Include web UI components | false |
+| `--with-jobs` | Include job scheduler components | false |
+| `--with-configs` | Include NGINX configuration templates | false |
+| `--with-templates` | Include custom configuration templates | false |
+
+### Examples
+
+#### Basic Plugin
+
+```bash
+./create_bunkerweb_plugin.sh -d "Rate limiting plugin" ratelimit
+```
+
+#### Full-Featured Plugin
+
+```bash
+./create_bunkerweb_plugin.sh \
+ -d "Advanced security monitor" \
+ -v "2.1.0" \
+ --stream yes \
+ --with-ui \
+ --with-jobs \
+ --with-configs \
+ --with-templates \
+ security-monitor
+```
+
+#### Web UI Plugin
+
+```bash
+./create_bunkerweb_plugin.sh \
+ -d "Custom WAF rules with web interface" \
+ --with-ui \
+ customwaf
+```
+
+#### Background Job Plugin
+
+```bash
+./create_bunkerweb_plugin.sh \
+ -d "Log analyzer with daily processing" \
+ --with-jobs \
+ loganalyzer
+```
+
+## Generated Structure
+
+### Basic Plugin Structure
+
+```
+plugin-name/
+├── plugin.json # Plugin metadata and settings
+├── plugin-name.lua # Main Lua execution file
+├── README.md # Comprehensive documentation
+└── docs/ # Documentation assets
+ └── diagram.mmd # Mermaid architecture diagram (also embedded inline in README)
+```
+
+### With UI Components (`--with-ui`)
+
+```
+plugin-name/
+└── ui/
+ └── actions.py # BunkerWeb UI hooks (pre_render + page handler)
+```
+
+### With Job Scheduler (`--with-jobs`)
+
+```
+plugin-name/
+├── jobs/
+│ └── plugin-name-job.py # Scheduled job script (daily by default)
+```
+
+### With NGINX Configs (`--with-configs`)
+
+```
+plugin-name/
+├── confs/
+│ ├── server-http/ # Server-level HTTP configurations
+│ ├── http/ # HTTP-level configurations
+│ ├── default-server-http/ # Default server configurations
+│ ├── stream/ # Stream-level configurations (stream mode only)
+│ └── server-stream/ # Server-level stream configurations (stream mode only)
+```
+
+### With Templates (`--with-templates`)
+
+```
+plugin-name/
+├── templates/
+│ ├── plugin-name-template.json # Default template (BunkerWeb step schema)
+│ ├── plugin-name-dev.json # Development template
+│ └── plugin-name-prod.json # Production template
+```
+
+## Plugin Features
+
+### Generated Plugin Capabilities
+
+All generated plugins include:
+
+- **Multisite Context**: Global and per-service configuration support
+- **Lifecycle Hooks**: `access`, `log`, `log_default` (plus `preread`/`log_stream` when stream mode is enabled)
+- **Comprehensive Logging**: Configurable log levels
+- **Settings Validation**: Regex validation on every setting in `plugin.json`
+- **Error Handling**: Each hook is gated on the enable setting
+
+With `--with-configs`, the plugin ships NGINX config snippets (`confs/server-http`, `confs/http`, `confs/default-server-http`, and stream configs in stream mode). The server-http snippet includes a commented example location — the scaffold ships no live, unauthenticated endpoint by default.
+
+### Default Settings
+
+Every plugin generates with these configurable settings (`` is the upper-cased plugin id):
+
+| Setting | Description | Default | Validation |
+| ------------------------- | --------------------- | ------------- | --------------------- |
+| `USE_` | Enable/disable plugin | no | yes/no |
+| `PLUGIN__SETTING` | Main configuration | default_value | Any string |
+| `PLUGIN__TIMEOUT` | Operation timeout | 5 | 1-300 seconds |
+| `PLUGIN__LOG_LEVEL` | Log verbosity | INFO | DEBUG/INFO/WARN/ERROR |
+
+### Multisite Configuration
+
+Generated plugins support both global and per-service configuration:
+
+```bash
+# Global configuration
+USE_MYPLUGIN=yes
+PLUGIN_MYPLUGIN_SETTING=global_value
+
+# Per-service configuration
+app1.example.com_USE_MYPLUGIN=yes
+app1.example.com_PLUGIN_MYPLUGIN_SETTING=service_specific_value
+```
+
+## Web UI Components
+
+When using `--with-ui`, the script generates a single `ui/actions.py` that follows the
+BunkerWeb UI plugin contract:
+
+- `pre_render(**kwargs)` — returns card data (a `ping_status` card) for the plugin's UI page, using `kwargs["bw_instances_utils"].get_ping(...)`
+- `(**kwargs)` — the page handler BunkerWeb resolves by id (a stub to extend). For hyphenated ids the generator also exposes it under the exact id, since a hyphen is not a valid Python function name.
+
+See any shipped plugin's `ui/actions.py` (e.g. `clamav/ui/actions.py`) for the reference.
+
+## Job Scheduler
+
+When using `--with-jobs`, the script generates:
+
+- **Daily Jobs**: Default daily execution frequency
+- **Configurable Frequency**: Easy to change to hourly, weekly, or monthly
+- **Data Processing**: Template for log processing and analytics
+- **Health Checks**: Automated plugin health validation
+- **Cleanup Tasks**: Automated old data cleanup
+- **Comprehensive Logging**: Detailed job execution logs
+
+Available job frequencies:
+
+- `hour` - Run every hour
+- `daily` - Run once per day (default)
+- `weekly` - Run once per week
+- `monthly` - Run once per month
+
+## NGINX Configuration Templates
+
+When using `--with-configs`, the script generates:
+
+### HTTP Configurations
+
+- **server-http**: Server block configurations
+- **http**: HTTP block configurations
+- **default-server-http**: Default server configurations
+
+### Stream Configurations
+
+Generated only when stream mode is `partial` or `yes`:
+
+- **stream**: Stream block configurations
+- **server-stream**: Stream server configurations
+
+## Configuration Templates
+
+When using `--with-templates`, the script generates:
+
+- **Development Template**: Permissive settings for development
+- **Production Template**: Strict settings for production
+- **Custom Template**: Template with custom NGINX configurations
+- **Template Structure**: Organized template hierarchy
+
+## Stream Support
+
+The `--stream` option configures TCP/UDP protocol support:
+
+- `no`: HTTP only (default for web-focused plugins)
+- `partial`: HTTP primary with stream support (recommended)
+- `yes`: Full stream support (for TCP/UDP focused plugins)
+
+## Validation Rules
+
+### Plugin Name Validation
+
+- Only alphanumeric characters, hyphens, and underscores
+- Maximum 50 characters
+- Cannot be empty
+
+### Parameter Validation
+
+- Stream mode: no, partial, or yes
+- Output directory must exist
+- Description cannot be empty
+
+## Generated Documentation
+
+The script automatically generates:
+
+### Plugin README.md
+
+- Complete installation instructions
+- Configuration examples for Docker, Kubernetes
+- Usage documentation with API examples
+- Development guidelines
+- Troubleshooting section
+- File structure documentation
+
+### Project README.md (if not exists)
+
+- Project overview and structure
+- Plugin development workflow
+- Contributing guidelines
+- Best practices and standards
+
+## Installation Integration
+
+### Docker Integration
+
+```bash
+# Copy plugin to BunkerWeb
+cp -r plugin-name /path/to/bw-data/plugins/
+
+# Set permissions
+chown -R 101:101 /path/to/bw-data/plugins/plugin-name
+chmod -R 750 /path/to/bw-data/plugins/plugin-name
+```
+
+### Linux Integration
+
+```bash
+# Copy plugin to BunkerWeb
+cp -r plugin-name /etc/bunkerweb/plugins/
+
+# Set permissions
+chown -R root:nginx /etc/bunkerweb/plugins/plugin-name
+chmod -R 750 /etc/bunkerweb/plugins/plugin-name
+
+# Restart BunkerWeb
+systemctl restart bunkerweb
+```
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Permission Denied**
+
+ ```bash
+ chmod +x create_bunkerweb_plugin.sh
+ ```
+
+2. **Directory Exists Error**
+
+ - Plugin directory already exists
+ - Choose different name or remove existing directory
+
+3. **Invalid Plugin Name**
+
+ - Use only alphanumeric characters, hyphens, underscores
+ - Maximum 50 characters
+
+4. **Missing Description**
+ - Description is required: use `-d "Your description"`
+
+### Debugging
+
+Run with debug output:
+
+```bash
+bash -x create_bunkerweb_plugin.sh -d "Test plugin" testplugin
+```
+
+### File Permissions
+
+Ensure the script has execute permissions:
+
+```bash
+ls -la create_bunkerweb_plugin.sh
+# Should show: -rwxr-xr-x
+```
+
+## Examples by Use Case
+
+### Security Plugin
+
+```bash
+./create_bunkerweb_plugin.sh \
+ -d "Advanced threat detection and blocking" \
+ --stream partial \
+ --with-configs \
+ threat-detector
+```
+
+### Analytics Plugin
+
+```bash
+./create_bunkerweb_plugin.sh \
+ -d "Request analytics and reporting" \
+ --with-jobs \
+ --with-ui \
+ analytics
+```
+
+### Rate Limiting Plugin
+
+```bash
+./create_bunkerweb_plugin.sh \
+ -d "Intelligent rate limiting with adaptive thresholds" \
+ --with-configs \
+ --with-templates \
+ smart-ratelimit
+```
+
+### Content Filter Plugin
+
+```bash
+./create_bunkerweb_plugin.sh \
+ -d "Content filtering and transformation" \
+ --stream no \
+ content-filter
+```
+
+## Best Practices
+
+### Plugin Development
+
+1. **Start Simple**: Begin with basic plugin, add components as needed
+2. **Use Templates**: Leverage generated templates for consistency
+3. **Test Thoroughly**: Test with both single-site and multisite configurations
+4. **Document Everything**: Update README.md with your specific functionality
+5. **Follow Conventions**: Use generated naming patterns and structure
+
+### Configuration Management
+
+1. **Global Defaults**: Set reasonable global defaults
+2. **Service Overrides**: Override only specific settings per service
+3. **Environment Separation**: Use different configurations for dev/staging/prod
+4. **Security Levels**: Apply stricter settings to production services
+
+### Performance Optimization
+
+1. **Timeout Settings**: Set realistic timeouts for operations
+2. **Log Levels**: Use INFO or WARN for production
+3. **Resource Management**: Clean up resources properly
+
+## Contributing
+
+1. Fork the repository
+2. Create a feature branch
+3. Make your changes
+4. Test with various plugin configurations
+5. Update documentation
+6. Submit a pull request
+
+## Support
+
+- **BunkerWeb Documentation**: [docs.bunkerweb.io](https://docs.bunkerweb.io/)
+- **Plugin Development**: [Plugin Documentation](https://docs.bunkerweb.io/latest/plugins/)
+- **Community**: [Discord Server](https://bunkerity.discord.com/)
+
+## License
+
+This script is provided as-is for BunkerWeb plugin development. Check individual plugin licenses as generated.
+
+---
+
+**Happy Plugin Development!** 🚀
diff --git a/templates/create_bunkerweb_plugin.sh b/templates/create_bunkerweb_plugin.sh
new file mode 100644
index 0000000..c373ed2
--- /dev/null
+++ b/templates/create_bunkerweb_plugin.sh
@@ -0,0 +1,1276 @@
+#!/bin/bash
+
+# Requires bash and GNU coreutils (mkdir, cat, sed, grep, tr, awk, find).
+set -eu
+
+# Colors — suppressed when neither stdout/stderr is a TTY, or NO_COLOR is set
+# (https://no-color.org/).
+if { [ ! -t 1 ] && [ ! -t 2 ]; } || [ -n "${NO_COLOR:-}" ]; then
+ RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC=''
+else
+ RED='\033[0;31m'
+ GREEN='\033[0;32m'
+ YELLOW='\033[1;33m'
+ BLUE='\033[0;34m'
+ BOLD='\033[1m'
+ NC='\033[0m'
+fi
+
+print_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
+print_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
+print_success() { echo -e "${GREEN}[OK]${NC} $1"; }
+print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
+print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
+
+show_usage() {
+ cat << 'EOF'
+Usage: ./create_bunkerweb_plugin.sh [OPTIONS] PLUGIN_NAME
+
+Create a new BunkerWeb plugin template with proper structure and files.
+
+OPTIONS:
+ -h, --help Show this help message
+ -d, --description TEXT Plugin description (required)
+ -v, --version VERSION Plugin version (default: 1.0.0)
+ -o, --output DIR Output directory (default: parent directory)
+ --stream MODE Stream support: no|partial|yes (default: partial)
+ --with-ui Include web UI components
+ --with-jobs Include job scheduler components (day frequency)
+ --with-configs Include NGINX configuration templates
+ --with-templates Include custom configuration templates
+
+EXAMPLES:
+ ./create_bunkerweb_plugin.sh -d "Rate limiting plugin" ratelimit
+ ./create_bunkerweb_plugin.sh -d "Custom WAF rules" -v "2.1.0" --with-ui customwaf
+ ./create_bunkerweb_plugin.sh -d "Log analyzer" --with-jobs loganalyzer
+ ./create_bunkerweb_plugin.sh -d "Security monitor" --with-jobs --with-ui monitor
+
+NOTE:
+- Script creates plugins in parent directory by default (assumes run from templates/)
+- Creates project README.md template if it doesn't exist
+- Jobs default to daily frequency. Edit plugin.json to change to minute/hour/day/week/once.
+EOF
+}
+
+# JSON-escape a string for safe interpolation into generated plugin.json
+# (handles backslash, double-quote and control characters).
+json_escape() {
+ printf '%s' "$1" | python3 -c 'import json,sys; sys.stdout.write(json.dumps(sys.stdin.read())[1:-1])'
+}
+
+# Title-case a plugin name (split on - / _), portably (no GNU sed \U / \|).
+title_case() {
+ printf '%s' "$1" | awk '
+ BEGIN { FS = "[-_]"; OFS = "" }
+ {
+ for (i = 1; i <= NF; i++) {
+ $i = toupper(substr($i, 1, 1)) substr($i, 2)
+ }
+ print
+ }'
+}
+
+validate_plugin_name() {
+ local name="$1"
+
+ if [ -z "$name" ]; then
+ print_error "Plugin name is required"
+ return 1
+ fi
+
+ if echo "$name" | grep -q '[^a-zA-Z0-9_-]'; then
+ print_error "Plugin name must contain only alphanumeric characters, hyphens, and underscores"
+ return 1
+ fi
+
+ if [ "${#name}" -gt 50 ]; then
+ print_error "Plugin name must be 50 characters or less"
+ return 1
+ fi
+
+ return 0
+}
+
+create_directory_structure() {
+ local plugin_dir="$1"
+
+ mkdir -p "$plugin_dir"
+
+ if [ "$WITH_UI" = "yes" ]; then
+ mkdir -p "$plugin_dir/ui"
+ fi
+
+ if [ "$WITH_JOBS" = "yes" ]; then
+ mkdir -p "$plugin_dir/jobs"
+ fi
+
+ if [ "$WITH_CONFIGS" = "yes" ]; then
+ mkdir -p "$plugin_dir/confs/server-http"
+ mkdir -p "$plugin_dir/confs/http"
+ mkdir -p "$plugin_dir/confs/default-server-http"
+ # Stream configs only make sense when the plugin supports stream mode.
+ if [ "$STREAM_MODE" != "no" ]; then
+ mkdir -p "$plugin_dir/confs/stream"
+ mkdir -p "$plugin_dir/confs/server-stream"
+ fi
+ fi
+
+ if [ "$WITH_TEMPLATES" = "yes" ]; then
+ mkdir -p "$plugin_dir/templates"
+ fi
+}
+
+create_docs() {
+ local plugin_dir="$1"
+
+ mkdir -p "$plugin_dir/docs"
+
+ if [ -f "template_diagram.mmd" ]; then
+ cp "template_diagram.mmd" "$plugin_dir/docs/diagram.mmd"
+ fi
+}
+
+generate_plugin_json() {
+ local plugin_dir="$1"
+ local plugin_name="$2"
+ local plugin_name_upper
+ plugin_name_upper=$(echo "$plugin_name" | tr '[:lower:]-' '[:upper:]_')
+
+ cat > "$plugin_dir/plugin.json" << EOF
+{
+ "id": "$plugin_name",
+ "name": "$(title_case "$plugin_name")",
+ "description": "$(json_escape "$DESCRIPTION")",
+ "version": "$VERSION",
+ "stream": "$STREAM_MODE",
+ "settings": {
+ "USE_${plugin_name_upper}": {
+ "context": "multisite",
+ "default": "no",
+ "help": "Enable or disable the $plugin_name plugin.",
+ "id": "use-${plugin_name}",
+ "label": "Use ${plugin_name}",
+ "regex": "^(yes|no)$",
+ "type": "check"
+ },
+ "PLUGIN_${plugin_name_upper}_SETTING": {
+ "context": "multisite",
+ "default": "default_value",
+ "help": "Configure the main setting for $plugin_name plugin.",
+ "id": "plugin-${plugin_name}-setting",
+ "label": "${plugin_name} Setting",
+ "regex": "^.*$",
+ "type": "text"
+ },
+ "PLUGIN_${plugin_name_upper}_TIMEOUT": {
+ "context": "multisite",
+ "default": "5",
+ "help": "Timeout (in seconds) for $plugin_name plugin operations (1-300).",
+ "id": "plugin-${plugin_name}-timeout",
+ "label": "${plugin_name} Timeout",
+ "regex": "^([1-9][0-9]?|[12][0-9]{2}|300)$",
+ "type": "text"
+ },
+ "PLUGIN_${plugin_name_upper}_LOG_LEVEL": {
+ "context": "multisite",
+ "default": "INFO",
+ "help": "Log verbosity for the $plugin_name plugin.",
+ "id": "plugin-${plugin_name}-log-level",
+ "label": "${plugin_name} Log Level",
+ "regex": "^(DEBUG|INFO|WARN|ERROR)$",
+ "select": ["DEBUG", "INFO", "WARN", "ERROR"],
+ "type": "select"
+ }
+ }$([ "$WITH_JOBS" = "yes" ] && echo ',
+ "jobs": [
+ {
+ "name": "'"$plugin_name"'-job",
+ "file": "'"$plugin_name"'-job.py",
+ "every": "day",
+ "reload": false
+ }
+ ]' || echo "")
+}
+EOF
+}
+
+generate_lua_file() {
+ local plugin_dir="$1"
+ local plugin_name="$2"
+ local plugin_name_upper
+ plugin_name_upper=$(echo "$plugin_name" | tr '[:lower:]-' '[:upper:]_')
+ # Lua identifiers cannot contain hyphens; derive a safe variable name while
+ # keeping the original plugin id for the BW-facing strings.
+ local plugin_ident
+ plugin_ident=$(echo "$plugin_name" | tr '-' '_')
+ local lua_file="$plugin_dir/$plugin_name.lua"
+
+ cat > "$lua_file" << 'EOF'
+local class = require "middleclass"
+local plugin = require "bunkerweb.plugin"
+
+local PLUGIN_NAME_LOWER = class("PLUGIN_ID", plugin)
+
+function PLUGIN_NAME_LOWER:initialize(ctx)
+ plugin.initialize(self, "PLUGIN_ID", ctx)
+end
+
+function PLUGIN_NAME_LOWER:access()
+ if self.variables["USE_PLUGIN_NAME_UPPER"] ~= "yes" then
+ return self:ret(true, "plugin disabled")
+ end
+
+ self.logger:log(ngx.NOTICE, "access called")
+ return self:ret(true, "success")
+end
+
+function PLUGIN_NAME_LOWER:log()
+ if self.variables["USE_PLUGIN_NAME_UPPER"] ~= "yes" then
+ return self:ret(true, "plugin disabled")
+ end
+
+ self.logger:log(ngx.NOTICE, "log called")
+ return self:ret(true, "success")
+end
+
+function PLUGIN_NAME_LOWER:log_default()
+ if self.variables["USE_PLUGIN_NAME_UPPER"] ~= "yes" then
+ return self:ret(true, "plugin disabled")
+ end
+
+ self.logger:log(ngx.NOTICE, "log_default called")
+ return self:ret(true, "success")
+end
+EOF
+
+ # Stream-context hooks only make sense when the plugin supports stream mode.
+ if [ "$STREAM_MODE" != "no" ]; then
+ cat >> "$lua_file" << 'EOF'
+
+function PLUGIN_NAME_LOWER:preread()
+ if self.variables["USE_PLUGIN_NAME_UPPER"] ~= "yes" then
+ return self:ret(true, "plugin disabled")
+ end
+
+ self.logger:log(ngx.NOTICE, "preread called")
+ return self:ret(true, "success")
+end
+
+function PLUGIN_NAME_LOWER:log_stream()
+ if self.variables["USE_PLUGIN_NAME_UPPER"] ~= "yes" then
+ return self:ret(true, "plugin disabled")
+ end
+
+ self.logger:log(ngx.NOTICE, "log_stream called")
+ return self:ret(true, "success")
+end
+EOF
+ fi
+
+ cat >> "$lua_file" << 'EOF'
+
+return PLUGIN_NAME_LOWER
+EOF
+
+ sed -i.bak "s|PLUGIN_NAME_LOWER|${plugin_ident}|g" "$lua_file"
+ sed -i.bak "s|PLUGIN_ID|${plugin_name}|g" "$lua_file"
+ sed -i.bak "s|USE_PLUGIN_NAME_UPPER|USE_${plugin_name_upper}|g" "$lua_file"
+ rm -f "$lua_file.bak"
+}
+
+generate_ui_components() {
+ local plugin_dir="$1"
+ local plugin_name="$2"
+ local plugin_name_upper
+ plugin_name_upper=$(echo "$plugin_name" | tr '[:lower:]-' '[:upper:]_')
+ # Python function names cannot contain hyphens; for hyphen-free ids this is
+ # identical to the id (which is what BunkerWeb looks up for the page).
+ local plugin_ident
+ plugin_ident=$(echo "$plugin_name" | tr '-' '_')
+
+ # BunkerWeb UI plugin contract: pre_render(**kwargs) returns card data for
+ # the plugin's UI page, and BunkerWeb resolves the page handler via
+ # getattr(actions, ""). See clamav/ui/actions.py for the reference.
+ cat > "$plugin_dir/ui/actions.py" << EOF
+from logging import getLogger
+from traceback import format_exc
+
+
+def pre_render(**kwargs):
+ logger = getLogger("UI")
+ ret = {
+ "ping_status": {
+ "title": "${plugin_name_upper} STATUS",
+ "value": "error",
+ "col-size": "col-12 col-md-6",
+ "card-classes": "h-100",
+ },
+ }
+ try:
+ ping_data = kwargs["bw_instances_utils"].get_ping("$plugin_name")
+ ret["ping_status"]["value"] = ping_data["status"]
+ except BaseException as e:
+ logger.debug(format_exc())
+ logger.error(f"Failed to get $plugin_name ping: {e}")
+ ret["error"] = str(e)
+
+ return ret
+
+
+def $plugin_ident(**kwargs):
+ pass
+EOF
+
+ # BunkerWeb looks the page handler up by the raw id via getattr(actions, "").
+ # When the id contains a hyphen it is not a valid Python function name, so
+ # expose the handler under the exact id as well.
+ if [ "$plugin_name" != "$plugin_ident" ]; then
+ cat >> "$plugin_dir/ui/actions.py" << EOF
+
+
+globals()["$plugin_name"] = $plugin_ident
+EOF
+ fi
+}
+
+generate_job_files() {
+ local plugin_dir="$1"
+ local plugin_name="$2"
+ local plugin_name_upper
+ plugin_name_upper=$(echo "$plugin_name" | tr '[:lower:]-' '[:upper:]_')
+
+ cat > "$plugin_dir/jobs/$plugin_name-job.py" << EOF
+#!/usr/bin/env python3
+
+import os
+import sys
+import time
+import json
+import logging
+from datetime import datetime, timedelta
+from pathlib import Path
+
+
+class PluginJob:
+ """
+ Main job class for $plugin_name plugin scheduled tasks
+ """
+
+ def __init__(self):
+ self.plugin_name = "$plugin_name"
+ self.version = "$VERSION"
+ self.logger = self.setup_logging()
+ self.config = self.load_configuration()
+
+ def setup_logging(self):
+ """
+ Configure logging for the job
+ """
+ log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+ logging.basicConfig(level=logging.INFO, format=log_format)
+ return logging.getLogger(f"{self.plugin_name}-job")
+
+ def load_configuration(self):
+ """
+ Load plugin configuration from environment variables
+ """
+ return {
+ "enabled": os.getenv("USE_${plugin_name_upper}", "no") == "yes",
+ "setting": os.getenv("PLUGIN_${plugin_name_upper}_SETTING", "default_value"),
+ "timeout": int(os.getenv("PLUGIN_${plugin_name_upper}_TIMEOUT", "5")),
+ "log_level": os.getenv("PLUGIN_${plugin_name_upper}_LOG_LEVEL", "INFO"),
+ }
+
+ def run(self):
+ """
+ Main job execution method
+ """
+ try:
+ self.logger.info(f"Starting {self.plugin_name} job execution")
+
+ if not self.config["enabled"]:
+ self.logger.info("Plugin disabled, skipping job execution")
+ return True
+
+ cleanup_success = self.cleanup_old_data()
+ processing_success = self.process_data()
+ health_success = self.perform_health_checks()
+
+ all_success = cleanup_success and processing_success and health_success
+
+ if all_success:
+ self.logger.info(f"{self.plugin_name} job completed successfully")
+ else:
+ self.logger.warning(
+ f"{self.plugin_name} job completed with some failures"
+ )
+
+ return all_success
+
+ except Exception as e:
+ self.logger.error(f"Job execution failed: {str(e)}")
+ return False
+
+ def cleanup_old_data(self):
+ """
+ Clean up old log files and temporary data
+ """
+ try:
+ self.logger.info("Starting data cleanup")
+
+ # Only ever delete from this plugin's own private directory.
+ # Never touch shared BunkerWeb dirs (e.g. /var/log/bunkerweb,
+ # /tmp/bunkerweb) — other plugins and the core write there too.
+ cleanup_paths = [f"/tmp/{self.plugin_name}/"]
+
+ cutoff_date = datetime.now() - timedelta(days=7)
+ files_removed = 0
+
+ for cleanup_path in cleanup_paths:
+ if os.path.exists(cleanup_path):
+ for file_path in Path(cleanup_path).glob("**/*"):
+ if file_path.is_file():
+ file_modified = datetime.fromtimestamp(
+ file_path.stat().st_mtime
+ )
+ if file_modified < cutoff_date:
+ file_path.unlink()
+ files_removed += 1
+
+ self.logger.info(f"Cleanup completed. Removed {files_removed} old files")
+ return True
+
+ except Exception as e:
+ self.logger.error(f"Cleanup failed: {str(e)}")
+ return False
+
+ def process_data(self):
+ """
+ Process accumulated data and generate reports
+ """
+ try:
+ if not self.config["enabled"]:
+ self.logger.info("Plugin disabled, skipping data processing")
+ return True
+
+ start_time = time.time()
+
+ processed_requests = self.process_request_logs()
+
+ stats = self.generate_statistics(processed_requests)
+
+ self.save_processed_data(stats)
+
+ processing_time = time.time() - start_time
+ self.logger.info(
+ f"Data processing completed in {processing_time:.2f} seconds. "
+ f"Processed {len(processed_requests)} requests"
+ )
+ return True
+
+ except Exception as e:
+ self.logger.error(f"Data processing failed: {str(e)}")
+ return False
+
+ def process_request_logs(self):
+ """
+ Process request logs and extract relevant data
+ """
+ processed_requests = []
+
+ log_pattern = f"*{self.plugin_name}*"
+ log_files = list(Path("/var/log/bunkerweb/").glob(log_pattern))
+
+ for log_file in log_files:
+ try:
+ with open(log_file, "r") as f:
+ for line in f:
+ if self.plugin_name in line:
+ request_data = self.parse_log_line(line)
+ if request_data:
+ processed_requests.append(request_data)
+ except Exception as e:
+ self.logger.warning(f"Failed to process log file {log_file}: {str(e)}")
+
+ return processed_requests
+
+ def parse_log_line(self, line):
+ """
+ Parse individual log line and extract request data
+ """
+ try:
+ parts = line.strip().split()
+ if len(parts) >= 6:
+ return {
+ "timestamp": parts[0] + " " + parts[1],
+ "level": parts[3],
+ "message": " ".join(parts[5:]),
+ }
+ except Exception:
+ pass
+
+ return None
+
+ def generate_statistics(self, processed_requests):
+ """
+ Generate statistics from processed request data
+ """
+ total_requests = len(processed_requests)
+
+ level_counts = {}
+ for request in processed_requests:
+ level = request.get("level", "UNKNOWN")
+ level_counts[level] = level_counts.get(level, 0) + 1
+
+ return {
+ "total_requests": total_requests,
+ "level_distribution": level_counts,
+ "generated_at": datetime.now().isoformat(),
+ "plugin_version": self.version,
+ }
+
+ def save_processed_data(self, stats):
+ """
+ Save processed statistics data
+ """
+ stats_file = f"/var/log/bunkerweb/{self.plugin_name}-stats.json"
+
+ try:
+ with open(stats_file, "w") as f:
+ json.dump(stats, f, indent=2)
+ self.logger.info(f"Statistics saved to {stats_file}")
+ except Exception as e:
+ self.logger.error(f"Failed to save statistics: {str(e)}")
+
+ def perform_health_checks(self):
+ """
+ Perform health checks and validation
+ """
+ try:
+ self.logger.info("Performing health checks")
+
+ checks = [
+ self.check_plugin_configuration(),
+ self.check_system_resources(),
+ self.check_log_file_permissions(),
+ ]
+
+ all_healthy = all(checks)
+
+ if all_healthy:
+ self.logger.info("All health checks passed")
+ else:
+ self.logger.warning("Some health checks failed")
+
+ return all_healthy
+
+ except Exception as e:
+ self.logger.error(f"Health checks failed: {str(e)}")
+ return False
+
+ def check_plugin_configuration(self):
+ """
+ Validate plugin configuration
+ """
+ try:
+ required_configs = ["setting", "timeout", "log_level"]
+
+ for config in required_configs:
+ if config not in self.config:
+ self.logger.error(f"Missing required configuration: {config}")
+ return False
+
+ if not (1 <= self.config["timeout"] <= 300):
+ self.logger.error(f"Invalid timeout value: {self.config['timeout']}")
+ return False
+
+ valid_log_levels = ["DEBUG", "INFO", "WARN", "ERROR"]
+ if self.config["log_level"] not in valid_log_levels:
+ self.logger.error(f"Invalid log level: {self.config['log_level']}")
+ return False
+
+ return True
+
+ except Exception as e:
+ self.logger.error(f"Configuration check failed: {str(e)}")
+ return False
+
+ def check_system_resources(self):
+ """
+ Check system resource availability
+ """
+ try:
+ import psutil
+
+ memory_usage = psutil.virtual_memory().percent
+ disk_usage = psutil.disk_usage("/").percent
+ cpu_usage = psutil.cpu_percent(interval=1)
+
+ if memory_usage > 90:
+ self.logger.warning(f"High memory usage: {memory_usage}%")
+ return False
+
+ if disk_usage > 90:
+ self.logger.warning(f"High disk usage: {disk_usage}%")
+ return False
+
+ if cpu_usage > 90:
+ self.logger.warning(f"High CPU usage: {cpu_usage}%")
+ return False
+
+ self.logger.info(
+ f"System resources OK - Memory: {memory_usage}%, "
+ f"Disk: {disk_usage}%, CPU: {cpu_usage}%"
+ )
+ return True
+
+ except ImportError:
+ self.logger.info("psutil not available, skipping resource checks")
+ return True
+ except Exception as e:
+ self.logger.error(f"Resource check failed: {str(e)}")
+ return False
+
+ def check_log_file_permissions(self):
+ """
+ Check log file permissions and accessibility
+ """
+ try:
+ log_files = [
+ "/var/log/bunkerweb/error.log",
+ f"/var/log/bunkerweb/{self.plugin_name}.log",
+ ]
+
+ for log_file in log_files:
+ if os.path.exists(log_file):
+ if not os.access(log_file, os.R_OK | os.W_OK):
+ self.logger.error(
+ f"Insufficient permissions for log file: {log_file}"
+ )
+ return False
+
+ return True
+
+ except Exception as e:
+ self.logger.error(f"Log file permission check failed: {str(e)}")
+ return False
+
+
+def main():
+ """
+ Main entry point for the job
+ """
+ job = PluginJob()
+ success = job.run()
+
+ if success:
+ print(f"{job.plugin_name} job completed successfully")
+ sys.exit(0)
+ else:
+ print(f"{job.plugin_name} job completed with errors")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
+EOF
+}
+
+generate_config_templates() {
+ local plugin_dir="$1"
+ local plugin_name="$2"
+ local plugin_name_upper
+ plugin_name_upper=$(echo "$plugin_name" | tr '[:lower:]-' '[:upper:]_')
+ # nginx identifiers (log_format / lua_shared_dict names) cannot contain
+ # hyphens; use a safe form while keeping the original id for URL paths.
+ local plugin_ident
+ plugin_ident=$(echo "$plugin_name" | tr '-' '_')
+
+ cat > "$plugin_dir/confs/server-http/$plugin_name.conf" << EOF
+# $plugin_name Plugin - Server HTTP Configuration
+#
+# Server-level (per-vhost) NGINX directives for the $plugin_name plugin. This
+# file is included inside each enabled server block.
+#
+# Example: a custom location served by the plugin. Left commented so the scaffold
+# ships no live, unauthenticated endpoint by default — uncomment and adapt, and
+# add your own access control (do not expose anything sensitive).
+#
+# {% if USE_${plugin_name_upper} == "yes" %}
+#
+# location /$plugin_name/example {
+# access_by_lua_block {
+# ngx.header.content_type = "application/json"
+# ngx.say('{"plugin":"$plugin_name","status":"ok"}')
+# ngx.exit(200)
+# }
+# }
+#
+# {% endif %}
+EOF
+
+ cat > "$plugin_dir/confs/http/$plugin_name.conf" << EOF
+# $plugin_name Plugin - HTTP Configuration
+
+{% if USE_${plugin_name_upper} == "yes" %}
+
+# Custom log format for plugin
+log_format ${plugin_ident}_custom
+ '\$remote_addr - \$remote_user [\$time_local] '
+ '"\$request" \$status \$body_bytes_sent '
+ '"\$http_referer" "\$http_user_agent" '
+ '${plugin_name}_setting="{{ PLUGIN_${plugin_name_upper}_SETTING }}" '
+ '${plugin_name}_timeout={{ PLUGIN_${plugin_name_upper}_TIMEOUT }} '
+ 'request_time=\$request_time '
+ 'upstream_response_time=\$upstream_response_time';
+
+# Shared memory zone for plugin data
+lua_shared_dict plugin_${plugin_ident}_cache 10m;
+lua_shared_dict plugin_${plugin_ident}_stats 5m;
+
+{% endif %}
+EOF
+
+ cat > "$plugin_dir/confs/default-server-http/$plugin_name.conf" << EOF
+# $plugin_name Plugin - Default Server Configuration
+
+{% if USE_${plugin_name_upper} == "yes" %}
+
+# Block plugin endpoints on default server
+location /$plugin_name {
+ return 444;
+}
+
+{% endif %}
+EOF
+
+ # ModSecurity configurations disabled by default due to syntax complexity
+ # Uncomment and customize if needed:
+
+ # cat > "$plugin_dir/confs/modsec/$plugin_name.conf" << EOF
+ # # $plugin_name Plugin - ModSecurity Configuration
+ # # NOTE: ModSecurity rules disabled by default
+ # # Uncomment and test carefully before enabling
+ #
+ # # {% if USE_${plugin_name_upper} == "yes" %}
+ # #
+ # # # Custom ModSecurity rules for $plugin_name plugin
+ # # SecRule REQUEST_URI "@beginsWith /$plugin_name" \\
+ # # "pass,\\
+ # # id:${plugin_name}001,\\
+ # # phase:1,\\
+ # # msg:'$plugin_name plugin: Processing plugin request',\\
+ # # tag:'$plugin_name',\\
+ # # logdata:'Plugin setting: {{ PLUGIN_${plugin_name_upper}_SETTING }}',\\
+ # # rev:'1'"
+ # #
+ # # {% endif %}
+ # EOF
+
+ # Stream configs only when the plugin supports stream mode.
+ if [ "$STREAM_MODE" != "no" ]; then
+ cat > "$plugin_dir/confs/stream/$plugin_name.conf" << EOF
+# $plugin_name Plugin - Stream Configuration
+
+{% if USE_${plugin_name_upper} == "yes" and LISTEN_STREAM == "yes" %}
+
+# Shared memory for stream plugin data
+lua_shared_dict stream_plugin_${plugin_ident}_cache 5m;
+
+# Log format for stream connections
+log_format ${plugin_ident}_stream
+ '\$remote_addr [\$time_local] '
+ '\$protocol \$status \$bytes_sent \$bytes_received '
+ '\$session_time '
+ '${plugin_name}_setting="{{ PLUGIN_${plugin_name_upper}_SETTING }}"';
+
+{% endif %}
+EOF
+
+ cat > "$plugin_dir/confs/server-stream/$plugin_name.conf" << EOF
+# $plugin_name Plugin - Server Stream Configuration
+
+{% if USE_${plugin_name_upper} == "yes" and LISTEN_STREAM == "yes" %}
+
+# Custom stream processing
+preread_by_lua_block {
+ local plugin_setting = "{{ PLUGIN_${plugin_name_upper}_SETTING }}"
+ ngx.log(ngx.INFO, "$plugin_name: Processing stream connection with setting: " .. plugin_setting)
+}
+
+{% endif %}
+EOF
+ fi
+}
+
+generate_custom_templates() {
+ local plugin_dir="$1"
+ local plugin_name="$2"
+ local plugin_name_upper
+ plugin_name_upper=$(echo "$plugin_name" | tr '[:lower:]-' '[:upper:]_')
+
+ # BunkerWeb template schema: a top-level "settings" map of value overrides,
+ # plus "steps" that group setting NAMES under a title/subtitle for the UI
+ # wizard (both "title" and "subtitle" are required). See the core "templates"
+ # plugin for the reference format.
+ write_template() {
+ # $1=file $2=name $3=setting value $4=timeout $5=log level
+ cat > "$1" << EOF
+{
+ "name": "$2",
+ "settings": {
+ "USE_${plugin_name_upper}": "yes",
+ "PLUGIN_${plugin_name_upper}_SETTING": "$3",
+ "PLUGIN_${plugin_name_upper}_TIMEOUT": "$4",
+ "PLUGIN_${plugin_name_upper}_LOG_LEVEL": "$5"
+ },
+ "steps": [
+ {
+ "title": "$plugin_name configuration",
+ "subtitle": "Configure the $plugin_name plugin",
+ "settings": [
+ "USE_${plugin_name_upper}",
+ "PLUGIN_${plugin_name_upper}_SETTING",
+ "PLUGIN_${plugin_name_upper}_TIMEOUT",
+ "PLUGIN_${plugin_name_upper}_LOG_LEVEL"
+ ]
+ }
+ ]
+}
+EOF
+ }
+
+ write_template "$plugin_dir/templates/$plugin_name-template.json" "$plugin_name default template" "template_value" "10" "INFO"
+ write_template "$plugin_dir/templates/$plugin_name-dev.json" "$plugin_name development" "development_mode" "30" "DEBUG"
+ write_template "$plugin_dir/templates/$plugin_name-prod.json" "$plugin_name production" "production_mode" "5" "WARN"
+
+ unset -f write_template
+}
+
+generate_project_readme() {
+ local output_dir="$1"
+
+ if [ -f "$output_dir/README.md" ]; then
+ return 0
+ fi
+
+ cat > "$output_dir/README.md" << 'EOF'
+# BunkerWeb Plugins
+
+BunkerWeb is a next-generation Web Application Firewall (WAF) that provides comprehensive
+security for your web services. This project extends BunkerWeb's capabilities with custom
+plugins tailored to specific security requirements.
+
+## Plugin Structure
+
+Each plugin in this repository follows the standard BunkerWeb plugin structure:
+
+```
+plugin-name/
+├── plugin.json # Plugin metadata and settings
+├── plugin-name.lua # Main Lua execution file
+├── ui/ # Web UI components (optional)
+│ └── actions.py # BunkerWeb UI hooks (pre_render + page handler)
+├── jobs/ # Scheduled maintenance jobs (optional)
+│ └── plugin-name-job.py # Job scheduler script
+├── confs/ # NGINX configuration templates (optional)
+│ ├── server-http/ # Server-level HTTP configurations
+│ ├── http/ # HTTP-level configurations
+│ └── stream/ # Stream configurations
+├── templates/ # Configuration templates (optional)
+└── README.md # Plugin documentation
+```
+
+## Available Plugins
+
+| Plugin | Description | Version | Features |
+|--------|-------------|---------|----------|
+| [example-plugin](./example-plugin/) | Example plugin description | 1.0.0 | Feature list |
+
+## Quick Start
+
+### Prerequisites
+
+- BunkerWeb 1.6.0 or later
+- Docker or Linux environment
+- Basic knowledge of NGINX and Lua (for development)
+
+### Installation
+
+1. **Clone this repository:**
+ ```bash
+ git clone https://github.com/bunkerity/bunkerweb-plugins
+ cd bunkerweb-plugins
+ ```
+
+2. **Install plugins to BunkerWeb:**
+
+ **For Docker:**
+ ```bash
+ # Copy plugins to BunkerWeb data directory
+ cp -r plugin-name /path/to/bw-data/plugins/
+
+ # Set correct permissions
+ chown -R 101:101 /path/to/bw-data/plugins/plugin-name
+ chmod -R 750 /path/to/bw-data/plugins/plugin-name
+ ```
+
+ **For Linux:**
+ ```bash
+ # Copy plugins to BunkerWeb plugins directory
+ cp -r plugin-name /etc/bunkerweb/plugins/
+
+ # Set correct permissions
+ chown -R root:nginx /etc/bunkerweb/plugins/plugin-name
+ chmod -R 750 /etc/bunkerweb/plugins/plugin-name
+
+ # Restart BunkerWeb
+ systemctl restart bunkerweb
+ ```
+
+3. **Configure plugins:**
+ ```bash
+ # Enable plugin (replace MYPLUGIN with the plugin's upper-cased id)
+ USE_MYPLUGIN=yes
+ PLUGIN_MYPLUGIN_SETTING=your_value
+ ```
+EOF
+}
+
+generate_readme() {
+ local plugin_dir="$1"
+ local plugin_name="$2"
+ local plugin_name_upper
+ plugin_name_upper=$(echo "$plugin_name" | tr '[:lower:]-' '[:upper:]_')
+
+ local features="- **Core Integration**: Seamlessly integrates with BunkerWeb's NGINX Lua module
+- **Multisite Support**: Built-in support for global and per-service configurations
+- **Configurable Settings**: Multiple configuration options with validation
+- **Performance Monitoring**: Built-in metrics and health checks"
+
+ if [ "$WITH_UI" = "yes" ]; then
+ features="${features}
+- **Web UI**: User-friendly configuration interface"
+ fi
+
+ if [ "$WITH_JOBS" = "yes" ]; then
+ features="${features}
+- **Scheduled Jobs**: Automated maintenance and data processing"
+ fi
+
+ if [ "$WITH_CONFIGS" = "yes" ]; then
+ features="${features}
+- **Custom NGINX Configs**: Flexible NGINX configuration templates (ModSecurity disabled by default)"
+ fi
+
+ if [ "$WITH_TEMPLATES" = "yes" ]; then
+ features="${features}
+- **Configuration Templates**: Pre-defined configuration templates with proper step structure"
+ fi
+
+ features="${features}
+- **Stream Support**: $(echo "$STREAM_MODE" | tr '[:lower:]' '[:upper:]') support for TCP/UDP protocols
+- **Basic Security**: NGINX-level protections
+- **Flexible Context**: Multisite context allows both global and service-specific settings"
+
+ cat > "$plugin_dir/README.md" << EOF
+# $plugin_name Plugin for BunkerWeb
+
+$DESCRIPTION
+
+\`\`\`mermaid
+$(cat "$plugin_dir/docs/diagram.mmd")
+\`\`\`
+
+## Features
+
+$features
+
+## Installation
+
+### Docker Integration
+
+1. **Download the plugin:**
+ \`\`\`bash
+ git clone https://github.com/bunkerity/bunkerweb-plugins && cd bunkerweb-plugins
+ \`\`\`
+
+2. **Copy to BunkerWeb plugins directory:**
+ \`\`\`bash
+ cp -r $plugin_name /path/to/bw-data/plugins/
+ \`\`\`
+
+3. **Set correct permissions:**
+ \`\`\`bash
+ chown -R 101:101 /path/to/bw-data/plugins/$plugin_name
+ chmod -R 750 /path/to/bw-data/plugins/$plugin_name
+ \`\`\`
+
+### Linux Integration
+
+1. **Copy plugin to BunkerWeb plugins directory:**
+ \`\`\`bash
+ cp -r $plugin_name /etc/bunkerweb/plugins/
+ \`\`\`
+
+2. **Set correct permissions:**
+ \`\`\`bash
+ chown -R root:nginx /etc/bunkerweb/plugins/$plugin_name
+ chmod -R 750 /etc/bunkerweb/plugins/$plugin_name
+ \`\`\`
+
+3. **Restart BunkerWeb:**
+ \`\`\`bash
+ systemctl restart bunkerweb
+ \`\`\`
+
+## Usage
+
+### Basic Configuration
+
+\`\`\`bash
+# Enable the plugin
+USE_${plugin_name_upper}=yes
+
+# Configure main setting
+PLUGIN_${plugin_name_upper}_SETTING=your_custom_value
+
+# Set timeout (1-300 seconds)
+PLUGIN_${plugin_name_upper}_TIMEOUT=10
+
+# Set log level
+PLUGIN_${plugin_name_upper}_LOG_LEVEL=INFO
+\`\`\`
+
+### Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| \`USE_${plugin_name_upper}\` | \`no\` | Enable or disable the plugin |
+| \`PLUGIN_${plugin_name_upper}_SETTING\` | \`default_value\` | Main plugin configuration setting |
+| \`PLUGIN_${plugin_name_upper}_TIMEOUT\` | \`5\` | Timeout for plugin operations (1-300 seconds) |
+| \`PLUGIN_${plugin_name_upper}_LOG_LEVEL\` | \`DEBUG\` | Log verbosity (DEBUG, INFO, WARN, ERROR) |
+
+## Development
+
+### Modifying the Plugin
+
+1. **Core Logic**: Edit \`$plugin_name.lua\` for main functionality
+2. **Settings**: Update \`plugin.json\` for new configuration options
+3. **Documentation**: Update this README.md with your changes
+
+### Testing
+
+\`\`\`bash
+# Test plugin syntax
+nginx -t
+
+# Check BunkerWeb logs
+tail -f /var/log/bunkerweb/error.log
+\`\`\`
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Plugin not loading:**
+ - Check file permissions (should be 750 with correct ownership)
+ - Verify plugin.json syntax
+ - Check BunkerWeb error logs
+
+2. **Configuration not applied:**
+ - Restart BunkerWeb services
+ - Verify environment variables are set correctly
+ - Check for configuration conflicts
+
+3. **Performance issues:**
+ - Adjust \`PLUGIN_${plugin_name_upper}_TIMEOUT\` setting
+ - Monitor system resources
+ - Check for excessive logging
+
+### Support
+
+- **Documentation**: [BunkerWeb Plugins](https://docs.bunkerweb.io/latest/plugins/)
+- **Community**: [Discord Server](https://bunkerity.discord.com/)
+- **Issues**: [GitHub Issues](https://github.com/bunkerity/bunkerweb-plugins/issues)
+
+## Contributing
+
+1. Fork the repository
+2. Create a feature branch
+3. Make your changes
+4. Add tests if applicable
+5. Submit a pull request
+
+Please follow the [BunkerWeb contribution guidelines](https://github.com/bunkerity/bunkerweb-plugins/blob/main/CONTRIBUTING.md).
+EOF
+}
+
+create_plugin() {
+ local plugin_name="$1"
+ local output_dir="$2"
+ local plugin_dir="${output_dir}/${plugin_name}"
+
+ echo -e "${BOLD}Creating BunkerWeb plugin '${plugin_name}'${NC}"
+ print_info "Output directory : $plugin_dir"
+ print_info "Description : $DESCRIPTION"
+ print_info "Version : $VERSION"
+ print_info "Stream mode : $STREAM_MODE"
+ print_info "Context : multisite (global and per-service)"
+ echo ""
+
+ if [ -d "$plugin_dir" ]; then
+ print_warning "Directory $plugin_dir already exists"
+ printf "Do you want to continue and overwrite? (y/N): "
+ # Name the variable explicitly (POSIX) and tolerate EOF under set -e so
+ # non-interactive runs fall through to the safe no-overwrite branch.
+ read -r REPLY || REPLY=""
+ if [ "$REPLY" != "y" ] && [ "$REPLY" != "Y" ]; then
+ print_warning "Aborted — existing directory left untouched"
+ return 1
+ fi
+ rm -rf "$plugin_dir"
+ fi
+
+ print_step "Creating directory structure"
+ create_directory_structure "$plugin_dir"
+ create_docs "$plugin_dir"
+
+ print_step "Generating core files (plugin.json, ${plugin_name}.lua)"
+ generate_plugin_json "$plugin_dir" "$plugin_name"
+ generate_lua_file "$plugin_dir" "$plugin_name"
+
+ if [ "$WITH_UI" = "yes" ]; then
+ print_step "Generating UI components"
+ generate_ui_components "$plugin_dir" "$plugin_name"
+ fi
+
+ if [ "$WITH_JOBS" = "yes" ]; then
+ print_step "Generating job files"
+ generate_job_files "$plugin_dir" "$plugin_name"
+ fi
+
+ if [ "$WITH_CONFIGS" = "yes" ]; then
+ print_step "Generating NGINX configuration templates"
+ generate_config_templates "$plugin_dir" "$plugin_name"
+ fi
+
+ if [ "$WITH_TEMPLATES" = "yes" ]; then
+ print_step "Generating custom templates"
+ generate_custom_templates "$plugin_dir" "$plugin_name"
+ fi
+
+ print_step "Generating documentation"
+ generate_readme "$plugin_dir" "$plugin_name"
+
+ project_readme_existed="no"
+ if [ -f "$output_dir/README.md" ]; then
+ project_readme_existed="yes"
+ fi
+ generate_project_readme "$output_dir"
+
+ echo ""
+ print_success "Plugin '${plugin_name}' created in $plugin_dir"
+ echo ""
+ echo -e "${BOLD}Files created:${NC}"
+ find "$plugin_dir" -type f | sort | sed 's/^/ /'
+ echo ""
+ if [ "$project_readme_existed" = "no" ]; then
+ print_info "Project README.md template created at: $output_dir/README.md"
+ echo ""
+ fi
+ echo -e "${BOLD}Next steps:${NC}"
+ echo " 1. Implement your logic in $plugin_name.lua"
+ echo " 2. Adjust settings in plugin.json as needed"
+ echo " 3. Update README.md with your plugin's specifics"
+ echo " 4. Test the plugin against a BunkerWeb instance"
+ echo ""
+ echo -e "${BOLD}Install (Docker):${NC}"
+ echo " cp -r $plugin_dir /path/to/bw-data/plugins/ && \\"
+ echo " chown -R 101:101 /path/to/bw-data/plugins/$plugin_name && \\"
+ echo " chmod -R 750 /path/to/bw-data/plugins/$plugin_name"
+}
+
+PLUGIN_NAME=""
+DESCRIPTION=""
+VERSION="1.0.0"
+OUTPUT_DIR=".."
+STREAM_MODE="partial"
+WITH_UI="no"
+WITH_JOBS="no"
+WITH_CONFIGS="no"
+WITH_TEMPLATES="no"
+
+while [ $# -gt 0 ]; do
+ if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
+ show_usage
+ exit 0
+ elif [ "$1" = "-d" ] || [ "$1" = "--description" ]; then
+ [ $# -ge 2 ] || { print_error "$1 requires a value"; exit 1; }
+ DESCRIPTION="$2"
+ shift 2
+ elif [ "$1" = "-v" ] || [ "$1" = "--version" ]; then
+ [ $# -ge 2 ] || { print_error "$1 requires a value"; exit 1; }
+ VERSION="$2"
+ shift 2
+ elif [ "$1" = "-o" ] || [ "$1" = "--output" ]; then
+ [ $# -ge 2 ] || { print_error "$1 requires a value"; exit 1; }
+ OUTPUT_DIR="$2"
+ shift 2
+ elif [ "$1" = "--stream" ]; then
+ [ $# -ge 2 ] || { print_error "$1 requires a value"; exit 1; }
+ if [ "$2" = "no" ] || [ "$2" = "partial" ] || [ "$2" = "yes" ]; then
+ STREAM_MODE="$2"
+ else
+ print_error "Invalid stream mode. Use: no, partial, or yes"
+ exit 1
+ fi
+ shift 2
+ elif [ "$1" = "--with-ui" ]; then
+ WITH_UI="yes"
+ shift
+ elif [ "$1" = "--with-jobs" ]; then
+ WITH_JOBS="yes"
+ shift
+ elif [ "$1" = "--with-configs" ]; then
+ WITH_CONFIGS="yes"
+ shift
+ elif [ "$1" = "--with-templates" ]; then
+ WITH_TEMPLATES="yes"
+ shift
+ elif echo "$1" | grep -q '^-'; then
+ print_error "Unknown option: $1"
+ show_usage >&2
+ exit 1
+ else
+ if [ -z "$PLUGIN_NAME" ]; then
+ PLUGIN_NAME="$1"
+ else
+ print_error "Multiple plugin names specified"
+ exit 1
+ fi
+ shift
+ fi
+done
+
+if [ -z "$PLUGIN_NAME" ]; then
+ print_error "Plugin name is required"
+ show_usage >&2
+ exit 1
+fi
+
+if [ -z "$DESCRIPTION" ]; then
+ print_error "Plugin description is required (use -d or --description)"
+ exit 1
+fi
+
+if ! validate_plugin_name "$PLUGIN_NAME"; then
+ exit 1
+fi
+
+if [ ! -d "$OUTPUT_DIR" ]; then
+ print_error "Output directory does not exist: $OUTPUT_DIR"
+ exit 1
+fi
+
+create_plugin "$PLUGIN_NAME" "$OUTPUT_DIR"
diff --git a/templates/template_diagram.mmd b/templates/template_diagram.mmd
new file mode 100644
index 0000000..c5b11b4
--- /dev/null
+++ b/templates/template_diagram.mmd
@@ -0,0 +1,22 @@
+flowchart TD
+ accTitle: BunkerWeb plugin request flow
+ accDescr: A client request passes through BunkerWeb, where this plugin runs its checks on the access or log phase before the request is forwarded to the upstream application. Replace this template with the plugin's real flow.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb]
+ direction TB
+ core["Core checks first: rate limit, bad behavior, antibot, DNSBL, black / whitelist"]
+ plugin["plugin.lua: plugin logic"]
+ core --> plugin
+ end
+
+ upstream([Upstream app])
+
+ client -->|request| core
+ plugin --> upstream
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class upstream ok;
+ class client,core,plugin app;
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..2533711
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,28 @@
+"""Shared fixtures for the plugin UI unit tests."""
+
+import pytest
+
+
+class FakePingUtils:
+ """Stand-in for ``kwargs['bw_instances_utils']`` used by ``ui/actions.py``.
+
+ ``get_ping`` either returns a canned ``{"status": ...}`` payload or raises,
+ so both the happy path and the broad ``except BaseException`` path of
+ ``pre_render`` can be exercised without a running BunkerWeb instance.
+ """
+
+ def __init__(self, status=None, exc=None):
+ self._status = status
+ self._exc = exc
+ self.called_with = None
+
+ def get_ping(self, plugin):
+ self.called_with = plugin
+ if self._exc is not None:
+ raise self._exc
+ return {"status": self._status}
+
+
+@pytest.fixture
+def fake_ping_utils():
+ return FakePingUtils
diff --git a/tests/test_cloudflare_helpers.py b/tests/test_cloudflare_helpers.py
new file mode 100644
index 0000000..a87e9b2
--- /dev/null
+++ b/tests/test_cloudflare_helpers.py
@@ -0,0 +1,198 @@
+"""Unit tests for cloudflare/jobs/cloudflare_helpers.py (pure logic, no BunkerWeb deps)."""
+
+import importlib.util
+from datetime import datetime, timezone
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+
+
+@pytest.fixture(scope="module")
+def helpers():
+ path = REPO_ROOT / "cloudflare" / "jobs" / "cloudflare_helpers.py"
+ spec = importlib.util.spec_from_file_location("cloudflare_helpers", path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+@pytest.fixture
+def now():
+ return datetime(2026, 1, 1, tzinfo=timezone.utc)
+
+
+# --- check_line -------------------------------------------------------------------
+
+
+def test_check_line_accepts_ip_and_cidr(helpers):
+ assert helpers.check_line(b"1.2.3.4") == (True, b"1.2.3.4")
+ assert helpers.check_line(b"173.245.48.0/20") == (True, b"173.245.48.0/20")
+ assert helpers.check_line(b"2400:cb00::/32") == (True, b"2400:cb00::/32")
+
+
+def test_check_line_rejects_junk(helpers):
+ assert helpers.check_line(b"not-an-ip") == (False, b"")
+ assert helpers.check_line(b"# comment") == (False, b"")
+ assert helpers.check_line(b"999.999.0.0/8") == (False, b"")
+
+
+# --- parse_ban_key ----------------------------------------------------------------
+
+
+def test_parse_ban_key_global(helpers):
+ assert helpers.parse_ban_key("bans_ip_1.2.3.4") == "1.2.3.4"
+ assert helpers.parse_ban_key(b"bans_ip_2001:db8::1") == "2001:db8::1"
+
+
+def test_parse_ban_key_service(helpers):
+ assert helpers.parse_ban_key("bans_service_www.example.com_ip_5.6.7.8") == "5.6.7.8"
+
+
+def test_parse_ban_key_non_ban_key(helpers):
+ assert helpers.parse_ban_key("sessions_abc") is None
+ assert helpers.parse_ban_key("bans_ip_") is None
+
+
+# --- get_env_secret (_FILE convention) --------------------------------------------
+
+
+def test_get_env_secret_prefers_file_then_env(helpers, tmp_path, monkeypatch):
+ secret = tmp_path / "token"
+ secret.write_text("from-file\n")
+ monkeypatch.setenv("X_CLOUDFLARE_API_TOKEN_FILE", str(secret))
+ monkeypatch.setenv("X_CLOUDFLARE_API_TOKEN", "from-env")
+ assert helpers.get_env_secret("X_CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN") == "from-file"
+
+
+def test_get_env_secret_falls_back_to_global_env(helpers, monkeypatch):
+ monkeypatch.delenv("X_CLOUDFLARE_API_TOKEN", raising=False)
+ monkeypatch.delenv("X_CLOUDFLARE_API_TOKEN_FILE", raising=False)
+ monkeypatch.setenv("CLOUDFLARE_API_TOKEN", "global-token")
+ assert helpers.get_env_secret("X_CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN") == "global-token"
+
+
+def test_get_env_secret_default_when_unset(helpers, monkeypatch):
+ for var in ("X", "Y", "X_FILE", "Y_FILE"):
+ monkeypatch.delenv(var, raising=False)
+ assert helpers.get_env_secret("X", "Y", default="") == ""
+
+
+# --- request_type_for -------------------------------------------------------------
+
+
+def test_request_type_for(helpers):
+ assert helpers.request_type_for("ecdsa") == "origin-ecc"
+ assert helpers.request_type_for("rsa") == "origin-rsa"
+ assert helpers.request_type_for("anything-else") == "origin-rsa"
+
+
+# --- build_csr_config -------------------------------------------------------------
+
+
+def test_build_csr_config_contains_cn_and_sans(helpers):
+ conf = helpers.build_csr_config("www.example.com", ["www.example.com", "example.com"])
+ assert "CN = www.example.com" in conf
+ assert "DNS.1 = www.example.com" in conf
+ assert "DNS.2 = example.com" in conf
+ assert "[alt_names]" in conf
+
+
+def test_build_csr_config_is_deterministic(helpers):
+ a = helpers.build_csr_config("a.example.com", ["a.example.com"])
+ b = helpers.build_csr_config("a.example.com", ["a.example.com"])
+ assert a == b
+
+
+# --- select_zone_name -------------------------------------------------------------
+
+
+def test_select_zone_name_strips_subdomain(helpers):
+ assert helpers.select_zone_name(["www.example.com"]) == "example.com"
+ assert helpers.select_zone_name(["example.com"]) == "example.com"
+
+
+def test_select_zone_name_picks_shortest(helpers):
+ # With multiple registrable domains the shortest wins (a documented heuristic).
+ assert helpers.select_zone_name(["a.verylongdomain.com", "b.short.io"]) == "short.io"
+
+
+def test_select_zone_name_cctld_limitation_documented(helpers):
+ # Public-suffix-naive: a.co.uk collapses to co.uk (set CLOUDFLARE_ZONE_ID for ccTLDs).
+ assert helpers.select_zone_name(["a.co.uk"]) == "co.uk"
+
+
+def test_select_zone_name_empty(helpers):
+ assert helpers.select_zone_name([]) == ""
+
+
+# --- select_zone ------------------------------------------------------------------
+
+
+def test_select_zone_picks_most_recent(helpers):
+ zones = [
+ {"id": "old", "modified_on": "2020-01-01T00:00:00Z"},
+ {"id": "new", "modified_on": "2025-01-01T00:00:00Z"},
+ ]
+ assert helpers.select_zone(zones)["id"] == "new"
+
+
+def test_select_zone_empty(helpers):
+ assert helpers.select_zone([]) is None
+
+
+# --- expiry parsing ---------------------------------------------------------------
+
+
+def test_parse_expires_on_go_format(helpers):
+ dt = helpers.parse_expires_on("2039-01-01 00:00:00 +0000 UTC")
+ assert dt.year == 2039 and dt.tzinfo is not None
+
+
+def test_parse_expires_on_rfc3339(helpers):
+ dt = helpers.parse_expires_on("2039-01-01T00:00:00Z")
+ assert dt.year == 2039 and dt.tzinfo is not None
+
+
+def test_parse_expires_on_garbage_is_epoch(helpers):
+ assert helpers.parse_expires_on("not-a-date").year == 1970
+
+
+def test_parse_expires_on_none_is_epoch(helpers):
+ # The Cloudflare SDK exposes expires_on as Optional[str]; None must not crash.
+ assert helpers.parse_expires_on(None).year == 1970
+
+
+def test_is_expired_none_is_true(helpers, now):
+ assert helpers.is_expired(None, now) is True
+
+
+def test_is_expired(helpers, now):
+ assert helpers.is_expired("2020-01-01 00:00:00 +0000 UTC", now) is True
+ assert helpers.is_expired("2099-01-01 00:00:00 +0000 UTC", now) is False
+
+
+# --- hostnames_match / find_matching_cert -----------------------------------------
+
+
+def test_hostnames_match_is_set_based(helpers):
+ assert helpers.hostnames_match(["a.com", "b.com"], ["b.com", "a.com"]) is True
+ assert helpers.hostnames_match(["a.com"], ["a.com", "b.com"]) is False
+
+
+def test_find_matching_cert_match_valid(helpers, now):
+ certs = [{"id": "c1", "hostnames": ["www.example.com"], "expires_on": "2099-01-01 00:00:00 +0000 UTC"}]
+ cert_id, found, expired = helpers.find_matching_cert(certs, ["www.example.com"], now)
+ assert (cert_id, found, expired) == ("c1", True, False)
+
+
+def test_find_matching_cert_match_expired(helpers, now):
+ certs = [{"id": "c1", "hostnames": ["www.example.com"], "expires_on": "2020-01-01 00:00:00 +0000 UTC"}]
+ cert_id, found, expired = helpers.find_matching_cert(certs, ["www.example.com"], now)
+ assert (cert_id, found, expired) == ("c1", True, True)
+
+
+def test_find_matching_cert_no_match(helpers, now):
+ certs = [{"id": "c1", "hostnames": ["other.example.com"], "expires_on": "2099-01-01 00:00:00 +0000 UTC"}]
+ assert helpers.find_matching_cert(certs, ["www.example.com"], now) == (None, False, False)
diff --git a/tests/test_ui_actions.py b/tests/test_ui_actions.py
new file mode 100644
index 0000000..bedd264
--- /dev/null
+++ b/tests/test_ui_actions.py
@@ -0,0 +1,56 @@
+"""Unit tests for every plugin's ``ui/actions.py``.
+
+The ``actions.py`` files are byte-identical apart from the plugin name, so one
+parametrized suite covers them all. Each module is loaded under a unique
+synthetic name to avoid the ``sys.modules`` collision that would otherwise make
+us test a single plugin many times. (authentik is excluded: it ships no
+``ui/actions.py``.)
+"""
+
+import importlib.util
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+PLUGINS = ["clamav", "cloudflare", "coraza", "discord", "matrix", "slack", "virustotal", "webhook"]
+
+
+def load_actions(plugin):
+ path = REPO_ROOT / plugin / "ui" / "actions.py"
+ spec = importlib.util.spec_from_file_location(f"actions_{plugin}", path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+@pytest.mark.parametrize("plugin", PLUGINS)
+def test_pre_render_happy_path(plugin, fake_ping_utils):
+ module = load_actions(plugin)
+ fake = fake_ping_utils(status="up")
+ ret = module.pre_render(bw_instances_utils=fake)
+ assert fake.called_with == plugin
+ assert ret["ping_status"]["value"] == "up"
+ assert "error" not in ret
+
+
+@pytest.mark.parametrize("plugin", PLUGINS)
+def test_pre_render_error_path(plugin, fake_ping_utils):
+ module = load_actions(plugin)
+ # The exception message stands in for something sensitive (e.g. an internal
+ # URL) that must never reach the rendered card.
+ fake = fake_ping_utils(exc=RuntimeError("boom https://internal.scheduler:8080"))
+ ret = module.pre_render(bw_instances_utils=fake)
+ # A generic marker is shown; the raw exception text is not leaked to the UI.
+ assert ret["error"] == "Could not retrieve the plugin status"
+ assert "boom" not in ret["error"]
+ assert "internal" not in ret["error"]
+ assert ret["ping_status"]["value"] == "error"
+
+
+@pytest.mark.parametrize("plugin", PLUGINS)
+def test_plugin_stub_is_noop(plugin):
+ module = load_actions(plugin)
+ fn = getattr(module, plugin)
+ assert callable(fn)
+ assert fn() is None
diff --git a/virustotal/README.md b/virustotal/README.md
index 172ac71..26d6a65 100644
--- a/virustotal/README.md
+++ b/virustotal/README.md
@@ -1,33 +1,135 @@
# VirusTotal plugin
-
-
-
+
+
+```mermaid
+flowchart TD
+ accTitle: BunkerWeb VirusTotal plugin request flow
+ accDescr: A client request first passes BunkerWeb core checks, then virustotal.lua optionally checks the client IP and any uploaded file against the VirusTotal API v3. Both paths share a 24-hour cache (IP keyed by address, file keyed by SHA-256). The suspicious and malicious counts are compared to configurable thresholds, and a result over threshold denies the request.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb access phase]
+ direction TB
+ core["1. Core checks first: rate limit, bad behavior, antibot, DNSBL, black / whitelist"]
+ lua["2. virustotal.lua"]
+ ipscan["IP scan (opt-in): GET /api/v3/ip_addresses/{ip}"]
+ filescan["File scan (opt-in): SHA-256, GET /api/v3/files/{hash}"]
+ cache{{"24h cache hit?"}}
+ core --> lua
+ lua --> ipscan --> cache
+ lua --> filescan --> cache
+ end
+
+ vt[["VirusTotal API v3"]]
+ verdict{"evaluate(): suspicious / malicious over threshold?"}
+ allow["Allow to upstream"]
+ deny["Deny request (get_deny_status)"]
+ upstream([Upstream app])
+
+ client -->|request| core
+ cache -.->|miss| vt
+ vt -.->|"last_analysis_stats"| verdict
+ cache -->|hit| verdict
+ verdict -->|no| allow
+ verdict -->|yes| deny
+ allow --> upstream
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class allow,upstream ok;
+ class deny deny;
+ class vt svc;
+ class client,core,lua,ipscan,filescan,cache app;
+```
-This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github) plugin will automatically check if any uploaded file is already analyzed on VirusTotal and deny the request if the file is detected by some antivirus engine(s).
+This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github)
+plugin checks incoming requests against the
+[VirusTotal](https://www.virustotal.com/) API v3 during BunkerWeb's access
+phase. When enabled, it can look up the client's IP address and the SHA-256 of
+each file uploaded in a `multipart/form-data` request, then deny the request
+when VirusTotal's aggregated verdict crosses a configurable threshold.
-At the moment, submission of new file is not supported, it only checks if files already exist in VT and get the scan result if that's the case.
+The check runs from Lua in the access phase, so all of BunkerWeb's built-in
+checks (rate limit, bad behavior, antibot, DNSBL, whitelist / blacklist, ...)
+run _before_ VirusTotal is queried — already-blocked clients never consume an
+API call. IP and file lookups are cached for 24 hours (IP keyed by address,
+file keyed by SHA-256), so repeated visitors and identical uploads do not
+re-query the API.
# Table of contents
- [VirusTotal plugin](#virustotal-plugin)
- [Table of contents](#table-of-contents)
+- [How it works](#how-it-works)
- [Prerequisites](#prerequisites)
- [Setup](#setup)
- [Docker](#docker)
- [Swarm](#swarm)
- [Kubernetes](#kubernetes)
- [Settings](#settings)
+- [Troubleshooting](#troubleshooting)
+- [Limitations](#limitations)
+
+# How it works
+
+For a request to `https://app.example.com/...`:
+
+1. BunkerWeb's access-phase checks run first (rate limit, bad behavior,
+ antibot, DNSBL, blacklist, ...). If any of them deny, the request stops
+ before VirusTotal is contacted.
+2. `virustotal.lua` runs when `USE_VIRUSTOTAL=yes` and at least one of
+ `VIRUSTOTAL_SCAN_IP` / `VIRUSTOTAL_SCAN_FILE` is `yes`. There is **no**
+ `init_worker` pre-connect — VirusTotal has no usable health endpoint — so
+ API connectivity problems first surface on a real request rather than at
+ worker startup.
+3. **IP scan** (when `VIRUSTOTAL_SCAN_IP=yes` _and_ the client IP is global):
+ the handler does a `GET` against `/ip_addresses/`. Private, loopback and
+ other non-global addresses are skipped.
+4. **File scan** (when `VIRUSTOTAL_SCAN_FILE=yes` _and_ the request is
+ `multipart/form-data`): each part that carries a filename is streamed and
+ hashed with SHA-256, then looked up with a `GET` against
+ `/files/`. Parts without a filename (plain form fields) are ignored.
+5. Both paths first consult the 24-hour cache (IP keyed by address, file keyed
+ by SHA-256). On a cache miss the VirusTotal API is queried and the result is
+ stored for 24 hours.
+6. **Verdict** (`virustotal_helpers.evaluate`): VirusTotal's
+ `last_analysis_stats` _suspicious_ and _malicious_ counts are compared to
+ their thresholds using a strict `>` — a count exactly equal to its
+ threshold is still treated as clean. A `404` (the hash or IP is unknown to
+ VirusTotal) is treated as clean. If either count exceeds its threshold the
+ request is denied with BunkerWeb's deny status; otherwise it continues to
+ the upstream.
+
+On any other API error (a non-`200`, non-`404` response — for example a `401`
+from a missing key or a `429` rate limit), the handler returns the error, which
+surfaces as a BunkerWeb HTTP 500 rather than silently allowing the request.
+
+The plugin also exposes an internal `POST /virustotal/ping` API endpoint, used
+by the BunkerWeb web UI to confirm connectivity: it looks up the EICAR test
+file's SHA-256 on VirusTotal and reports success only if that known hash is
+returned.
# Prerequisites
-Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation first.
+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 VirusTotal API key to contact their API (see [here](https://support.virustotal.com/hc/en-us/articles/115002088769-Please-give-me-an-API-key)). The free API key is also working but you should check the terms of service and limits as described [here](https://support.virustotal.com/hc/en-us/articles/115002119845-What-is-the-difference-between-the-public-API-and-the-private-API-).
+You will need a VirusTotal API key to contact their API (see
+[here](https://support.virustotal.com/hc/en-us/articles/115002088769-Please-give-me-an-API-key)).
+The free (public) API key works too, but it is rate-limited (roughly 4 requests
+per minute) — review the terms of service and limits described
+[here](https://support.virustotal.com/hc/en-us/articles/115002119845-What-is-the-difference-between-the-public-API-and-the-private-API-)
+before relying on it for high-traffic scanning.
# 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.
+See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+of the BunkerWeb documentation for the generic installation procedure
+depending on your integration. The plugin settings go on the **scheduler**
+service.
## Docker
@@ -35,11 +137,11 @@ See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign
services:
bw-scheduler:
- image: bunkerity/bunkerweb-scheduler:1.6.0-rc1
+ image: bunkerity/bunkerweb-scheduler:1.6.11
...
environment:
- - USE_VIRUSTOTAL=yes
- - VIRUSTOTAL_API_KEY=mykey
+ USE_VIRUSTOTAL: "yes"
+ VIRUSTOTAL_API_KEY: "mykey"
...
```
@@ -49,17 +151,15 @@ services:
services:
bw-scheduler:
- image: bunkerity/bunkerweb-scheduler:1.6.0-rc1
+ image: bunkerity/bunkerweb-scheduler:1.6.11
...
environment:
- - USE_VIRUSTOTAL=yes
- - VIRUSTOTAL_API_KEY=mykey
+ USE_VIRUSTOTAL: "yes"
+ VIRUSTOTAL_API_KEY: "mykey"
...
networks:
- bw-plugins
...
-
-...
```
## Kubernetes
@@ -76,13 +176,59 @@ metadata:
# Settings
-| Setting | Default | Context | Multiple | Description |
-| ---------------------------- | ------- | --------- | -------- | -------------------------------------------------------------------------------- |
-| `USE_VIRUSTOTAL` | `no` | multisite | no | Activate VirusTotal integration. |
-| `VIRUSTOTAL_API_KEY` | | global | no | Key to authenticate with VirusTotal API. |
-| `VIRUSTOTAL_SCAN_FILE` | `yes` | multisite | no | Activate automatic scan of uploaded files with VirusTotal (only existing files). |
-| `VIRUSTOTAL_SCAN_IP` | `yes` | multisite | no | Activate automatic scan of uploaded ips with VirusTotal. |
-| `VIRUSTOTAL_IP_SUSPICIOUS` | `5` | global | no | Minimum number of suspicious reports before considering IP as bad. |
-| `VIRUSTOTAL_IP_MALICIOUS` | `3` | global | no | Minimum number of malicious reports before considering IP as bad. |
-| `VIRUSTOTAL_FILE_SUSPICIOUS` | `5` | global | no | Minimum number of suspicious reports before considering file as bad. |
-| `VIRUSTOTAL_FILE_MALICIOUS` | `3` | global | no | Minimum number of malicious reports before considering file as bad. |
+| Setting | Default | Context | Multiple | Description |
+| ---------------------------- | ----------------------------------- | --------- | -------- | -------------------------------------------------------------------------------- |
+| `USE_VIRUSTOTAL` | `no` | multisite | no | Activate VirusTotal integration. |
+| `VIRUSTOTAL_API_KEY` | | global | no | Key to authenticate with VirusTotal API. |
+| `VIRUSTOTAL_API_URL` | `https://www.virustotal.com/api/v3` | global | no | Base URL of the VirusTotal API (or a VirusTotal-compatible endpoint). |
+| `VIRUSTOTAL_TIMEOUT` | `1000` | global | no | Timeout in milliseconds for VirusTotal API requests. |
+| `VIRUSTOTAL_SCAN_FILE` | `yes` | multisite | no | Activate automatic scan of uploaded files with VirusTotal (only existing files). |
+| `VIRUSTOTAL_SCAN_IP` | `yes` | multisite | no | Activate automatic scan of the client IP with VirusTotal. |
+| `VIRUSTOTAL_IP_SUSPICIOUS` | `5` | global | no | Minimum number of suspicious reports before considering IP as bad. |
+| `VIRUSTOTAL_IP_MALICIOUS` | `3` | global | no | Minimum number of malicious reports before considering IP as bad. |
+| `VIRUSTOTAL_FILE_SUSPICIOUS` | `5` | global | no | Minimum number of suspicious reports before considering file as bad. |
+| `VIRUSTOTAL_FILE_MALICIOUS` | `3` | global | no | Minimum number of malicious reports before considering file as bad. |
+
+# Troubleshooting
+
+- **HTTP 500 on every scanned request, log says `received status 401 from VT
+API`.** `VIRUSTOTAL_API_KEY` is missing or invalid. The key is required as
+ soon as `USE_VIRUSTOTAL=yes` and a scan would fire; without a valid key the
+ API call fails and the error surfaces as a 500 (the plugin never silently
+ allows on error).
+- **Intermittent HTTP 500 under load (`received status 429`).** You are hitting
+ VirusTotal's rate limit. The public/free tier allows roughly 4 requests per
+ minute, so high-traffic file scanning can exceed it. Use a private API key,
+ cut down on what you scan, or rely on the 24-hour cache to absorb repeated
+ lookups.
+- **A known-bad file is not blocked.** Only files VirusTotal already knows (by
+ hash) get a verdict. A hash VirusTotal has never seen returns `404`, which
+ the plugin treats as clean — the plugin never uploads the file for analysis.
+- **A file at exactly the threshold is allowed.** The threshold comparison is a
+ strict `>`, so a count equal to `VIRUSTOTAL_FILE_SUSPICIOUS` /
+ `VIRUSTOTAL_FILE_MALICIOUS` (or the IP equivalents) is still treated as clean.
+ Lower the threshold by one to also block the equal case.
+- **The client IP is never scanned.** Only global IP addresses are looked up;
+ private, loopback and other non-global addresses are skipped. Behind another
+ reverse proxy, make sure BunkerWeb's real-IP handling is configured so
+ `remote_addr` is the actual client.
+- **Requests time out / are slow.** Each cache miss makes a synchronous HTTP
+ call to VirusTotal bounded by `VIRUSTOTAL_TIMEOUT` (default `1000` ms). Raise
+ it if your path to the API is slow, but remember it adds latency to scanned
+ requests.
+
+# Limitations
+
+- **File submission is not supported.** The plugin only _looks up_ existing
+ file hashes on VirusTotal; it never uploads a file for analysis. A brand-new
+ or otherwise unknown file returns `404` and is allowed through. Pair this
+ with the ClamAV plugin if you need uploads to be scanned for unknown content.
+- **IPs are scanned only when global.** Private, loopback and link-local client
+ addresses are skipped by design.
+- **No startup health check.** Because VirusTotal exposes no usable ping
+ endpoint, there is no `init_worker` pre-connect — an unreachable API or a bad
+ key is only detected on the first request that triggers a lookup.
+- **Verdict quality depends on VirusTotal's data.** Only files and IPs already
+ present in VirusTotal's database yield a meaningful verdict, and the
+ suspicious / malicious counts reflect third-party engines, so tune the
+ thresholds to your tolerance for false positives.
diff --git a/virustotal/docs/diagram.drawio b/virustotal/docs/diagram.drawio
deleted file mode 100644
index ce4f0b1..0000000
--- a/virustotal/docs/diagram.drawio
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/virustotal/docs/diagram.mmd b/virustotal/docs/diagram.mmd
new file mode 100644
index 0000000..e53fc3c
--- /dev/null
+++ b/virustotal/docs/diagram.mmd
@@ -0,0 +1,40 @@
+flowchart TD
+ accTitle: BunkerWeb VirusTotal plugin request flow
+ accDescr: A client request first passes BunkerWeb core checks, then virustotal.lua optionally checks the client IP and any uploaded file against the VirusTotal API v3. Both paths share a 24-hour cache (IP keyed by address, file keyed by SHA-256). The suspicious and malicious counts are compared to configurable thresholds, and a result over threshold denies the request.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb access phase]
+ direction TB
+ core["1. Core checks first: rate limit, bad behavior, antibot, DNSBL, black / whitelist"]
+ lua["2. virustotal.lua"]
+ ipscan["IP scan (opt-in): GET /api/v3/ip_addresses/{ip}"]
+ filescan["File scan (opt-in): SHA-256, GET /api/v3/files/{hash}"]
+ cache{{"24h cache hit?"}}
+ core --> lua
+ lua --> ipscan --> cache
+ lua --> filescan --> cache
+ end
+
+ vt[["VirusTotal API v3"]]
+ verdict{"evaluate(): suspicious / malicious over threshold?"}
+ allow["Allow to upstream"]
+ deny["Deny request (get_deny_status)"]
+ upstream([Upstream app])
+
+ client -->|request| core
+ cache -.->|miss| vt
+ vt -.->|"last_analysis_stats"| verdict
+ cache -->|hit| verdict
+ verdict -->|no| allow
+ verdict -->|yes| deny
+ allow --> upstream
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class allow,upstream ok;
+ class deny deny;
+ class vt svc;
+ class client,core,lua,ipscan,filescan,cache app;
diff --git a/virustotal/docs/diagram.svg b/virustotal/docs/diagram.svg
deleted file mode 100644
index 6ab63d0..0000000
--- a/virustotal/docs/diagram.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/virustotal/plugin.json b/virustotal/plugin.json
index dd742ac..44d2e39 100644
--- a/virustotal/plugin.json
+++ b/virustotal/plugin.json
@@ -1,8 +1,8 @@
{
"id": "virustotal",
"name": "VirusTotal",
- "description": "Automatic scan of uploaded files and ips optionally with the VirusTotal API.",
- "version": "1.10",
+ "description": "Automatic scan of uploaded files and client IPs with the VirusTotal API.",
+ "version": "1.11",
"stream": "partial",
"settings": {
"USE_VIRUSTOTAL": {
@@ -23,6 +23,15 @@
"regex": "^.*$",
"type": "password"
},
+ "VIRUSTOTAL_API_URL": {
+ "context": "global",
+ "default": "https://www.virustotal.com/api/v3",
+ "help": "Base URL of the VirusTotal API (or a VirusTotal-compatible endpoint).",
+ "id": "virustotal-api-url",
+ "label": "API URL",
+ "regex": "^https?://.*$",
+ "type": "text"
+ },
"VIRUSTOTAL_TIMEOUT": {
"context": "global",
"default": "1000",
@@ -44,7 +53,7 @@
"VIRUSTOTAL_SCAN_IP": {
"context": "multisite",
"default": "yes",
- "help": "Activate automatic scan of uploaded ips with VirusTotal.",
+ "help": "Activate automatic scan of the client IP with VirusTotal.",
"id": "virustotal-scan-ip",
"label": "Scan IP addresses",
"regex": "^(yes|no)$",
diff --git a/virustotal/ui/actions.py b/virustotal/ui/actions.py
index e7086fa..c7a42c6 100644
--- a/virustotal/ui/actions.py
+++ b/virustotal/ui/actions.py
@@ -18,10 +18,7 @@ def pre_render(**kwargs):
except BaseException as e:
logger.debug(format_exc())
logger.error(f"Failed to get virustotal ping: {e}")
- ret["error"] = str(e)
-
- if "error" in ret:
- return ret
+ ret["error"] = "Could not retrieve the plugin status"
return ret
diff --git a/virustotal/virustotal.lua b/virustotal/virustotal.lua
index 9ec4d66..7cca596 100644
--- a/virustotal/virustotal.lua
+++ b/virustotal/virustotal.lua
@@ -6,6 +6,7 @@ local sha256 = require("resty.sha256")
local str = require("resty.string")
local upload = require("resty.upload")
local utils = require("bunkerweb.utils")
+local virustotal_helpers = require("virustotal.virustotal_helpers")
local virustotal = class("virustotal", plugin)
@@ -213,17 +214,14 @@ function virustotal:check_file()
end
function virustotal:get_result(response, type)
- local result = "clean"
- if
- response["suspicious"] > tonumber(self.variables["VIRUSTOTAL_" .. type .. "_SUSPICIOUS"])
- or response["malicious"] > tonumber(self.variables["VIRUSTOTAL_" .. type .. "_MALICIOUS"])
- then
- result = tostring(response["suspicious"])
- .. " suspicious and "
- .. tostring(response["malicious"])
- .. " malicious"
- end
- return result
+ -- Threshold evaluation lives in virustotal/virustotal_helpers.lua so it can be
+ -- unit-tested with busted outside OpenResty (see spec/virustotal_helpers_spec.lua).
+ return virustotal_helpers.evaluate(
+ response["suspicious"],
+ response["malicious"],
+ tonumber(self.variables["VIRUSTOTAL_" .. type .. "_SUSPICIOUS"]),
+ tonumber(self.variables["VIRUSTOTAL_" .. type .. "_MALICIOUS"])
+ )
end
function virustotal:is_in_cache(key)
@@ -252,8 +250,16 @@ function virustotal:request(url)
local timeout = tonumber(self.variables["VIRUSTOTAL_TIMEOUT"]) or 1000
httpc:set_timeouts(timeout, timeout, timeout)
-- Send request
+ local base_url = self.variables["VIRUSTOTAL_API_URL"]
+ if base_url == nil or base_url == "" then
+ base_url = "https://www.virustotal.com/api/v3"
+ end
+ -- Strip trailing slash(es) so base_url .. "/files/..." never doubles the slash.
+ while base_url:sub(-1) == "/" do
+ base_url = base_url:sub(1, -2)
+ end
local res
- res, err = httpc:request_uri("https://www.virustotal.com/api/v3" .. url, {
+ res, err = httpc:request_uri(base_url .. url, {
headers = {
["x-apikey"] = self.variables["VIRUSTOTAL_API_KEY"],
},
diff --git a/virustotal/virustotal_helpers.lua b/virustotal/virustotal_helpers.lua
new file mode 100644
index 0000000..e423bcb
--- /dev/null
+++ b/virustotal/virustotal_helpers.lua
@@ -0,0 +1,19 @@
+-- Pure helpers extracted from virustotal.lua so they can be unit-tested with
+-- busted outside the OpenResty runtime. No ngx/resty dependencies — see
+-- spec/virustotal_helpers_spec.lua.
+local tostring = tostring
+
+local _M = {}
+
+-- Decide whether a VirusTotal last_analysis_stats result is malicious. Returns the
+-- string "clean" when both counts are within their thresholds, otherwise a human
+-- readable " suspicious and malicious" summary. Thresholds use a strict ">"
+-- so a count equal to its threshold is still considered clean.
+function _M.evaluate(suspicious, malicious, susp_threshold, mal_threshold)
+ if suspicious > susp_threshold or malicious > mal_threshold then
+ return tostring(suspicious) .. " suspicious and " .. tostring(malicious) .. " malicious"
+ end
+ return "clean"
+end
+
+return _M
diff --git a/webhook/README.md b/webhook/README.md
index 45e0abb..a119f0f 100644
--- a/webhook/README.md
+++ b/webhook/README.md
@@ -1,44 +1,110 @@
# WebHook plugin
-
-
-
+
+
+```mermaid
+flowchart TD
+ accTitle: BunkerWeb WebHook plugin notification flow
+ accDescr: The plugin does not block traffic. When BunkerWeb denies a request, webhook.lua runs on the log phase, builds a JSON payload, and schedules an async ngx.timer so the HTTP POST to the custom endpoint happens after the response, leaving request latency unaffected. A 429 rate-limit response is retried after its Retry-After delay.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb]
+ direction TB
+ decision{"Request denied?"}
+ log["webhook.lua (log phase): build JSON payload (content: IP, reason, request, headers)"]
+ timer["ngx.timer.at(0): async, after response"]
+ decision -->|yes| log --> timer
+ end
+
+ endpoint[["Custom HTTP endpoint WEBHOOK_URL"]]
+ served([Response already returned to client])
+
+ client -->|request| decision
+ decision -->|no| served
+ timer -.->|"HTTP POST JSON (async)"| endpoint
+ endpoint -.->|"429 -> retry after Retry-After"| timer
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class served ok;
+ class log,timer deny;
+ class endpoint svc;
+ class client,decision app;
+```
+
+This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github)
+plugin posts an attack notification to a custom HTTP endpoint of your choice
+(a webhook) every time BunkerWeb denies a request. It is a generic notifier: it
+never inspects or blocks traffic itself - it only reports decisions that
+BunkerWeb's other plugins (rate limit, bad behavior, antibot, blacklist, ...)
+have already made.
-This [BunkerWeb](https://www.bunkerweb.io/?utm_campaign=self&utm_source=github) plugin will automatically send you attack notifications on a custom HTTP endpoint of your choice using a webhook.
+The notification is assembled and dispatched from BunkerWeb's `log` phase, after
+the response has already been returned to the client. The actual HTTP `POST`
+runs inside an `ngx.timer.at(0, ...)` callback, so it is sent asynchronously and
+adds zero latency to the request. The plugin works on both HTTP and stream (L4)
+servers.
# Table of contents
- [WebHook plugin](#webhook-plugin)
- [Table of contents](#table-of-contents)
-- [Prerequisites](#prerequisites)
+- [How it works](#how-it-works)
- [Setup](#setup)
- [Docker](#docker)
- [Swarm](#swarm)
- [Kubernetes](#kubernetes)
- [Settings](#settings)
-- [TODO](#todo)
-
-# Prerequisites
-
-Please read the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) of the BunkerWeb documentation first.
+- [Payload format](#payload-format)
+- [Troubleshooting](#troubleshooting)
+- [Notes](#notes)
+
+# How it works
+
+For each request that reaches a site with `USE_WEBHOOK=yes`:
+
+1. BunkerWeb's normal access-phase checks run (rate limit, bad behavior,
+ antibot, DNSBL, blacklist, ...). The webhook plugin takes no part in this
+ decision and never blocks anything.
+2. On the `log` phase, `webhook.lua` runs. If the request was **not** denied
+ (`utils.get_reason` returns nothing), the plugin does nothing - only denied
+ requests trigger a notification. A companion `log_default` hook covers
+ denials that hit the default server when `DISABLE_DEFAULT_SERVER=yes`.
+3. For a denied request, the plugin builds a JSON payload of the form
+ `{"content": ""}`. The message is a markdown code block holding the
+ client IP, the deny reason and its reason data, the raw request line
+ (`ngx.var.request`), and every request header. Headers that carry
+ credentials are redacted (see [Notes](#notes)).
+4. The send is scheduled with `ngx.timer.at(0, self.send, ...)`. The HTTP `POST`
+ to `WEBHOOK_URL` (`Content-Type: application/json`) therefore happens
+ asynchronously, after the response has been returned - request latency is
+ unaffected.
+5. If the endpoint replies `429` and `WEBHOOK_RETRY_IF_LIMITED=yes`, the timer
+ is rescheduled after the response's `Retry-After` delay. Otherwise any
+ non-`2xx` response (including a `429` when retries are disabled) is logged
+ and the notification is dropped.
# 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 services to setup besides the plugin itself.
+See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
+of the BunkerWeb documentation for the generic plugin installation procedure
+(the short version: drop the `webhook/` directory into the scheduler's
+`/data/plugins/` and restart). There is no additional service to stand up
+besides the receiving endpoint itself.
## Docker
```yaml
services:
-
bw-scheduler:
- image: bunkerity/bunkerweb-scheduler:1.6.0-rc1
+ image: bunkerity/bunkerweb-scheduler:1.6.11
...
environment:
- - USE_WEBHOOK=yes
- - WEBHOOK_URL=https://api.example.com/bw
+ USE_WEBHOOK: "yes"
+ WEBHOOK_URL: "https://api.example.com/bw"
...
```
@@ -46,13 +112,12 @@ services:
```yaml
services:
-
bw-scheduler:
- image: bunkerity/bunkerweb-scheduler:1.6.0-rc1
- ..
+ image: bunkerity/bunkerweb-scheduler:1.6.11
+ ...
environment:
- - USE_WEBHOOK=yes
- - WEBHOOK_URL=https://api.example.com/bw
+ USE_WEBHOOK: "yes"
+ WEBHOOK_URL: "https://api.example.com/bw"
...
```
@@ -76,14 +141,68 @@ metadata:
| `WEBHOOK_URL` | `https://api.example.com/bw` | global | no | Address of the webhook. |
| `WEBHOOK_RETRY_IF_LIMITED` | `no` | global | no | Retry to send the request if the remote server is rate limiting us (may consume a lot of resources). |
-# TODO
-
-- Add more info in notification :
- - Date
- - Country of IP
- - ASN of IP
- - ...
-- Add settings to control what details to send :
- - Anonymize IP
- - Add body
- - Add headers
+# Payload format
+
+This is a **generic** webhook, so your endpoint must accept a `POST` whose body
+is a single JSON object of the form:
+
+````json
+{
+ "content": "```Denied request for IP 1.2.3.4 (reason = ... / reason data = {...}).\n\nRequest data :\n\nGET / HTTP/1.1\nhost: app.example.com\nuser-agent: ...\n```"
+}
+````
+
+The `content` field is a single string carrying a markdown code block. The
+plain message inside it is what the notifier-style plugins (Discord, Slack, ...)
+also send, so the same receiver shape works across all of them. Parse the
+`content` string on your side if you need the structured fields - the plugin
+does not send them as separate JSON keys.
+
+The same shape is used by the connectivity test endpoint: a `POST` to
+`/webhook/ping` sends `{"content": "```Test message from bunkerweb```"}` to
+`WEBHOOK_URL` and reports the result. The BunkerWeb web UI surfaces this as the
+plugin's status.
+
+# Troubleshooting
+
+- **No notifications arrive.** Confirm `USE_WEBHOOK=yes` is set on the site and
+ that `WEBHOOK_URL` is reachable from the scheduler/BunkerWeb container. Remember
+ that **only denied requests** are reported - a site with no blocked traffic
+ produces no notifications.
+- **Endpoint rejects the payload.** The receiver must accept a `POST` of
+ `{"content": "..."}` JSON with `Content-Type: application/json`. A receiver
+ expecting a different schema will reject it; adapt the receiver (or front it
+ with a small adapter) to the shape in [Payload format](#payload-format).
+- **Notifications are silently lost.** Any non-`2xx` response from the endpoint
+ is logged as an error in the scheduler/nginx logs and the notification is
+ dropped. These failures are **log-only** and never affect the client request.
+- **You are being rate-limited.** If the endpoint returns `429`, set
+ `WEBHOOK_RETRY_IF_LIMITED=yes` so the plugin honors the `Retry-After` header
+ and retries instead of dropping the message (this can consume more resources
+ under sustained attacks).
+- **Test the connection.** Issue a `POST` to `/webhook/ping` (or use the status
+ card in the BunkerWeb web UI) to verify the endpoint receives a test message.
+
+# Notes
+
+- **Denials only, never blocks.** This plugin only reacts to requests that
+ BunkerWeb has already denied; it never inspects request content and never
+ blocks or delays traffic on its own. Disabling it changes nothing about
+ whether a request is allowed.
+- **Zero added latency.** The notification is sent from an `ngx.timer.at(0)`
+ callback after the response is returned, so the client never waits on the
+ webhook round-trip.
+- **Failures are log-only.** If the HTTP client cannot be created, the request
+ fails, or the endpoint returns a non-`2xx` status, the error is written to the
+ logs and the notification is discarded - it is never retried unless it was a
+ `429` with `WEBHOOK_RETRY_IF_LIMITED=yes`.
+- **Sensitive headers are redacted.** Before headers are placed in the payload,
+ values of credential-bearing headers are replaced with `[REDACTED]`:
+ `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`,
+ `X-Csrf-Token`, `X-Xsrf-Token`, `X-Auth-Token`, `X-Access-Token`,
+ `X-Session-Token`, and `X-Amz-Security-Token` (matched case-insensitively).
+- **Generic payload shape.** Because the body is just `{"content": "..."}`,
+ document this shape for whoever owns the receiving endpoint so they can parse
+ the message reliably.
+- **Stream support.** The plugin works on stream (L4) servers as well as HTTP
+ servers.
diff --git a/webhook/docs/diagram.drawio b/webhook/docs/diagram.drawio
deleted file mode 100644
index 0ae8b84..0000000
--- a/webhook/docs/diagram.drawio
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/webhook/docs/diagram.mmd b/webhook/docs/diagram.mmd
new file mode 100644
index 0000000..d3d77d8
--- /dev/null
+++ b/webhook/docs/diagram.mmd
@@ -0,0 +1,30 @@
+flowchart TD
+ accTitle: BunkerWeb WebHook plugin notification flow
+ accDescr: The plugin does not block traffic. When BunkerWeb denies a request, webhook.lua runs on the log phase, builds a JSON payload, and schedules an async ngx.timer so the HTTP POST to the custom endpoint happens after the response, leaving request latency unaffected. A 429 rate-limit response is retried after its Retry-After delay.
+
+ client([Client / Browser])
+
+ subgraph bw[BunkerWeb]
+ direction TB
+ decision{"Request denied?"}
+ log["webhook.lua (log phase): build JSON payload (content: IP, reason, request, headers)"]
+ timer["ngx.timer.at(0): async, after response"]
+ decision -->|yes| log --> timer
+ end
+
+ endpoint[["Custom HTTP endpoint WEBHOOK_URL"]]
+ served([Response already returned to client])
+
+ client -->|request| decision
+ decision -->|no| served
+ timer -.->|"HTTP POST JSON (async)"| endpoint
+ endpoint -.->|"429 -> retry after Retry-After"| timer
+
+ classDef ok fill:#eafaf0,stroke:#27ae60,color:#14532d;
+ classDef deny fill:#fdecea,stroke:#e74c3c,color:#7f1d1d;
+ classDef svc fill:#e8f4fd,stroke:#2980b9,color:#0c4a6e;
+ classDef app fill:#ffffff,stroke:#334155,color:#0f172a;
+ class served ok;
+ class log,timer deny;
+ class endpoint svc;
+ class client,decision app;
diff --git a/webhook/docs/diagram.svg b/webhook/docs/diagram.svg
deleted file mode 100644
index 4f5bc3a..0000000
--- a/webhook/docs/diagram.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/webhook/plugin.json b/webhook/plugin.json
index df399b2..3529496 100644
--- a/webhook/plugin.json
+++ b/webhook/plugin.json
@@ -2,7 +2,7 @@
"id": "webhook",
"name": "WebHook",
"description": "Send alerts to a custom webhook.",
- "version": "1.10",
+ "version": "1.11",
"stream": "yes",
"settings": {
"USE_WEBHOOK": {
diff --git a/webhook/ui/actions.py b/webhook/ui/actions.py
index 538f0b4..301ede6 100644
--- a/webhook/ui/actions.py
+++ b/webhook/ui/actions.py
@@ -18,10 +18,7 @@ def pre_render(**kwargs):
except BaseException as e:
logger.debug(format_exc())
logger.error(f"Failed to get webhook ping: {e}")
- ret["error"] = str(e)
-
- if "error" in ret:
- return ret
+ ret["error"] = "Could not retrieve the plugin status"
return ret
diff --git a/webhook/webhook.lua b/webhook/webhook.lua
index 5d51938..8d58c49 100644
--- a/webhook/webhook.lua
+++ b/webhook/webhook.lua
@@ -3,6 +3,7 @@ local class = require("middleclass")
local http = require("resty.http")
local plugin = require("bunkerweb.plugin")
local utils = require("bunkerweb.utils")
+local webhook_helpers = require("webhook.webhook_helpers")
local webhook = class("webhook", plugin)
@@ -21,6 +22,7 @@ local get_variable = utils.get_variable
local get_reason = utils.get_reason
local tostring = tostring
local encode = cjson.encode
+local redact_header = webhook_helpers.redact_header
function webhook:initialize(ctx)
-- Call parent initialize
@@ -55,7 +57,7 @@ function webhook:log(bypass_use_webhook)
data.content = data.content .. "error while getting headers : " .. err
else
for header, value in pairs(headers) do
- data.content = data.content .. header .. ": " .. value .. "\n"
+ data.content = data.content .. header .. ": " .. redact_header(header, value) .. "\n"
end
end
data.content = data.content .. "```"
@@ -73,6 +75,7 @@ function webhook.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
local res, err_http = httpc:request_uri(self.variables["WEBHOOK_URL"], {
method = "POST",
@@ -84,6 +87,7 @@ function webhook.send(premature, self, data)
httpc:close()
if not res then
self.logger:log(ERR, "error while sending request : " .. err_http)
+ return
end
if self.variables["WEBHOOK_RETRY_IF_LIMITED"] == "yes" and res.status == 429 and res.headers["Retry-After"] then
self.logger:log(WARN, "HTTP endpoint is rate-limiting us, retrying in " .. res.headers["Retry-After"] .. "s")
@@ -143,6 +147,7 @@ function webhook:api()
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(self.variables["WEBHOOK_URL"], {
method = "POST",
@@ -153,7 +158,7 @@ function webhook:api()
})
httpc:close()
if not res then
- self.logger:log(ERR, "error while sending request : " .. err_http)
+ return self:ret(true, "error while sending request : " .. err_http, HTTP_INTERNAL_SERVER_ERROR)
end
if self.variables["WEBHOOK_RETRY_IF_LIMITED"] == "yes" and res.status == 429 and res.headers["Retry-After"] then
return self:ret(
diff --git a/webhook/webhook_helpers.lua b/webhook/webhook_helpers.lua
new file mode 100644
index 0000000..df4fad7
--- /dev/null
+++ b/webhook/webhook_helpers.lua
@@ -0,0 +1,46 @@
+-- Pure helpers extracted from webhook.lua so they can be unit-tested with busted
+-- outside the OpenResty runtime. No ngx/resty dependencies — see
+-- spec/webhook_helpers_spec.lua.
+local lower = string.lower
+local concat = table.concat
+local tostring = tostring
+local type = type
+
+local _M = {}
+
+-- Request headers that carry credentials/secrets. Their values are never
+-- forwarded to the third-party notification service. Keys are lowercase so the
+-- lookup is case-insensitive (HTTP header names are case-insensitive).
+local SENSITIVE_HEADERS = {
+ ["authorization"] = true,
+ ["proxy-authorization"] = true,
+ ["cookie"] = true,
+ ["set-cookie"] = true,
+ ["x-api-key"] = true,
+ ["x-csrf-token"] = true,
+ ["x-xsrf-token"] = true,
+ ["x-auth-token"] = true,
+ ["x-access-token"] = true,
+ ["x-session-token"] = true,
+ ["x-amz-security-token"] = true,
+}
+
+-- Repeated headers are returned by ngx.req.get_headers() as an array table.
+-- Flatten to a single string so downstream concatenation never fails on a table.
+function _M.flatten_header_value(value)
+ if type(value) == "table" then
+ return concat(value, ", ")
+ end
+ return tostring(value)
+end
+
+-- Return a notification-safe value for a header: "[REDACTED]" for sensitive
+-- headers, otherwise the flattened value.
+function _M.redact_header(name, value)
+ if SENSITIVE_HEADERS[lower(name)] then
+ return "[REDACTED]"
+ end
+ return _M.flatten_header_value(value)
+end
+
+return _M