Skip to content

Security model

What SESKit protects, how, and where the deliberate holes are.

AWS credentials

SESKit stores an AWS access key per project. This changed in Phase 14; before it, credentials came from the environment the process ran in and SESKit held nothing. It is worth being exact about what was traded for what.

Why it changed. Reading credentials from the environment meant that connecting AWS required shell access to the server: SSH in, install a CLI or edit a file, restart. That is a wall in front of the first thing anyone wants to do, and it is not a wall most people can climb on a managed host. It also meant every project on an instance necessarily resolved the same AWS account, because there was only one environment.

What is stored. The access key id in plain text — it is an identifier, and AWS shows them in full in its own console. The secret access key encrypted with Fernet, under a key derived from SECRET_KEY by HKDF with a label that separates it from every other use of that secret (sessions, CSRF, webhook signatures, unsubscribe links).

What the encryption protects. The copy that leaves the building: a backup, a snapshot, a replica with looser access, a pg_dump pasted into a support thread. In every one of those the ciphertext is useless without SECRET_KEY, which lives in the environment and not in the database.

What it does not protect. A compromised host. The process must be able to read SECRET_KEY in order to send mail at all, so anything that can read the application's environment can decrypt every stored credential. Encryption at rest is not a defence against an attacker already inside; it is a defence against the copy that walks out. A reader who believes otherwise will make worse decisions than one told plainly.

What follows for you. Give the IAM user only the actions SESKit needs — never AdministratorAccess. Treat a database backup as containing live AWS credentials, because it does. And know that rotating SECRET_KEY makes every stored key unreadable, so each project must be connected again.

The key is never returned by any route, never rendered into a page, never echoed back into a form after a failure, and never present in a log line or a repr. Each of those is a test rather than an intention.

API keys are hashed; webhook secrets are not

Stored as Why
API key SHA-256 hash SESKit only ever needs to recognise one, never reproduce it
Webhook signing secret Plaintext Your receiver has to read it back to verify signatures

The asymmetry is deliberate and worth understanding, because it makes a database backup a secret-bearing artifact. See backup and restore.

There is no salted KDF on API keys, and that is also deliberate. A KDF makes low-entropy secrets expensive to guess; a key is 256 random bits, so there is no guessing to frustrate. Adding one would put latency on every authenticated request and buy nothing.

A key is shown once at creation. A missing header and an invalid token get the same 401 and the same message — distinguishing them would tell someone probing which half they had right.

Two untrusted boundaries

Both directions of the event pipeline face something SESKit does not control.

Inbound: SNS notifications

The HTTPS receiver is unauthenticated by necessity — SNS has no credential to present. So every request is verified against the RSA signature SNS signed it with, using a certificate fetched only from sns.<region>.amazonaws.com and only after that host is validated against a pattern anchored at both ends.

Checking the topic ARN instead would not work. It is a field in the request body and topic ARNs are not secrets, so anyone who learns one could fabricate bounce and complaint events.

Checking the signature alone does not work either, and this page said it did until a review found otherwise. SNS signing keys are per-region and shared by every AWS customer: anyone with an account can have SNS sign whatever they publish to a topic of their own, and the signed fields do not include the endpoint the message went to. A valid signature proves Amazon SNS emitted the message, not that your topic did. So the receiver checks both. The signature says the bytes are genuine; the region and account in the signed TopicArn must then match an AWS account a project on this instance has connected, or the message is refused with a 403 — before a subscription confirmation is answered, so a stranger cannot subscribe your endpoint to their topic, and before any event is recorded.

An event that passes both is still only allowed to attach to a message sent by a project on that account, and a bounce may only suppress addresses the message actually went to. The SES message id is written into the headers of every delivered mail, so every recipient holds it; those two limits are what keep it from being a key.

Notifications are deduplicated on the SNS message id with a unique constraint, because SNS and SQS are both explicitly at-least-once and a double-counted bounce inflates the rate AWS judges your account by.

Outbound: webhook destinations

A user-supplied URL that SESKit will make requests to is a server-side request forgery primitive, and because response bodies are captured into the delivery log, it composes into a read primitive against your internal network.

So:

  • Loopback, private and link-local ranges are refused outside local development.
  • The check runs against the resolved address, not the string — otherwise DNS rebinding walks straight past it.
  • It runs again at every delivery, not only at registration, because DNS can change afterwards.
  • Redirects are never followed. A redirect would forward your signed payload to a host you never registered.
  • Response capture is bounded to text-ish content types and a few kilobytes, so a hostile endpoint cannot stream gigabytes into your database.

