# Security

Checked against the code on 2026-10-31 (version 0.1.0).

This page describes what the code does today. It is not a certification; there has been no external audit yet.

## Report a problem

Email **security@seedrophost.com** with steps to reproduce. Please do not open a public issue for a vulnerability. Machine-readable contact: [/.well-known/security.txt](/.well-known/security.txt). Abuse of a published site (phishing, malware) goes to the same address; the operator can take a site down at once with `DELETE /v1/sites/{site}`.

## Secrets

- Site variables are encrypted at rest with **AES-256-GCM** under the server's master key (`SDH_MASTER_KEY`).
- Secret values are **always masked** in API, CLI, MCP and dashboard answers (for example `s3c••••••ue`) and are not written to logs.
- Outside development mode the server refuses to start without its own `SDH_TOKEN` and `SDH_MASTER_KEY`. The self-host installer generates both at random and hands them to the service as systemd credentials (`LoadCredential=`), outside the process environment.
- The server token (`SDH_TOKEN`) is compared in constant time. Account tokens are described under "Accounts and isolation".

## Who did what

Every write (site, deploy, publish, variable, domain, drop, claim, site deletion) is recorded in an audit log with the actor — `cli`, `mcp:claude`, `mcp-remoto`, `dashboard`, or whatever the client sends in `x-sdh-actor` — the action, the target and the time. Read it with `GET /v1/audit`. Each entry also records the account. The actor name is declared by the client, not proven; with labelled tokens it tells machines and agents apart, not people. An account token reads only the entries of its own account; the administrator reads all of them.

## Accounts and isolation

- **Tokens.** Each account has its own tokens, with the prefix `sdh_` (easy for secret scanners to recognise). The secret is shown **once**, when the token is created; the server stores only its SHA-256. An account has at most 20 active tokens, and revoking one takes effect on the next request. Revoked tokens stay in the list for 90 days and then disappear. The server token (`SDH_TOKEN`) is the administrator: the only one that creates accounts and changes plans.
- **One filter for every route.** A route that takes a site or a deploy goes through the account filter before its handler runs, so a new route is protected from the start. A site, deploy, domain request or token of another account answers `404`, the same as one that does not exist, because a `403` would confirm that it exists. An automated test walks the whole route table to check that none escapes the filter.
- **Files.** Files are stored once on disk by their SHA-256, but each account has to send the bytes at least once: knowing the hash of another account's file is not enough to use it.
- **Names.** A site name that starts with `site-`, `dpl-`, `acc-` or `tok-` is refused, because it looks like an internal id.
- **Usage metering.** Bandwidth is summed per site and month in memory and written once a minute, with no IP address and no path. Going over a plan limit only produces a warning: no site goes offline and no lead is lost. The one hard refusal is creating a site beyond the plan (`402`), which touches nothing that is already live.
- **Not people.** An account is a group of tokens with labels, not of users: there are no identities or roles inside an account yet.

## Previews are public by link

Every deploy, drafts included, gets an address `<deploy>--<site>.<base>`. It asks for no login: anyone with the link sees the content, as with the deploy previews of other services. The deploy id is random, so it cannot be guessed, and the preview is served with `X-Robots-Tag: noindex`, so search engines do not list it. A link that someone forwards still opens. Do not publish confidential material (a proposal, something before its launch), not even as a draft.

## AI agents

- Deploys made through MCP always start as **drafts**; drafts are served with `noindex`.
- Tools that change what is live or replace or delete a secret (`publish_deploy`, `set_env_var`, `delete_env_var`) are marked `destructiveHint: true`, so MCP clients ask a person first.
- The local MCP reads only folders listed in `SDH_MCP_ROOTS` (real paths are resolved, so symlinks cannot escape). The remote MCP cannot read the server's disk at all.

## Dashboard and sites kept apart

