143 lines
8.9 KiB
Markdown
143 lines
8.9 KiB
Markdown
# 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` — 2–100 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` — 10–5,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 **petrbalvin@yandex.com** with a description and a reproducer. Please do not file a public issue for security-sensitive reports.
|