Files
nuntius/docs/security.md
T

143 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) |
| 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`:
```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)
Behind nginx, set `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` — otherwise every request looks like it came from `127.0.0.1` and the bucket becomes a single global bucket.
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`.
## 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.sh` 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 is meant to run behind nginx (or another reverse proxy) that terminates TLS. Exposing the binary directly on `:8080` 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.