MAS-WP-0005-T12: gateway reads trusted A-R verdict; ticket service replies only on DMARC pass

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 252470@bnt-lap001
Assistant-Session: ad628c8f-4be2-4979-94c9-76c7ed6f8241
This commit is contained in:
tegwick 2026-10-18 22:40:30 +02:00
parent ab3346d187
commit cca58d41fb
6 changed files with 128 additions and 13 deletions

View file

@ -29,7 +29,45 @@ from mas_core.statute import build_statute
EVENT_VERSION = "0.1"
def message_meta_from_rfc822(raw: bytes) -> dict:
def authentication_from_headers(headers: list[tuple[str, str]], trusted_authserv_id: Optional[str]) -> Optional[dict]:
"""Sender-authentication verdict from our own MTA's Authentication-Results.
Only A-R headers whose authserv-id is `trusted_authserv_id` count, and only
those above the first Received header, i.e. added at our hop (RFC 8601
section 5). Anything a sender brought along sits below that line. The MTA
also strips incoming A-R headers claiming our id; this is the second layer.
"""
if not trusted_authserv_id:
return None
want = trusted_authserv_id.lower()
verdict: dict = {}
for name, value in headers:
lname = name.lower()
if lname == "received":
break
if lname != "authentication-results":
continue
authserv, _, results = str(value).partition(";")
ids = authserv.split()
if not ids or ids[0].lower() != want:
continue
for clause in results.split(";"):
words = clause.split()
if not words or "=" not in words[0]:
continue
method, result = words[0].split("=", 1)
method, result = method.lower(), result.lower()
if method in ("dmarc", "spf", "dkim"):
# several DKIM signatures: one pass is enough
if not (method == "dkim" and verdict.get("dkim") == "pass"):
verdict[method] = result
for w in words[1:]:
if method == "dmarc" and w.lower().startswith("header.from="):
verdict["header_from"] = w.split("=", 1)[1]
return verdict or None
def message_meta_from_rfc822(raw: bytes, trusted_authserv_id: Optional[str] = None) -> dict:
msg: EmailMessage = BytesParser(policy=policy.default).parsebytes(raw)
body = msg.get_body(preferencelist=("plain",))
text = body.get_content() if body is not None else ""
@ -43,6 +81,7 @@ def message_meta_from_rfc822(raw: bytes) -> dict:
"date": str(msg.get("Date", "")) or None,
"auto_submitted": str(msg.get("Auto-Submitted", "")) or None,
"precedence": str(msg.get("Precedence", "")) or None,
"authentication": authentication_from_headers(msg.items(), trusted_authserv_id),
"text": text,
}

View file

@ -23,6 +23,7 @@ import argparse
import json
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Optional
from urllib.parse import parse_qs, urlsplit
from .core import Resolver, gateway, message_meta_from_rfc822
@ -33,26 +34,30 @@ MAX_BODY = 25 * 1024 * 1024
def cmd_pipe(args, resolver) -> int:
raw = sys.stdin.buffer.read()
deliveries = gateway(args.recipient, message_meta_from_rfc822(raw), resolver, args.app_url)
meta = message_meta_from_rfc822(raw, args.trusted_authserv_id)
deliveries = gateway(args.recipient, meta, resolver, args.app_url)
for d in deliveries:
print(json.dumps(d.__dict__), file=sys.stderr)
return EX_TEMPFAIL if any(d.outcome == "tempfail" for d in deliveries) else 0
def webhook_message(body: bytes, ctype: str, query: dict) -> tuple[list[str], dict]:
def webhook_message(body: bytes, ctype: str, query: dict,
trusted_authserv_id: Optional[str] = None) -> tuple[list[str], dict]:
if ctype.startswith("message/rfc822"):
return query.get("recipient", []), message_meta_from_rfc822(body)
return query.get("recipient", []), message_meta_from_rfc822(body, trusted_authserv_id)
data = json.loads(body.decode())
recipients = data.pop("recipients", None) or [data.pop("recipient")]
meta = {"from": data.get("from"), "to": data.get("to", recipients),
"subject": data.get("subject", ""), "message_id": data.get("message_id"),
"in_reply_to": data.get("in_reply_to"), "references": data.get("references"),
"date": data.get("date"), "auto_submitted": data.get("auto_submitted"),
"precedence": data.get("precedence"), "text": data.get("text", "")}
"precedence": data.get("precedence"), "text": data.get("text", ""),
"authentication": data.get("authentication")} # as verified by the inbound provider
return recipients, meta
def make_webhook_server(listen: str, app_url: str, resolver: Resolver) -> ThreadingHTTPServer:
def make_webhook_server(listen: str, app_url: str, resolver: Resolver,
trusted_authserv_id: Optional[str] = None) -> ThreadingHTTPServer:
host, port = listen.rsplit(":", 1)
class Handler(BaseHTTPRequestHandler):
@ -63,7 +68,8 @@ def make_webhook_server(listen: str, app_url: str, resolver: Resolver) -> Thread
body = self.rfile.read(length)
try:
recipients, meta = webhook_message(body, self.headers.get("Content-Type", ""),
parse_qs(urlsplit(self.path).query))
parse_qs(urlsplit(self.path).query),
trusted_authserv_id)
except (ValueError, KeyError) as e:
return self._reply(400, {"error": f"bad inbound payload: {e}"})
if not recipients:
@ -92,6 +98,9 @@ def main(argv=None) -> int:
ap.add_argument("--statute", action="append", metavar="FILE",
help="pin a statute from local configuration (keyed by its namespace)")
ap.add_argument("--registry", metavar="FILE", help="local registry JSON")
ap.add_argument("--trusted-authserv-id", metavar="ID",
help="read sender authentication only from Authentication-Results with this id "
"(our own MTA); without it, no verdict is reported")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("pipe")
p.add_argument("--recipient", action="append", required=True)
@ -104,7 +113,7 @@ def main(argv=None) -> int:
resolver = Resolver.with_statute_files(args.statute, local_registry=registry)
if args.cmd == "pipe":
return cmd_pipe(args, resolver)
srv = make_webhook_server(args.listen, args.app_url, resolver)
srv = make_webhook_server(args.listen, args.app_url, resolver, args.trusted_authserv_id)
print(f"mas-gateway webhook on http://{args.listen} -> {args.app_url}", file=sys.stderr)
srv.serve_forever()
return 0