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.
The latest released version. There are no maintained backport branches.
- Any path that accepts a request it should have rejected — a signature bypass, a
nonesource reachable from outside itsallow_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/dlqreturning anything beyond failure metadata. It reportsevent_id,source,sink,attempt,response_code,errorandexhausted_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.
- 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.0by design; what can reach that port is the operator's port-publish and network configuration. See ARCHITECTURE.md. /metricsbeing unauthenticated by default. That is deliberate — it is the Prometheus scrape convention, and a mandatory token breaks a stockscrape_config. It exposes topology, never a credential. Deny it at the reverse proxy alongside/admin/, or setmetrics.token_envif you accept the non-standard scrape config. Note that a token shorter thanmetrics.min_token_lengthis treated as absent and leaves the endpoint open; that is logged asmetrics_unauthenticatedat every boot.strategy: noneused carelessly. It is guarded and it warns loudly, but an operator who setsallow_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.
These are the properties this project is built to hold. A report showing any of them broken is a vulnerability, not a feature request.
-
There is no code path where a missing or empty secret results in a request being accepted.
-
Every credential comparison is constant-time.
-
HMAC is computed over the raw request bytes, before any decoding.
-
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
httpxinstrumentation, 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
*_envfield, which is how every bundled sink declares one. A URL containing a credential written inline asurl:is not a resolved secret and is redacted nowhere — useurl_env. -
A verification failure tells the caller nothing about why.
-
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.
-
Content that a source's parser declared attacker-authored cannot escape its
<untrusted>fence when the destination sink isagent_readable. This survives storage and replay. -
No endpoint returns withheld or dead-lettered content.
GET /admin/dlqandGET /admin/heldreturn failure metadata only, and a detector reports the names of the rules that matched, never the text that matched them.
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.
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.