9.1 KiB
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
psaccess to the nuntius PID can read it from/proc/<pid>/environ. UsesystemdEnvironmentFile=with mode0600to keep the file readable only byroot:nuntius. - An attacker who can MITM the SMTP connection. Use a provider that supports STARTTLS on port
587(Go'snet/smtp.SendMaildoes STARTTLS automatically when the server advertises it) or implicit TLS on port465via a small wrapper.
Input Validation
All four form types are validated by pkg/contactform.Validate, which enforces:
name— 2–100 runes afterstrings.TrimSpaceemail— RFC 5322 vianet/mail.ParseAddressafterstrings.TrimSpaceservice(contact only) — restricted to a fixed allow-list:architecture,ai,infrastructure,software,unix,other, or emptymessage— 10–5,000 runes afterstrings.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:
- Logs a
honeypot triggeredline atINFOwithform,path, andip. - Responds
200 {"ok": true}— the same as a legitimate submission. - Does not send mail.
- 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
Originheader is allowed (treats the client ascurl/ non-browser). This makes debugging easy without weakening anything in production. - A present
Originis checked against the allowlist. If not present, the request is rejected with403 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:
X-Forwarded-For(first hop, before any comma)X-Real-IPRemoteAddr(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
Authorizationheader, or the raw SMTPFrom/Toof any single message (only the form-leveltofrom the config). - It does not write the password to disk.
/etc/nuntius/.envis 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.Infocarries onlymethod,path,status,duration_ms, andip.
Things you should do:
- Make sure
/etc/nuntius/.envis0600and ownedroot:nuntius(theinstall.pyscript does this for you). - Use an app-specific password or OAuth token if your provider supports it. nuntius uses
PLAINauth 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 nuntiusif 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/DELETEpath. - Crash-safe —
os.OpenFile(path, O_APPEND | O_CREATE | O_WRONLY, 0644)plus a singleWrite(line). A partial write leaves a malformed trailing line;CountandListskip malformed lines, so the rest of the log is still readable. - Bounded — there is no upper bound; add a
logrotateunit 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.SendMailopportunistically upgrades to TLS via STARTTLS when the server advertises it; if you need implicit TLS on:465, wrapSendMailin 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.