v1.1.0 — Security hardening & operational maturity
A security-hardening and operational-maturity release, from a full audit of the shipped v1.0. One breaking change (below) that closes an open-relay footgun; everything else is additive or a fix.
Breaking
auth_required = "none"on a public bind is now refused at config-parse time (#41). An SMTP listener withauth_required = "none"whoselistenaddress is not verifiably loopback/private (e.g.:2525,0.0.0.0:2525, a public IP, or an unresolvable hostname) is a startup error. Settrusted_network = trueto assert the listener is reachable only from a trusted network — a Docker bridge with noports:exposure, a VPN, or a firewall. Migration: deployments built from the pre-existing Ghost / Gitea / internal-relay recipes usedauth_required = "none"withlisten = ":2525"and must addtrusted_network = true(the updated recipes include it). This closes the open-relay footgun where the sender allowlist was the only gate on an unauthenticated public listener.
Added
- SMTP listener brute-force and resource hardening (#50). Per-remote-IP AUTH-failure budget (10 failures/minute; further attempts get
421and the connection closes; successful auths never consume budget). Global (max_connections, default 100) and per-IP (max_connections_per_ip, default 16) concurrent-connection caps; over-cap connections get a421before any protocol work. - CSRF rejections now increment
posthorn_spam_blocked_total{kind="csrf"}— previously they were log-only and invisible on/metrics. spam_blocked(honeypot and origin) andcsrf_rejectedlog lines now carryclient_ipfor operator forensics, matchingrate_limited; omitted whenstrip_client_ip = true(FR59).
Fixed
- An
[smtp_listener]-only config (no[[endpoints]]) is now accepted — the config validator required at least one HTTP endpoint, breaking the documented Ghost and Gitea recipes and every SMTP-listener-only deployment.posthorn validateoutput now names the listener so a listener-only config doesn't report a bare "0 endpoint(s)". (#37) - SMTP-listener sends now honor the FR19-22 retry policy — a transient provider error is retried once (as the HTTP ingress already did) instead of returning an immediate
451. Previously a provider blip could drop a transactional email on the SMTP path that the HTTP path would have retried. The retry policy is now shared between both ingresses. (#59) - The HTTP server now sets
ReadTimeout(15s) andWriteTimeout(30s, chosen to exceedReadTimeout+ the handler's 10s request bound so a legitimate slow upload followed by a provider retry isn't truncated after the mail was sent) plus an explicit 64 KBMaxHeaderBytes, so slow-body senders and slow-reading clients can't hold connections indefinitely. (#42) - Raw stdlib parse errors are no longer echoed to clients on malformed bodies; both parse paths return a generic 400 and log the detail server-side as
body_parse_failed. Deliberate API-shape messages (e.g. "nested objects are not supported") remain client-visible. (#42)
Security & supply chain
- CI now runs a
gofmtgate,govulncheck, andgolangci-linton every push and pull request. (#38) - Docker base images are pinned by digest, and Dependabot keeps the base images, Go modules, and GitHub Actions current. (#39)
- Release images now ship SLSA provenance and SBOM attestations, are signed with cosign (keyless via GitHub OIDC), and are gated on a Trivy vulnerability scan. (#56)
Development
- New hermetic provider-testing harness (
core/providertest): ingress→egress end-to-end tests for all five transports and the SMTP listener, run on every PR with no credentials. Amake test-liverunner and a scheduled, protected workflow validate against real provider APIs (against non-delivering targets; SES via OIDC, no stored key). See #76. Posthorn deliberately does not test deliverability/DKIM — the provider owns signing and reputation.
Image: ghcr.io/craigmccaskill/posthorn:1.1.0 (multi-arch, cosign-signed, SBOM + provenance attested). Validated via v1.1.0-rc.1 before GA.