WEBHOOK_ALLOWED_CIDRS is the deliberate hole. Only list ranges you genuinely need, and remember that responses from them are recorded.

Outbound signatures

SESKit signs what it sends you: HMAC-SHA256 over "{timestamp}.{body}", sent as v1={hex} with the timestamp in its own header.

The timestamp is inside the signed string, which is what makes a replay detectable — signing the body alone would let a captured request be replayed for ever with a fresh timestamp. The v1= prefix allows the scheme to change later without a flag day.

How to verify it.

The dashboard

  • Sessions are signed with SECRET_KEY, which refuses to boot on the example value.
  • CSRF tokens on every state-changing form. Logging out is a form, not a link, so a prefetch cannot trigger it.
  • Message bodies are rendered as source, never as HTML. The body is chosen by whoever holds an API key; rendering it in the account owner's authenticated session would be stored XSS with a session cookie attached.
  • Blind copies are stored but never displayed beside other recipients. A blind copy that shows up in the interface is not blind.
  • Pages carry Cache-Control: no-store when signed in, so the back button after a logout cannot re-display the previous user's dashboard from the browser cache.

Response headers

Set by one middleware on every response, because the failure mode of per-route security is a route somebody forgot.

Header
Content-Security-Policy default-src 'self', with a per-request nonce for the two inline theme scripts. No unsafe-inline, no unsafe-eval
X-Frame-Options / frame-ancestors DENY / 'none'. Every destructive action on the dashboard is a one-click form, which is what clickjacking needs
form-action 'self' The directive framing protection has no answer for: injected markup posting a CSRF token to another origin
X-Content-Type-Options nosniff
Referrer-Policy same-origin, so an unsubscribe token or an email id in a path does not leave with the Referer
Strict-Transport-Security Outside local development only

A strict policy is affordable because of a decision made in Phase 1. §5 forbids Node, npm and a build step, so every script and stylesheet is a local file: no CDN, no analytics, no embedded fonts, no data: URIs. default-src 'self' costs a codebase built that way nothing, and would be expensive to adopt later.

The autoescaping above is the defence against injected markup. The policy is the layer that holds on the day an escape is missed, and a test walks the template tree and fails on any inline script without a nonce — a missing nonce does not raise, it just silently refuses to run.

HSTS is not set locally on purpose. A browser that has seen it refuses every other project served from http://localhost, and the only cure is clearing HSTS state by hand.

Request size

MAX_REQUEST_BYTES caps a request body before anything reads it. A declared Content-Length is refused without reading a byte; a chunked body is counted as it arrives, because Content-Length is a header the client controls and refusing on it alone is a courtesy rather than a defence.

Distinct from EMAIL_MAX_MESSAGE_BYTES, which is checked against the assembled message after the body has been read, parsed and base64-decoded into memory. The first protects the server; the second protects the send.

Tenancy

Ownership is part of every query rather than a check after it. An id belonging to another project resolves to nothing — the same answer as "no such record" — so a stranger cannot probe for real ids by watching which ones return a different error.

Logging

Structured logs record ids: which key, which email, which endpoint. They do not record message bodies, recipients or secrets. A repr of an email deliberately omits its subject and addresses, because reprs end up in logs.

What is not covered yet

Stated plainly rather than left to be discovered:

  • No HTTP API for the suppression list. Addresses are suppressed automatically and managed on the dashboard; there is no /v1 endpoint to read or bulk-load the list. See suppression.
  • No RBAC. An account owns its projects; there are no roles or team members.
  • No per-IP rate limit. Limits are per project for the API and per account for sign-in; an unauthenticated flood is a job for whatever sits in front of this.
  • Suppression removal is not audited. The row records who suppressed an address and when, but not who took it off the list.
  • Migrations are not audited for backward compatibility, so rolling upgrades are not a supported story. See upgrading.
  • SNS message timestamps are not checked for freshness. A genuine notification from your own topic could be replayed later; it deduplicates on the SNS message id, so a replay records nothing new. Harmless today, and noted so nobody assumes the check exists.
  • The HTTPS receiver has no shared secret in its URL and no per-IP limit. A stranger who finds it can make it do signature checks. The topic check above means that is all they can make it do.

Security issues should go to SECURITY.md rather than a public issue.