Files

9.1 KiB
Raw Permalink Blame History

Security

nuntius is a public-facing HTTP service that accepts untrusted input and turns it into outbound email and on-disk records. This document covers the threats it faces, the defenses built in, and the things you, the operator, are responsible for.

Threat Model

nuntius is designed to defend against:

Threat Mitigation
Naive spam bots filling the form Per-form honeypot field that silently accepts and drops
Casual brute-force / abuse Per-form, per-IP token-bucket rate limit (in-memory, with periodic cleanup goroutine)
Cross-origin abuse from a third-party site Per-form CORS allowlist enforced on preflight and actual requests
Malformed bodies Strict JSON parsing and per-type field validation
SMTP credential leakage in logs log/slog never logs the From / Authorization / password; the password only lives in FormSender
Process crash on disk full Append-only JSONL opens with O_APPEND; partial writes cannot corrupt earlier lines
Partial write bricking the subscriber log Count and List skip malformed lines
Config typos silently disabling a form interpres.NewDecoder(...).DisallowUnknownFields() + per-type allow-list at startup
Long-running abusive connections ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout on the server
Process hanging on shutdown 15 s graceful shutdown via signal.NotifyContext + srv.Shutdown

nuntius is not designed to defend against:

  • A determined attacker who can submit a valid request from a rotating pool of IPs and bypass the per-IP rate limit. nuntius has no CAPTCHA, no proof-of-work, no IP reputation service, and no global rate limit. If you need those, put a reverse proxy with Cloudflare Turnstile / hCaptcha / reCAPTCHA in front of it.
  • A compromised frontend that posts valid CORS-allowlisted traffic at high rate from a single IP. The token bucket handles that case.
  • An attacker who has root on the server. nuntius reads the SMTP password from the process environment; anyone with ps access to the nuntius PID can read it from /proc/<pid>/environ. Use systemd EnvironmentFile= with mode 0600 to keep the file readable only by root:nuntius.
  • An attacker who can MITM the SMTP connection. Use a provider that supports STARTTLS on port 587 (Go's net/smtp.SendMail does STARTTLS automatically when the server advertises it) or implicit TLS on port 465 via a small wrapper.

Input Validation

All four form types are validated by pkg/contactform.Validate, which enforces:

  • name — 2100 runes after strings.TrimSpace
  • email — RFC 5322 via net/mail.ParseAddress after strings.TrimSpace
  • service (contact only) — restricted to a fixed allow-list: architecture, ai, infrastructure, software, unix, other, or empty
  • message — 105,000 runes after strings.TrimSpace

Validation is server-side and runs on every request, regardless of what the client claims it has already checked. Field-level errors are returned as details[] so the UI can highlight individual inputs.

Honeypot

Each form has a honeypot_field (default "website") that should be hidden from humans via CSS (display: none, off-screen positioning, tabindex="-1", autocomplete="off"). The form's HTML has a real-looking input for that name; humans never fill it; bots auto-fill every input they see.

If the field is non-empty, nuntius:

  1. Logs a honeypot triggered line at INFO with form, path, and ip.
  2. Responds 200 {"ok": true} — the same as a legitimate submission.
  3. Does not send mail.
  4. Does not persist the subscriber.

This makes the bot think it succeeded. There is no way for an external observer to tell a honeypot-hit response from a real success without the server log.

To disable the honeypot on a specific form, set honeypot_field: "". The form's HTML must then omit the hidden input.

CORS

Per-form allowlist, configured in config.toml:

[[forms]]
name = "contact"
allowed_origins = ["https://petrbalvin.org", "https://www.petrbalvin.org"]

Rules:

  • An absent Origin header is allowed (treats the client as curl / non-browser). This makes debugging easy without weakening anything in production.
  • A present Origin is checked against the allowlist. If not present, the request is rejected with 403 origin_not_allowed.
  • Wildcards are not supported. List every origin explicitly.
  • The preflight (OPTIONS) and the actual request go through the same allowlist check.

The response sets Access-Control-Allow-Origin to the echoed origin (not *) and includes Vary: Origin so downstream caches do not mix origins.

Rate Limiting

Each form has its own per-IP token bucket. The bucket size is rate_limit_per_hour and the refill rate is perHour / 3600 tokens per second. The bucket map is guarded by a sync.Mutex and is in-process.

The IP used is taken from:

  1. X-Forwarded-For (first hop, before any comma)
  2. X-Real-IP
  3. RemoteAddr (with port stripped)

Caddy forwards X-Forwarded-For automatically — so the real client IP is always passed to nuntius without any extra configuration.

To disable rate limiting on a specific form, set rate_limit_per_hour: 0. There is no global safety net.

State is per-process and resets on restart. If you scale nuntius horizontally, each replica has its own bucket; the effective limit is replicas × per_form_limit.

A background goroutine (startCleanup) ticks once per hour and removes IP entries that are older than twice the refill window. This prevents the in-memory bucket map from growing unboundedly as one-off IPs accumulate over weeks of uptime.

Secret Handling

SMTP credentials live in /etc/nuntius/.env (mode 0600, owner root:nuntius). The config file references them as ${NUNTIUS_SMTP_PASSWORD}; os.Expand substitutes them before the TOML is parsed, so the parsed config.Form holds the expanded value in memory only for the lifetime of the process.

Things nuntius does not do:

  • It does not log the password, the Authorization header, or the raw SMTP From / To of any single message (only the form-level to from the config).
  • It does not write the password to disk. /etc/nuntius/.env is the only place it is persisted.
  • It does not echo the password in any error response.
  • It does not log the full request body. slog.Info carries only method, path, status, duration_ms, and ip.

Things you should do:

  • Make sure /etc/nuntius/.env is 0600 and owned root:nuntius (the install.py script does this for you).
  • Use an app-specific password or OAuth token if your provider supports it. nuntius uses PLAIN auth because that is what every SMTP provider offers; the credential is the secret, not the auth mechanism.
  • Rotate the password on the same cadence as any other production secret.
  • Restrict read access to journalctl -u nuntius if you do not want the client IPs in your local log files.

Disk Storage

The newsletter JSONL log is:

  • Append-only — nuntius only ever writes new lines and reads them back. There is no UPDATE / DELETE path.
  • Crash-safe — os.OpenFile(path, O_APPEND | O_CREATE | O_WRONLY, 0644) plus a single Write(line). A partial write leaves a malformed trailing line; Count and List skip malformed lines, so the rest of the log is still readable.
  • Bounded — there is no upper bound; add a logrotate unit if it grows.

The systemd unit runs nuntius as user nuntius and points WorkingDirectory at /var/lib/nuntius. The data dir inherits that ownership.

Timeouts

The http.Server is configured with:

Timeout Value Purpose
ReadHeaderTimeout 10 s Limit Slowloris-style header reads
ReadTimeout 15 s Total request body read
WriteTimeout 30 s Total response write
IdleTimeout 60 s Keep-alive idle

The graceful shutdown budget is 15 s (srv.Shutdown with context.WithTimeout).

What nuntius Does Not (Yet) Do

  • No CAPTCHA / proof-of-work. If you need a stronger abuse signal, add a reverse proxy with a CAPTCHA in front of nuntius; the same CORS rules apply.
  • No request signing / HMAC. A frontend that has been compromised can post at the configured rate from a single IP. The token bucket handles the volume; the origin allowlist limits the surface.
  • No per-IP persistence across restarts. Restart resets the buckets. That is an acceptable trade-off for a small backend and is documented in the rate-limit section above.
  • No TLS for the nuntius listener itself. nuntius binds [::]:8080 (the dual-stack IPv6 wildcard) and is meant to run behind Caddy (or another reverse proxy) that terminates TLS. Exposing the binary directly is a development convenience, not a production pattern.
  • No DKIM signing. Outbound mail is signed by your SMTP provider, not by nuntius.
  • No TLS for the SMTP connection itself is configured by nuntius. Go's net/smtp.SendMail opportunistically upgrades to TLS via STARTTLS when the server advertises it; if you need implicit TLS on :465, wrap SendMail in a custom dialer.

Reporting a Vulnerability

Email opensource@petrbalvin.org with a description and a reproducer. Please do not file a public issue for security-sensitive reports.