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:
parent
ab3346d187
commit
cca58d41fb
6 changed files with 128 additions and 13 deletions
|
|
@ -63,7 +63,7 @@ if [ -n "$MAS_RELAY" ] && [ -s /etc/mas-relay/username ]; then
|
||||||
# service cap below really bounds everything sent through TEM.
|
# service cap below really bounds everything sent through TEM.
|
||||||
postconf -e "sender_dependent_default_transport_maps=inline:{<>=discard:}" \
|
postconf -e "sender_dependent_default_transport_maps=inline:{<>=discard:}" \
|
||||||
"empty_address_default_transport_maps_lookup_key=<>"
|
"empty_address_default_transport_maps_lookup_key=<>"
|
||||||
SMTP_ARG="--smtp 127.0.0.1:25 --max-sent ${MAS_MAX_SENT:-300}"
|
SMTP_ARG="--smtp 127.0.0.1:25 --max-sent ${MAS_MAX_SENT:-300} --require-dmarc-pass"
|
||||||
else
|
else
|
||||||
postconf -e "default_transport=error:5.7.1 outbound mail disabled"
|
postconf -e "default_transport=error:5.7.1 outbound mail disabled"
|
||||||
fi
|
fi
|
||||||
|
|
@ -111,7 +111,7 @@ postconf -e "header_checks=regexp:/etc/postfix/ar_strip.regexp" "smtpd_milters=i
|
||||||
cat >> /etc/postfix/master.cf <<CF
|
cat >> /etc/postfix/master.cf <<CF
|
||||||
mas unix - n n - - pipe
|
mas unix - n n - - pipe
|
||||||
flags=Rq user=mas argv=/usr/local/bin/mas-gateway
|
flags=Rq user=mas argv=/usr/local/bin/mas-gateway
|
||||||
--statute $STATUTE_DIR/.well-known/mas
|
--statute $STATUTE_DIR/.well-known/mas --trusted-authserv-id $DOMAIN
|
||||||
pipe --app-url http://127.0.0.1:8024/mas-events --recipient \${recipient}
|
pipe --app-url http://127.0.0.1:8024/mas-events --recipient \${recipient}
|
||||||
CF
|
CF
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ CREATE TABLE IF NOT EXISTS outbox (
|
||||||
class TicketService:
|
class TicketService:
|
||||||
def __init__(self, db_path: str = ":memory:", id_factory: Callable[[], str] = default_ticket_id,
|
def __init__(self, db_path: str = ":memory:", id_factory: Callable[[], str] = default_ticket_id,
|
||||||
smtp: Optional[tuple[str, int]] = None, closed_notice: bool = True,
|
smtp: Optional[tuple[str, int]] = None, closed_notice: bool = True,
|
||||||
max_sent: Optional[int] = None):
|
max_sent: Optional[int] = None, require_dmarc_pass: bool = False):
|
||||||
self.db = sqlite3.connect(db_path, check_same_thread=False)
|
self.db = sqlite3.connect(db_path, check_same_thread=False)
|
||||||
self.db.row_factory = sqlite3.Row
|
self.db.row_factory = sqlite3.Row
|
||||||
self.db.executescript(SCHEMA)
|
self.db.executescript(SCHEMA)
|
||||||
|
|
@ -74,6 +74,8 @@ class TicketService:
|
||||||
self.smtp = smtp
|
self.smtp = smtp
|
||||||
self.closed_notice = closed_notice
|
self.closed_notice = closed_notice
|
||||||
self.max_sent = max_sent # lifetime cap on mail actually sent via smtp
|
self.max_sent = max_sent # lifetime cap on mail actually sent via smtp
|
||||||
|
# spec 14 item 10: auto-reply only to authenticated senders
|
||||||
|
self.require_dmarc_pass = require_dmarc_pass
|
||||||
|
|
||||||
# -- event handling -------------------------------------------------------------
|
# -- event handling -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -149,6 +151,8 @@ class TicketService:
|
||||||
bulk = (msg.get("precedence") or "").lower() in ("bulk", "list", "junk")
|
bulk = (msg.get("precedence") or "").lower() in ("bulk", "list", "junk")
|
||||||
if not to or auto or bulk:
|
if not to or auto or bulk:
|
||||||
return False # never auto-reply to automated mail
|
return False # never auto-reply to automated mail
|
||||||
|
if self.require_dmarc_pass and (msg.get("authentication") or {}).get("dmarc") != "pass":
|
||||||
|
return False # unauthenticated sender: keep the ticket, send nothing
|
||||||
if not subject.lower().startswith("re:"):
|
if not subject.lower().startswith("re:"):
|
||||||
subject = "Re: " + subject
|
subject = "Re: " + subject
|
||||||
self.db.execute("INSERT INTO outbox (ticket_id, mail_from, mail_to, subject, body, in_reply_to)"
|
self.db.execute("INSERT INTO outbox (ticket_id, mail_from, mail_to, subject, body, in_reply_to)"
|
||||||
|
|
@ -241,10 +245,12 @@ if __name__ == "__main__":
|
||||||
ap.add_argument("--db", default="tickets.sqlite3")
|
ap.add_argument("--db", default="tickets.sqlite3")
|
||||||
ap.add_argument("--listen", default="127.0.0.1:8025")
|
ap.add_argument("--listen", default="127.0.0.1:8025")
|
||||||
ap.add_argument("--smtp", metavar="HOST:PORT", help="send replies via this SMTP relay")
|
ap.add_argument("--smtp", metavar="HOST:PORT", help="send replies via this SMTP relay")
|
||||||
|
ap.add_argument("--require-dmarc-pass", action="store_true",
|
||||||
|
help="auto-reply only when the event says the sender passed DMARC")
|
||||||
ap.add_argument("--max-sent", type=int, metavar="N",
|
ap.add_argument("--max-sent", type=int, metavar="N",
|
||||||
help="stop sending once N replies have been sent in total (kept in the db)")
|
help="stop sending once N replies have been sent in total (kept in the db)")
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
smtp = (a.smtp.rsplit(":", 1)[0], int(a.smtp.rsplit(":", 1)[1])) if a.smtp else None
|
smtp = (a.smtp.rsplit(":", 1)[0], int(a.smtp.rsplit(":", 1)[1])) if a.smtp else None
|
||||||
h, p = a.listen.rsplit(":", 1)
|
h, p = a.listen.rsplit(":", 1)
|
||||||
print(f"ticket service on http://{a.listen}/mas-events")
|
print(f"ticket service on http://{a.listen}/mas-events")
|
||||||
make_server(TicketService(a.db, smtp=smtp, max_sent=a.max_sent), h, int(p)).serve_forever()
|
make_server(TicketService(a.db, smtp=smtp, max_sent=a.max_sent, require_dmarc_pass=a.require_dmarc_pass), h, int(p)).serve_forever()
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,45 @@ from mas_core.statute import build_statute
|
||||||
EVENT_VERSION = "0.1"
|
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)
|
msg: EmailMessage = BytesParser(policy=policy.default).parsebytes(raw)
|
||||||
body = msg.get_body(preferencelist=("plain",))
|
body = msg.get_body(preferencelist=("plain",))
|
||||||
text = body.get_content() if body is not None else ""
|
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,
|
"date": str(msg.get("Date", "")) or None,
|
||||||
"auto_submitted": str(msg.get("Auto-Submitted", "")) or None,
|
"auto_submitted": str(msg.get("Auto-Submitted", "")) or None,
|
||||||
"precedence": str(msg.get("Precedence", "")) or None,
|
"precedence": str(msg.get("Precedence", "")) or None,
|
||||||
|
"authentication": authentication_from_headers(msg.items(), trusted_authserv_id),
|
||||||
"text": text,
|
"text": text,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Optional
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
from .core import Resolver, gateway, message_meta_from_rfc822
|
from .core import Resolver, gateway, message_meta_from_rfc822
|
||||||
|
|
@ -33,26 +34,30 @@ MAX_BODY = 25 * 1024 * 1024
|
||||||
|
|
||||||
def cmd_pipe(args, resolver) -> int:
|
def cmd_pipe(args, resolver) -> int:
|
||||||
raw = sys.stdin.buffer.read()
|
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:
|
for d in deliveries:
|
||||||
print(json.dumps(d.__dict__), file=sys.stderr)
|
print(json.dumps(d.__dict__), file=sys.stderr)
|
||||||
return EX_TEMPFAIL if any(d.outcome == "tempfail" for d in deliveries) else 0
|
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"):
|
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())
|
data = json.loads(body.decode())
|
||||||
recipients = data.pop("recipients", None) or [data.pop("recipient")]
|
recipients = data.pop("recipients", None) or [data.pop("recipient")]
|
||||||
meta = {"from": data.get("from"), "to": data.get("to", recipients),
|
meta = {"from": data.get("from"), "to": data.get("to", recipients),
|
||||||
"subject": data.get("subject", ""), "message_id": data.get("message_id"),
|
"subject": data.get("subject", ""), "message_id": data.get("message_id"),
|
||||||
"in_reply_to": data.get("in_reply_to"), "references": data.get("references"),
|
"in_reply_to": data.get("in_reply_to"), "references": data.get("references"),
|
||||||
"date": data.get("date"), "auto_submitted": data.get("auto_submitted"),
|
"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
|
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)
|
host, port = listen.rsplit(":", 1)
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
@ -63,7 +68,8 @@ def make_webhook_server(listen: str, app_url: str, resolver: Resolver) -> Thread
|
||||||
body = self.rfile.read(length)
|
body = self.rfile.read(length)
|
||||||
try:
|
try:
|
||||||
recipients, meta = webhook_message(body, self.headers.get("Content-Type", ""),
|
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:
|
except (ValueError, KeyError) as e:
|
||||||
return self._reply(400, {"error": f"bad inbound payload: {e}"})
|
return self._reply(400, {"error": f"bad inbound payload: {e}"})
|
||||||
if not recipients:
|
if not recipients:
|
||||||
|
|
@ -92,6 +98,9 @@ def main(argv=None) -> int:
|
||||||
ap.add_argument("--statute", action="append", metavar="FILE",
|
ap.add_argument("--statute", action="append", metavar="FILE",
|
||||||
help="pin a statute from local configuration (keyed by its namespace)")
|
help="pin a statute from local configuration (keyed by its namespace)")
|
||||||
ap.add_argument("--registry", metavar="FILE", help="local registry JSON")
|
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)
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
p = sub.add_parser("pipe")
|
p = sub.add_parser("pipe")
|
||||||
p.add_argument("--recipient", action="append", required=True)
|
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)
|
resolver = Resolver.with_statute_files(args.statute, local_registry=registry)
|
||||||
if args.cmd == "pipe":
|
if args.cmd == "pipe":
|
||||||
return cmd_pipe(args, resolver)
|
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)
|
print(f"mas-gateway webhook on http://{args.listen} -> {args.app_url}", file=sys.stderr)
|
||||||
srv.serve_forever()
|
srv.serve_forever()
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -203,3 +203,47 @@ def test_gateway_is_resource_type_agnostic():
|
||||||
("resource", "invoice", "RE=2026-4711", "dispute")
|
("resource", "invoice", "RE=2026-4711", "dispute")
|
||||||
finally:
|
finally:
|
||||||
srv.shutdown()
|
srv.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
# -- sender authentication (spec 14 item 10) ----------------------------------------
|
||||||
|
|
||||||
|
AR_OURS = "Authentication-Results: great-service.example; dkim=pass header.d=example.net; " \
|
||||||
|
"spf=pass smtp.mailfrom=example.net; dmarc=pass header.from=example.net\r\n"
|
||||||
|
RECEIVED = "Received: from mx.example.net by great-service.example; Sun, 18 Oct 2026 20:00:00 +0000\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _meta(headers):
|
||||||
|
return message_meta_from_rfc822(raw(f"ticket!callback@{D}", "hi", extra=headers),
|
||||||
|
trusted_authserv_id=D)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_read_from_our_own_header():
|
||||||
|
a = _meta(AR_OURS + RECEIVED)["authentication"]
|
||||||
|
assert a == {"dkim": "pass", "spf": "pass", "dmarc": "pass", "header_from": "example.net"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_forged_or_foreign_authentication_is_ignored():
|
||||||
|
forged = RECEIVED + AR_OURS # below our Received: brought by the sender
|
||||||
|
foreign = AR_OURS.replace("great-service.example;", "other.example;") + RECEIVED
|
||||||
|
assert _meta(forged)["authentication"] is None
|
||||||
|
assert _meta(foreign)["authentication"] is None
|
||||||
|
assert message_meta_from_rfc822(raw("x@y", "s", extra=AR_OURS))["authentication"] is None # no trusted id
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_only_to_authenticated_sender(env):
|
||||||
|
svc, send, *_ = env
|
||||||
|
svc.require_dmarc_pass = True
|
||||||
|
sent = []
|
||||||
|
svc.smtp, svc._send = ("127.0.0.1", 25), lambda *a: sent.append(a)
|
||||||
|
send(f"ticket!callback@{D}", "spoofed") # no verdict
|
||||||
|
assert sent == [] and svc.outbox() == [] and svc.ticket("GS-41872") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_sender_gets_reply(env):
|
||||||
|
svc, _, _, resolver = env
|
||||||
|
svc.require_dmarc_pass = True
|
||||||
|
sent = []
|
||||||
|
svc.smtp, svc._send = ("127.0.0.1", 25), lambda *a: sent.append(a)
|
||||||
|
event = build_event(f"ticket!callback@{D}", _meta(AR_OURS + RECEIVED), resolver)
|
||||||
|
status, body = svc.handle(event)
|
||||||
|
assert status == 201 and len(sent) == 1 and sent[0][1] == CUSTOMER
|
||||||
|
|
|
||||||
|
|
@ -393,7 +393,7 @@ never reject on DMARC failure. Milter failure means accept
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: MAS-WP-0005-T12
|
id: MAS-WP-0005-T12
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -404,6 +404,23 @@ header_from}` into the event. The ticket service gets
|
||||||
sends auto-replies (intake confirmation, closed notice) only when
|
sends auto-replies (intake confirmation, closed notice) only when
|
||||||
`dmarc == pass`. Unit tests cover forged A-R headers from other ids.
|
`dmarc == pass`. Unit tests cover forged A-R headers from other ids.
|
||||||
|
|
||||||
|
- 2026-10-18 done:
|
||||||
|
- `mas_gateway.core.authentication_from_headers` reads only A-R headers
|
||||||
|
with the trusted authserv-id **above the first Received header**
|
||||||
|
(RFC 8601 §5). Forged copies below our hop are ignored even if the
|
||||||
|
MTA did not strip them. Several DKIM results count as pass if any one
|
||||||
|
passes.
|
||||||
|
- The event gets `message.authentication = {dmarc, spf, dkim,
|
||||||
|
header_from}` or null. `--trusted-authserv-id` works on both pipe and
|
||||||
|
webhook; a JSON webhook passes the provider's verdict through.
|
||||||
|
- Ticket service `--require-dmarc-pass`: tickets are still created and
|
||||||
|
mail attached, but replies go out only on `dmarc == pass`.
|
||||||
|
- Entrypoint: gateway `--trusted-authserv-id $DOMAIN`, ticket service
|
||||||
|
`--require-dmarc-pass`.
|
||||||
|
- Tests: own header read; forged (below Received) and foreign ids
|
||||||
|
ignored; no trusted id gives no verdict; reply gated off without a
|
||||||
|
verdict and sent on pass. 183 tests pass.
|
||||||
|
|
||||||
## Live check: authenticated vs forged sender
|
## Live check: authenticated vs forged sender
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue