Add support for secret webhook path
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user