- With `SDH_PANEL_HOST` set, the dashboard, API and remote MCP answer only on that host (or to calls from the machine itself), and no site is ever served on it.
- With `SDH_DROP_DOMAIN` set, anonymous drops live on a separate domain from your sites.
- Dashboard answers carry `frame-ancestors 'none'`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` and `nosniff`, so the dashboard cannot be framed by another page.
- `Set-Cookie` is dropped from every site's `_headers` and `[[headers]]`, because all sites share the base domain.
- `_redirects`, `_headers`, `netlify.toml`, `seedrop.toml` and function code are never served as files.

## Server-side request forgery (SSRF)

One shared check refuses addresses outside the public internet: private, loopback, link-local (including cloud metadata at `169.254.169.254`), carrier-grade NAT, multicast and reserved IPv4 ranges; IPv6 loopback, unique-local, link-local and multicast; and IPv4 hidden inside IPv6 (`::ffff:…`, NAT64 `64:ff9b::/96`, 6to4 `2002::/16`).

- **Proxy rules** check the literal host first, then check again at connection time, so DNS cannot switch to a private address afterwards. No redirects are followed; 10-second timeout; 10 MB answer limit read as a stream.
- **The migration check** run through the remote MCP uses the same check.
- The internal route Caddy uses before issuing certificates answers only calls from the machine itself without proxy headers; the shipped Caddyfile hides it from the internet.

## Domains and HTTPS

- A custom domain is served **only after its TXT record is verified**, and a domain verified by one site cannot be taken by another.
- In the self-host setup, the base domain, every site, every preview and the drop domain use **one wildcard certificate** installed by the operator. Caddy issues on-demand certificates only for the dashboard host and verified custom domains; random names pointed at the server get nothing, which protects the Let's Encrypt quota.

## Abuse of anonymous publishing

Publishing without an account is **off by default**. When the operator turns it on:

- 25 MiB per request; zip limited to 50 MiB uncompressed and 25 MiB per file.
- 5 per network per hour (an IPv4 address, or a whole IPv6 `/64`), plus a server-wide cap per hour (`SDH_ANON_DROP_MAX_HOUR`, default 60).
- At most 2 processed at the same time, and a total storage quota (`SDH_ANON_DROP_MAX_MB`, default 2048).
- Sites expire after 24 hours unless claimed, are always `noindex`, cannot receive form submissions, cannot redirect or proxy to other sites, never run functions, and cannot set CSP, `Service-Worker-Allowed` or CORS headers.
- The claim code is shown once and stored only as a hash.

A phishing page can stay up until it expires or is deleted; see "Not in place yet".

## Functions

Off by default (`SDH_FUNCTIONS=1` turns them on). Even when on, they run only for sites of the main (`principal`) account: sites of customer accounts answer `404` for function routes until functions are isolated. A function runs in a worker of the same process with a 10-second and 128 MB limit and sees only its site's variables, but this is **not isolation**: a function can read the database, every site's files and the server credentials. Turn functions on only if you own every site on the server.

## Uploads

Zip files are read with strict limits: entry count, total and per-file size, compression ratio (zip bombs), CRC32 check, no absolute paths, no `..`, no backslashes, no password-protected entries. Deploy paths are validated the same way.

## Forms and privacy

- Visitor IP addresses are not stored with submissions: only a keyed hash (HMAC-SHA256 with a key derived from the master key), so the IP cannot be recovered by trying every address.
- Form `action` values that point to another host (`//…`) are refused.
- The optional access log (`SDH_ACCESS_LOG=1`) prints a per-process alias instead of the IP, and no query string.
- Spam receives the same answer as a real submission.

## Hardening of the self-host install

systemd service with `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome` and kernel protections; the app listens on `127.0.0.1` behind Caddy; firewall open only on 22, 80 and 443; updates run the tests as the service user, not root; daily backups kept 14 days.

## Not in place yet

- External security audit.
- People and roles inside an account (today: several labelled tokens, not users).
- Isolated functions per account (today only the main account runs functions, and only on a server with a single owner).
- OAuth for the remote MCP: **In progress** (a Bearer token today).
- Automated abuse scanning of published content.
- Rate limits are kept in memory, so they reset when the server restarts.
