diff --git a/README.md b/README.md index 45cd827..0a0e3b3 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,12 @@ Environment variables: | Variable | Default | Description | | --- | --- | --- | | `DISCORD_WEBHOOK_URL` | *(required)* | Discord channel webhook URL | +| `WEBHOOK_SECRET` | *(unset)* | if set, POSTs must arrive at `/`; mismatches get `404`. Leave unset to disable the guard | | `LISTEN_HOST` | `127.0.0.1` | bind address | | `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 This project uses [uv](https://docs.astral.sh/uv/) for Python and dependency management. @@ -42,6 +45,8 @@ In `parsedmarc.ini`: ```ini [webhook] aggregate_url = http://127.0.0.1:8080/ +# with WEBHOOK_SECRET set: +# aggregate_url = https://dmarc.example.com/ ``` (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`); - requires the `DISCORD_WEBHOOK_URL` environment variable; +- honors `WEBHOOK_SECRET` for the path-based auth guard (recommended when public); - runs as an unprivileged user; - serves `/healthz` (returns `200 ok`) for container health checks. ```sh 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 diff --git a/dmarc_to_discord.py b/dmarc_to_discord.py index 96b4adb..6fbcff1 100644 --- a/dmarc_to_discord.py +++ b/dmarc_to_discord.py @@ -8,12 +8,14 @@ it to Discord embeds and forward. Env vars: DISCORD_WEBHOOK_URL required + WEBHOOK_SECRET optional; if set, POSTs must arrive at / LISTEN_HOST default 127.0.0.1 LISTEN_PORT default 8080 """ from __future__ import annotations +import hmac import json import logging import os @@ -21,10 +23,12 @@ import sys import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any +from urllib.parse import urlsplit import requests 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_PORT = int(os.environ.get("LISTEN_PORT", "8080")) @@ -217,7 +221,21 @@ def send_to_discord(report): class Handler(BaseHTTPRequestHandler): + def _authorized(self) -> bool: + # When no secret is configured the guard is disabled (local convenience). + # Otherwise the request path must equal "/"; 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): + 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)) body = self.rfile.read(length) try: @@ -255,6 +273,10 @@ def main(): format="%(asctime)s %(levelname)s %(message)s") srv = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler) logging.info("listening on %s:%d", LISTEN_HOST, LISTEN_PORT) + if WEBHOOK_SECRET: + logging.info("secret guard enabled; POST to /") + else: + logging.warning("WEBHOOK_SECRET unset; accepting unauthenticated POSTs on any path") srv.serve_forever()