M1-21: Transport protection¶
Status: ready | Depends on: M1-18 | Milestone: M1
Goal¶
After this ticket the public listener of bothy serve accepts plaintext HTTP only from loopback
peers and from peers listed in server.trusted_proxies, and closes every other plaintext
connection at accept time, before a byte is read. An instance operator who configures a
certificate gets server-side TLS with TLS 1.2 as the floor and the Go standard cipher selection.
Every response of both listeners carries the headers of SEC-HDR-1 and SEC-HDR-2, including HSTS
with http.hsts_max_age whenever the instance is served over HTTPS. One list,
server.trusted_proxies, then decides both who may send plaintext and whose forwarded headers
count; this closes the server side of threat T-NW-02.
Read first¶
docs/spec/19-security.md: "2. Transport" (SEC-TLS-1 to SEC-TLS-3), "3. HTTP security headers" (SEC-HDR-1, SEC-HDR-2), "9. Logs without secrets and without content" (SEC-LOG-5), "14. Tests" (SEC-T01, SEC-T03, SEC-T17), "15. Decisions of this chapter" (R-SE-01), "16. Configuration values of this chapter" (http.hsts_max_age).docs/spec/16-operations.md: "4. Network" ("4.1 Basic rules", "4.2 Two listeners", "4.3 Edge and identity"), "5.1 Configuration", "5.2 Secret provider", "6.1 Environments", "14. Configuration values of this chapter" (server.listen_addr,server.internal_listen_addr,server.public_url,server.trusted_proxies,secrets.cache_ttl), "15. Decisions" (R-OP-01, R-OP-02).docs/spec/02-architecture.md: "9. Request path" ("9.1 Chain in the api node"), "13. Configuration loading", "14.1 Error kinds", "14.4 Failure of a dependency" (row secret provider), "15. Start and graceful shutdown" ("15.1 Start", "15.2 Health").docs/spec/08-identity.md: "2.4 Trusted proxy header" (rules 1 and 2), "13. Login protection" (client IP rule), "15. Tests" (ID-T08, ID-T25).docs/spec/06-api.md: "2. Basic rules" (row "Client IP"), "5. Errors".docs/PLAN.md: section 7, rows M1-18, M1-21, M1-43, M1-64, M1-73; "Known contradictions that M1 resolves" (W1 row "trusted proxies", W4); section 19 decisions P-08, P-09, P-10, P-11.- ADR 0011 (
docs/adr/0011-12-factor-configuration-and-secret-provider.md). docs/security/threat-model.md: "2. System boundaries" (TB1, TB2), "5.3 Network edge and proxy", register rows T-NW-02 and T-ID-03.infra/compose/compose.yaml(anchorx-server-env, servicesapiandapi-tailscale),infra/compose/.env.example,docs/operations/install-compose.md"5.1 Profile direct",docs/operations/tailscale.md,docs/operations/configuration.mdsections 2, 4 (generated) and 9 ("Compose variables").- Code merged by earlier tickets:
services/server/internal/platform/config(M1-07),internal/platform/secrets(M1-09),internal/platform/lifecycleandinternal/platform/health(M1-11),internal/platform/httpserver(M1-18: middleware chain, security headers, client IP from trusted proxies, problem bodies). docs/quality/gates.md,docs/quality/push-discipline.md,docs/quality/red-team-gate.md,CONTRIBUTING.md.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every behaviour change starts with a failing test (red output goes into the report), then code until green, then cleanup.
- No en dash and no em dash in any file you write (
uv run --project tools --locked --quiet python tools/hooks/text_rules.py <file>exits 0). White label: no company, person, host or instance names; examples useexample.com, people Alex, Sam, Robin, documentation addresses from RFC 5737 and RFC 3849. - Configuration only through named keys listed in
docs/operations/configuration.md; a new key gets its row in the chapter table first (Ruling L4), and the reference is regenerated. - SEC-TLS-1 (19 "2. Transport"): plaintext HTTP only from loopback or from peers in
server.trusted_proxies; every other plaintext connection is refused while the connection is set up. - SEC-TLS-2: where the server terminates TLS itself, at least TLS 1.2, TLS 1.3 preferred, Go standard cipher suites without an own list.
- SEC-TLS-3 and SEC-HDR-1: HSTS with
http.hsts_max_ageas soon as the instance is reachable over HTTPS, also behind a TLS proxy;X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer, no version inServeron every response. - SEC-HDR-2: API responses carry
Cache-Control: no-store,Cross-Origin-Resource-Policy: same-originandContent-Type: application/jsonorapplication/problem+json. - SEC-LOG-5: IP addresses never appear in general logs.
- R-OP-01: two listeners, public and internal; health stays on the internal listener (L2).
- P-10: tests bind only
127.0.0.1and port 0.
Scope¶
All Go paths are below services/server/.
- Specification (already decided): 16 "4.1 Basic rules" rule 1, "4.3 Edge and identity",
"5.2 Secret provider" and "14" (
server.tls_cert_ref,server.tls_key_ref); 19 SEC-TLS-1 to SEC-TLS-3, R-SE-08 and R-SE-09;docs/operations/configuration.mdsections 4 and 9 (BOTHY_TRUSTED_PROXIES);install-compose.md"5.1";infra/compose/.env.exampleand the passthroughBOTHY_SERVER__TRUSTED_PROXIESin serviceapiofinfra/compose/compose.yaml. Change them only if the build proves a statement wrong, in the same change. - Configuration schema (
internal/platform/config, typed config ininternal/wire): addserver.tls_cert_refandserver.tls_key_ref(typesecret_ref, default empty) and, if not yet present,http.hsts_max_age(type duration, default31536000s, whole seconds,>= 0). Cross checks at load time (02 "13. Configuration loading" step 4): both TLS keys set or both empty; with TLS set,server.public_urlmust usehttps. Error messages name key and source, never a value. - Transport in
internal/platform/httpserver(new filestransport.go,tlscert.go, tests):type TransportConfig struct { ListenAddr string; PublicURL *url.URL; TrustedProxies []netip.Prefix; TLSCertRef, TLSKeyRef <secret reference type of internal/platform/secrets> }where the zero value of both refs means plaintext.func ListenPublic(ctx context.Context, cfg TransportConfig, provider <secret provider interface of internal/platform/secrets>, log *slog.Logger) (net.Listener, error): openscfg.ListenAddr; returns a TLS listener in TLS mode, otherwise the plaintext listener wrapped byPeerFilter.func PeerFilter(inner net.Listener, trusted []netip.Prefix, name string, log *slog.Logger) net.Listener:Acceptreturns only connections whose peer is loopback or insidetrusted; it closes all others itself and keeps accepting.- A certificate source used as
tls.Config.GetCertificate(unexported is fine) that loads and validates the key pair at start and follows rotation through the secret provider.
- Headers: HSTS per design note 6 in the security-header middleware of M1-18. If M1-18
already emits
Strict-Transport-Security, make that single implementation follow design note 6; never keep two. Fix every response kind of design note 7 that lacks SEC-HDR-1, SEC-HDR-2 or a problem body. - Lifecycle (
internal/platform/lifecycle): open the public listener throughListenPublic; load and validate TLS material at start step 2 ("Resolve secrets", 02 "15.1 Start") so that bad material aborts before the database wait. The internal listener keeps its plainnet.Listen. - Compose contract: already in place (Scope 1). Add a test that reads
infra/compose/compose.yamland checks that serviceapipassesBOTHY_SERVER__TRUSTED_PROXIESfromBOTHY_TRUSTED_PROXIESwith the loopback default. Profiletailscalestays unchanged (the server listens on loopback in the sidecar namespace). - Docs and records: package doc comment (
doc.go) ofhttpserverdescribes the transport rules;services/server/README.mdplatform table mentions transport protection;docs/security/threat-model.mdregister row T-NW-02 lists SEC-T17 next to SEC-T01 and SEC-T02;services/server/CHANGELOG.mdunderUnreleased(Security). R-SE-08, R-SE-09, the HSTS form and the Compose passthrough are already in 16 and 19.
Out of scope¶
- The
trusted_headerloopback rule, ID-T08 and the second half of SEC-T01 ("start with the proxy header driver and a non-loopback listener"): M1-43 with ruling W4. - Client IP from
ForwardedandX-Forwarded-Forand ID-T25: M1-18. Reuse its parsedserver.trusted_proxies; do not parse the list a second time. - Rate limits and the
429response: M1-19. Ifinternal/platform/ratelimitis merged when you start, include its429in the SEC-T03 table; otherwise M1-19 adds that row. - WebSocket origin check SEC-CORS-3 and close codes: M1-64. Console headers SEC-HDR-3: M6. File
download headers SEC-HDR-4: M3. Outgoing TLS and the
InsecureSkipVerifylint: M1-22. TLS to PostgreSQL: M1-12. - Making the installation Compose startable and choosing a fixed Compose subnet: M7. The smoke
test (M1-73) probes from the network namespace of
apiand needs no proxy entry.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-21-transport-protection, created from the base branch the launcher names. One writer per worktree. - Conventional Commits with the module or component as scope (for example
feat(httpserver): refuse plaintext from untrusted peers), each commit signed off for the DCO (git commit -s); squash into meaningful commits before the merge. - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above, except
docs/DECISIONS.md,services/server/CHANGELOG.mdand generated references named above. - The core contract (
docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is read-only. If this ticket needs something it lacks, stop withVerdict: BLOCKED(P-11). - Follow the host rules supplied by the launcher. Tests bind only
127.0.0.1and port 0. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d(in Go testst.TempDir()), removed at the end by their literal path. - Never read or print secrets; never open
.envfiles. Test keys and certificates are generated inside the test. - No network calls except package downloads for pinned tools.
- Do not lower a threshold, disable a gate or delete a test.
Design notes¶
- Which listener. Transport protection applies to the public listener
(
server.listen_addr). The internal listener (server.internal_listen_addr) stays plaintext without a peer filter: it serves only/healthz,/readyzand in-stack webhooks, evaluates no identity or forwarded headers, and is never published on the host (16 "4.1 Basic rules" rule 1, "4.2 Two listeners"). Filtering it would force instance operators to add the whole Compose network toserver.trusted_proxies, which would also make forwarded headers from any container count on the public listener (R-SE-09). - Plaintext mode (both TLS refs empty). On every accepted connection take
conn.RemoteAddr(), parse it asnetip.AddrPort, applyAddr().Unmap(). Allowed if the address is loopback (127.0.0.0/8,::1) or contained in a prefix ofserver.trusted_proxies. Otherwise close the connection at once, without reading or writing, and continue accepting; never return an error fromAcceptfor a refused peer (that would makehttp.Serverback off). A remote address that is not TCP is refused. - Logging a refusal. The first refusal per listener and process logs at
Warnwith the listener name and the hint "plaintext connection from an untrusted peer refused; add the address of the reverse proxy as seen by the server to server.trusted_proxies"; later refusals log atDebug. No log line contains the peer address (SEC-LOG-5). - TLS mode (both refs set).
tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: ...}; noCipherSuites, noCurvePreferences, noMaxVersion, defaultNextProtosofnet/http, no client certificates. No peer filter in TLS mode. No HTTP-to-HTTPS redirect listener: native apps useserver.public_url, and a redirect listener would accept plaintext from anyone. - Certificate material. The certificate ref holds a PEM chain (leaf first), the key ref a PEM
private key in a form
tls.X509KeyPairaccepts. At start the server aborts with a clear error (key names, never values) if either ref cannot be read, the pair does not match, the leaf is not yet valid or expired, or the leaf does not cover the host ofserver.public_url(x509.Certificate.VerifyHostname). Rotation: on each handshake ask the provider for both values (the provider caches them forsecrets.cache_ttl, M1-09); when the returned versions change, parse the new pair; if parsing fails keep serving the previous certificate and log oneErrorper failed version (02 "14.4": rotation failure keeps the old value). - HSTS. On every response of both listeners, when the scheme of
server.public_urlishttps(which TLS mode enforces):Strict-Transport-Security: max-age=<http.hsts_max_age in whole seconds>. NoincludeSubDomains(other subdomains belong to the instance operator) and nopreload(not reversible). With anhttppublic URL (loopback development, 16 "6.1 Environments") the header is absent.max-age=0is valid and tells browsers to forget the rule. - Response kinds that SEC-T03 covers in M1. Set the SEC-HDR-1 headers in the outermost
middleware before calling the next handler, so that responses written by inner components
inherit them. Never set a
Serverheader. Kinds:2xxJSON of an operation; problem bodies for400malformed JSON,401,404unknown path,405wrong method,413body larger thanhttp.max_json_body_bytes,500from a recovered panic,500 internal_errorfrom the proof check (causeauthz_not_checked),503while shutting down;/healthzand/readyzon the internal listener in both states and an unknown path there. Where an inner component writes a body that is notapplication/problem+json(for example the defaultServeMux404 or 405 text, orhttp.TimeoutHandlerHTML), replace it with the problem body of 06 "5. Errors". - One source for trusted proxies.
TransportConfig.TrustedProxiesis the same parsed slice that M1-18 uses for the client IP. Bare addresses in the configuration count as/32or/128, following the type M1-07 defines. - Not supported, on purpose: PROXY protocol, h2c, Unix socket listeners for the public API.
Tests (write them first)¶
All tests live next to the code in services/server/internal/platform/httpserver unless named
otherwise. Use a fake net.Listener that hands out net.Pipe connections with a chosen
RemoteAddr to simulate non-loopback peers without binding anything but 127.0.0.1.
TestSECT01_PlaintextFromUntrustedPeerRefused: peer192.0.2.10:40000, trusted list without it; a realhttp.Serverserves the wrapped fake listener; the client side writes a valid request and reads EOF; the handler counter stays 0 and no byte reaches the client.TestSECT01_PlaintextFromTrustedProxyAccepted: peer198.51.100.7:40000, trusted198.51.100.0/24;200.TestSECT01_LoopbackAccepted: a real listener on127.0.0.1:0answers; fake peers[::1]:40000and[::ffff:127.0.0.1]:40000are accepted.TestSECT01_MappedUntrustedRefused:[::ffff:192.0.2.10]:40000is refused.TestSECT01_RefusalKeepsAccepting: a refused connection followed by a trusted one; the second is served.TestSECT01_RefusalLogHasNoAddress: capturedslogoutput has the hint once atWarn, later refusals atDebug, and nowhere the string192.0.2.10.TestSECT01_InternalListenerNotFiltered: a fake untrusted peer gets/healthzfrom the internal listener.TestSECT17_TLSBelow12Refused: TLS listener on127.0.0.1:0with a CA and a leaf for127.0.0.1generated in the test and written with mode0600intot.TempDir(), referenced asfile:secrets; a client withRootCAsset to the test CA andMaxVersion: tls.VersionTLS11fails the handshake. If the Go toolchain refuses to offer a version below 1.2, send a raw TLS 1.1 ClientHello and assert a fatal alert or a closed connection. Never useInsecureSkipVerify(M1-22 lints it repo wide).TestSECT17_TLS12And13: a client limited to TLS 1.2 negotiates 1.2; a default client negotiates 1.3; the server config has noCipherSuites.TestTLS_StartFailsOnBadMaterial: only cert set, only key set, mismatched pair, expired leaf, leaf without the public URL host, TLS with anhttppublic URL; each fails with an error that names the key and contains no PEM content.TestTLS_CertificateRotation: replace the certificate file; after the provider cache expires (inject the provider clock or a shortsecrets.cache_ttlin the test config) new handshakes see the new serial; a broken replacement keeps the old certificate and logs one error.TestSECT03_HeadersPerResponseKind: table driven over the kinds of design note 7, once withserver.public_url = https://chat.example.comand once withhttp://127.0.0.1:8080; expected header sets come from one table in the test file so later tickets add rows (files in M3, console in M6). If M1-18 already has a SEC-T03 test, extend it instead of adding a second one.TestHSTS_ValueAndCondition:max-ageequalshttp.hsts_max_agein seconds; noincludeSubDomains, nopreload; absent with anhttppublic URL.- Configuration tests in
internal/platform/config: the two new keys, their env namesBOTHY_SERVER__TLS_CERT_REFandBOTHY_SERVER__TLS_KEY_REF, the cross checks,http.hsts_max_agerejects fractions of a second and negative values.
Commands that must pass: make test, make lint, make docs, make check; and the Compose
contract check docker compose -f infra/compose/compose.yaml --env-file infra/compose/.env.example
config --quiet once with COMPOSE_PROFILES=direct and once with COMPOSE_PROFILES=tailscale.
Acceptance criteria¶
- SEC-T01 (first half): a plaintext connection from a non-loopback peer outside
server.trusted_proxiesis closed at accept time; the handler never runs. - Loopback peers, IPv4-mapped loopback and trusted peers are served.
- SEC-T17: a TLS handshake below 1.2 against a listener with own TLS fails; 1.2 and 1.3 succeed; 1.3 is negotiated when the client offers it; no own cipher list.
- Bad TLS material aborts the start with a message naming the key, never the value; rotation works without restart and a broken rotation keeps the old certificate.
- SEC-T03: every response kind of design note 7 on both listeners carries SEC-HDR-1 and, for
JSON and problem bodies, SEC-HDR-2; no
Serverheader; HSTS present exactly when the public URL ishttps, withmax-agefromhttp.hsts_max_age. - No log line contains a peer IP address.
- 16, 19,
configuration.md(regenerated section 4 and section 9),install-compose.md, Compose file and.env.exampleagree;make docsis green. -
make checkis green.
Definition of done¶
- Code, tests and documentation from the scope are complete: doc comment on every public symbol
(purpose, parameters, return value, errors), package doc for
httpserver, README and spec updates,docs/DECISIONS.mdentries, CHANGELOG entry underUnreleasedinservices/server/CHANGELOG.md. No ADR is needed unless you take an architecture decision beyond the rulings listed here; then write one (MADR 4,docs/adr/0000-template-madr-4.md). make checkis green.- Independent review:
make reviewplus a code review over the diff; then the red team ofdocs/quality/red-team-gate.mdover the diff with verdictJAorNEIN, recorded in the report. OnNEIN: fix with a test per finding and have the delta reviewed again. - Merge locally into the base branch only if review and red team are green and
make checkis green on the merged state; thengit worktree removethe worktree.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, test results with counts (including the red output of the
first test run), what is not proven, and follow-ups that could not be done in scope with the
reason. Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.