Skip to content

Security: TadMSTR/webhook-doorman

Security

SECURITY.md

Security Policy

Reporting a vulnerability

Report privately through GitHub Security Advisories. Please do not open a public issue for a vulnerability.

Include what you did, what happened, and what you expected. A minimal config.yml that reproduces the behaviour is the single most useful thing you can attach — redact your secrets.

Expect an acknowledgement within 7 days. This is a personal project, so timelines are best-effort, but a report that shows a verification bypass will be treated as urgent.

Supported versions

The latest released version. There are no maintained backport branches.

What is in scope

  • Any path that accepts a request it should have rejected — a signature bypass, a none source reachable from outside its allow_from, a disabled source that still serves.
  • Credential exposure: a secret reaching the event log, a log line, an error response, or the published image.
  • Anything that lets a caller reach /admin/ without a valid token.
  • GET /admin/dlq returning anything beyond failure metadata. It reports event_id, source, sink, attempt, response_code, error and exhausted_at. It must never return a stored payload, request headers, or parser context — the event body is retrievable only by deliberately replaying it, and a list endpoint over stored bodies would be a much larger exfiltration surface behind the same single token. A response containing payload content is a vulnerability.
  • A secret reaching /metrics. The endpoint exposes source and sink names and traffic volume; anything else in its output — a payload fragment, a header, a credential — is a bug.
  • Template sandbox escape: reaching the filesystem, the environment, or arbitrary code execution through a sink template.
  • Webhook content reaching a bundled sink's destination without escaping appropriate to how that destination renders it — for example unescaped HTML reaching a rich-text field.
  • Denial of service through unbounded resource use — an unbounded body read, unbounded storage growth, a retry loop that never terminates.

What is out of scope

  • Reverse proxy and TLS configuration. This service speaks plain HTTP and expects a proxy in front of it for public ingress.
  • The exposure decision itself. The container binds 0.0.0.0 by design; what can reach that port is the operator's port-publish and network configuration. See ARCHITECTURE.md.
  • /metrics being unauthenticated by default. That is deliberate — it is the Prometheus scrape convention, and a mandatory token breaks a stock scrape_config. It exposes topology, never a credential. Deny it at the reverse proxy alongside /admin/, or set metrics.token_env if you accept the non-standard scrape config. Note that a token shorter than metrics.min_token_length is treated as absent and leaves the endpoint open; that is logged as metrics_unauthenticated at every boot.
  • strategy: none used carelessly. It is guarded and it warns loudly, but an operator who sets allow_from: ["0.0.0.0/0"] has made an informed choice.
  • The secrecy of your secrets. A leaked webhook secret lets an attacker forge signed requests; that is verification working, not failing.
  • Vulnerabilities in producers, sinks, or upstream dependencies — report those upstream, though a note here is welcome if this project's use of them makes an issue worse.

Design commitments

These are the properties this project is built to hold. A report showing any of them broken is a vulnerability, not a feature request.

  1. There is no code path where a missing or empty secret results in a request being accepted.

  2. Every credential comparison is constant-time.

  3. HMAC is computed over the raw request bytes, before any decoding.

  4. Credentials are redacted before anything is written to storage, a log, or an exported trace span. That includes two boundaries beyond the ingest path: a destination's own response body, which reaches the DLQ inside a delivery error message; and the request URL captured by OpenTelemetry's automatic httpx instrumentation, which for a Discord or Slack sink is the credential. Both are redacted as of 0.3.0.

    This holds for credentials declared in a *_env field, which is how every bundled sink declares one. A URL containing a credential written inline as url: is not a resolved secret and is redacted nowhere — use url_env.

  5. A verification failure tells the caller nothing about why.

  6. A bundled sink escapes webhook content for the way its destination renders it. Verification proves a payload's origin, never that its content is safe — an issue title on a public repo is written by a stranger and is authentically signed by GitHub either way.

  7. Content that a source's parser declared attacker-authored cannot escape its <untrusted> fence when the destination sink is agent_readable. This survives storage and replay.

  8. No endpoint returns withheld or dead-lettered content. GET /admin/dlq and GET /admin/held return failure metadata only, and a detector reports the names of the rules that matched, never the text that matched them.

The content-safety layer is defence in depth, and it is evadable

Added in 0.4.0. Please read this before relying on any of it.

The prompt-injection detector is telemetry, not a boundary. It is a small set of scored regular expressions. It will miss things a person would catch in a second, and it will flag legitimate content — a security repository's issue tracker carries "ignore all previous instructions" as ordinary text. That is why annotate is the default and why the README tells you to watch your own false-positive rate before enabling quarantine.

This is not a limitation of this implementation. Machine-learned guards in this class are also evadable: arXiv 2510.01529 (June 2026) documents controlled-release bypasses against the reference open-source prompt-injection classifiers. A later release will add ML and LLM backends behind the same interface; none of them will change this paragraph. A detector score is a signal to investigate, not a control to depend on.

What is not probabilistic, and what you should actually lean on:

Mechanism Kind Relies on
filter.event_types / require / deny deterministic your config, and the payload's structure
Unicode sanitization deterministic a closed set of codepoints
Fencing deterministic the parser's declaration of which fields are free text
Detector score probabilistic pattern matching, and it is evadable

The first three remove more real risk than the fourth, and they do it the same way every time. Configure them first.

Fencing marks a boundary; it does not enforce one. <untrusted> is a text delimiter, and the only thing between it and a forged boundary is that content cannot write the tag — opening or closing, with or without attributes, which are all removed case-insensitively and whitespace-tolerantly before wrapping. Whether the model on the other end respects the fence is a property of that model and its system prompt, not of this router. Treat fenced content as data in your agent's prompt, and do not give an agent irreversible capabilities on the strength of a fence alone.

A report that the fence can be escaped — that content reaches the far side outside the <untrusted> wrapper, or that a closing tag survives — is a vulnerability. A report that the detector missed an injection is not; it is expected, and the design says so.

Escaping in your own templates

The bundled sinks handle this: the Vikunja sink escapes its description, because Vikunja renders that field as HTML, and the chat sinks do not, because escaping a chat message corrupts it.

A type: http sink is yours. If you point one at something that renders HTML, escape explicitly with Jinja's | e filter — the generic sink cannot know how your endpoint treats its input. For JSON bodies use | tojson, which is the correct escaping for that context.

There aren't any published security advisories