Add support for secret webhook path

This commit is contained in:
2026-05-28 23:31:07 +02:00
parent 4b8a187dbd
commit 0e51550cc8
2 changed files with 32 additions and 1 deletions
+10 -1
View File
@@ -20,9 +20,12 @@ Environment variables:
| Variable | Default | Description | | Variable | Default | Description |
| --- | --- | --- | | --- | --- | --- |
| `DISCORD_WEBHOOK_URL` | *(required)* | Discord channel webhook URL | | `DISCORD_WEBHOOK_URL` | *(required)* | Discord channel webhook URL |
| `WEBHOOK_SECRET` | *(unset)* | if set, POSTs must arrive at `/<secret>`; mismatches get `404`. Leave unset to disable the guard |
| `LISTEN_HOST` | `127.0.0.1` | bind address | | `LISTEN_HOST` | `127.0.0.1` | bind address |
| `LISTEN_PORT` | `8080` | bind port | | `LISTEN_PORT` | `8080` | bind port |
When the relay is reachable beyond a trusted private network, set `WEBHOOK_SECRET` to a long random string (e.g. `openssl rand -hex 32`) and put it in the parsedmarc URL — it's a shared secret, so keep TLS in front of it. The secret is compared in constant time, and the health endpoints (`/`, `/health`, `/healthz`) stay open regardless.
## Running ## Running
This project uses [uv](https://docs.astral.sh/uv/) for Python and dependency management. This project uses [uv](https://docs.astral.sh/uv/) for Python and dependency management.
@@ -42,6 +45,8 @@ In `parsedmarc.ini`:
```ini ```ini
[webhook] [webhook]
aggregate_url = http://127.0.0.1:8080/ aggregate_url = http://127.0.0.1:8080/
# with WEBHOOK_SECRET set:
# aggregate_url = https://dmarc.example.com/<secret>
``` ```
(parsedmarc also supports `forensic_url` and `smtp_tls_url`; this relay currently only handles the aggregate-report schema.) (parsedmarc also supports `forensic_url` and `smtp_tls_url`; this relay currently only handles the aggregate-report schema.)
@@ -52,12 +57,16 @@ The included `Dockerfile` builds the service with uv. The image:
- exposes port `8080` and binds to `0.0.0.0` inside the container (override with `LISTEN_PORT` / `LISTEN_HOST`); - exposes port `8080` and binds to `0.0.0.0` inside the container (override with `LISTEN_PORT` / `LISTEN_HOST`);
- requires the `DISCORD_WEBHOOK_URL` environment variable; - requires the `DISCORD_WEBHOOK_URL` environment variable;
- honors `WEBHOOK_SECRET` for the path-based auth guard (recommended when public);
- runs as an unprivileged user; - runs as an unprivileged user;
- serves `/healthz` (returns `200 ok`) for container health checks. - serves `/healthz` (returns `200 ok`) for container health checks.
```sh ```sh
docker build -t dmarc-to-discord . docker build -t dmarc-to-discord .
docker run -p 8080:8080 -e DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... dmarc-to-discord docker run -p 8080:8080 \
-e DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... \
-e WEBHOOK_SECRET=$(openssl rand -hex 32) \
dmarc-to-discord
``` ```
Any platform that builds from a Dockerfile can deploy it: point it at this Any platform that builds from a Dockerfile can deploy it: point it at this
+22
View File
@@ -8,12 +8,14 @@ it to Discord embeds and forward.
Env vars: Env vars:
DISCORD_WEBHOOK_URL required DISCORD_WEBHOOK_URL required
WEBHOOK_SECRET optional; if set, POSTs must arrive at /<secret>
LISTEN_HOST default 127.0.0.1 LISTEN_HOST default 127.0.0.1
LISTEN_PORT default 8080 LISTEN_PORT default 8080
""" """
from __future__ import annotations from __future__ import annotations
import hmac
import json import json
import logging import logging
import os import os
@@ -21,10 +23,12 @@ import sys
import time import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any from typing import Any
from urllib.parse import urlsplit
import requests import requests
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "") DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "")
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
LISTEN_HOST = os.environ.get("LISTEN_HOST", "127.0.0.1") LISTEN_HOST = os.environ.get("LISTEN_HOST", "127.0.0.1")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8080")) LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8080"))
@@ -217,7 +221,21 @@ def send_to_discord(report):
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def _authorized(self) -> bool:
# When no secret is configured the guard is disabled (local convenience).
# Otherwise the request path must equal "/<secret>"; parsedmarc preserves
# the full aggregate_url, so the secret rides along in the path. Constant-
# time compare avoids leaking the secret via response timing.
if not WEBHOOK_SECRET:
return True
path = urlsplit(self.path).path
return hmac.compare_digest(path, "/" + WEBHOOK_SECRET)
def do_POST(self): def do_POST(self):
if not self._authorized():
# 404 (not 401) so the endpoint doesn't advertise that it exists.
self.send_error(404)
return
length = int(self.headers.get("Content-Length", 0)) length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) body = self.rfile.read(length)
try: try:
@@ -255,6 +273,10 @@ def main():
format="%(asctime)s %(levelname)s %(message)s") format="%(asctime)s %(levelname)s %(message)s")
srv = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler) srv = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
logging.info("listening on %s:%d", LISTEN_HOST, LISTEN_PORT) logging.info("listening on %s:%d", LISTEN_HOST, LISTEN_PORT)
if WEBHOOK_SECRET:
logging.info("secret guard enabled; POST to /<WEBHOOK_SECRET>")
else:
logging.warning("WEBHOOK_SECRET unset; accepting unauthenticated POSTs on any path")
srv.serve_forever() srv.serve_forever()