M1-22: Egress client and SSRF lint¶
Status: ready | Depends on: M1-07 | Milestone: M1
Goal¶
After this ticket every outgoing HTTP connection of the server has one place to go through:
internal/platform/egress. It refuses targets that are not allowed before any connection, checks
the IP address after DNS resolution at connection time (so DNS rebinding does not help), never
follows a redirect it has not checked, bounds time and response size, and always verifies TLS.
Two Semgrep rules make every other HTTP client, transport or dialer in production code and every
disabled certificate check a gate failure. OIDC discovery and JWKS (M1-41), push, webhooks, object
storage and transcription build on this client later.
Read first¶
docs/spec/19-security.md: "8. SSRF ban and outgoing connections" (SEC-NET-1 to SEC-NET-6), "2. Transport" (SEC-TLS-4, SEC-TLS-5), "9. Logs without secrets and without content", "14. Tests" (SEC-T02, SEC-T09), "15. Decisions of this chapter" (R-SE-04), "16. Configuration values of this chapter" (egress.allowed_hosts,egress.timeout,egress.max_response_bytes).docs/spec/02-architecture.md: "6. Code layout", "5. Layers within a module" (platform only in adapters), "8.5 What never happens in a transaction", "13. Configuration loading", "14.2 Deadlines" (row "outbound calls"), "14.4 Failure of a dependency".docs/spec/16-operations.md: "4.1 Basic rules" (rules 3 and 4).docs/spec/08-identity.md: "2.2 OIDC: server as relying party, app as client of the server" (the first consumer: discovery and JWKS).docs/spec/11-extensions.md: "7. Outgoing webhooks" (row "SSRF protection": onlyhttps, host inwebhook.allowed_hosts, connection bound to the checked address, no redirects).docs/spec/12-files.md: "8.2 Transcript" (voice.transcription_urlis instance configuration).docs/PLAN.md: section 7, rows M1-11, M1-22, M1-41; "Known contradictions" W9; decisions P-10, P-11.docs/security/threat-model.md: "2. System boundaries" (TB7), register row T-NW-01..semgrep/(existing rules and their fixtures, especiallybothy-go-route-registration.yamlfor thenosemgrepprecedent), thesemgreptarget inMakefile.docs/operations/configuration.mdsections 2 and 4, the generator merged by M1-08.- Code merged by earlier tickets:
services/server/internal/platform/config(M1-07),services/server/architecture/modules.yamland.go-arch-lint.yml(M1-05). docs/quality/gates.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. White label: no company, person, host or
instance names; examples use
example.comand addresses from RFC 5737 and RFC 3849. - Configuration only through named keys listed in
docs/operations/configuration.md. - SEC-NET-1: all outgoing HTTP connections go through the egress client; a lint forbids
http.DefaultClient,http.Get,http.Postand own dialers outside this package. - SEC-NET-2: allowed targets are only those that consumers name plus
egress.allowed_hosts; everything else is refused before the connection. - SEC-NET-3: the IP check runs after DNS resolution at connection time; blocked are loopback,
RFC 1918, link-local including the cloud metadata address, RFC 6598, RFC 4193, multicast and
unspecified addresses; the only exception are entries of
egress.allowed_hostswithallow_private. - SEC-NET-4: no automatic redirects for webhooks; for OIDC only to allowed targets.
- SEC-NET-5: time and size limits per response (
egress.timeout,egress.max_response_bytes). - SEC-TLS-4: outgoing TLS always verifies certificate and host name;
InsecureSkipVerifyis forbidden by lint.
Scope¶
All Go paths are below services/server/.
- Specification (already decided): 19 "8. SSRF ban and outgoing connections"
(SEC-NET-1 with the loopback client and the OTLP exemption, SEC-NET-2 with
httponly forallow_privateentries and no environment proxies, SEC-NET-3 with the full blocked list, SEC-NET-5 with the metric), R-SE-10, the meaning ofegress.allowed_hostsin "16. Configuration values of this chapter"; 02 "6. Code layout" (egress/, W9);docs/operations/configuration.md"2. Naming rule and formats" (row Egress target); the metricbothy.egress.blockedindocs/observability/signals.md. Change them only if the build proves a statement wrong, in the same change. - Configuration schema (
internal/platform/config, typed config ininternal/wire):egress.allowed_hosts(list of entries, default empty, each entry parsed withegress.ParseRuleand rejected at load time if invalid or duplicated),egress.timeout(duration, default15s,> 0),egress.max_response_bytes(integer, default1048576,> 0). Env namesBOTHY_EGRESS__ALLOWED_HOSTS,BOTHY_EGRESS__TIMEOUT,BOTHY_EGRESS__MAX_RESPONSE_BYTES. -
Package
internal/platform/egress(filesdoc.go,rule.go,addr.go,client.go,loopback.go,errors.goand tests), standard library only. Public interface:// Rule is one entry of egress.allowed_hosts. type Rule struct { Host string // lower-case DNS name or IP literal; no wildcard, scheme or path Port uint16 // 0 means only the default port of the scheme AllowPrivate bool } func ParseRule(s string) (Rule, error) // "host[:port][;allow_private]", IPv6 literal in brackets type Config struct { AllowedHosts []Rule // egress.allowed_hosts Timeout time.Duration // egress.timeout MaxResponseBytes int64 // egress.max_response_bytes } // HostPolicy is the allow list of one consumer (for example the hosts of configured identity // providers). It is asked before any DNS lookup. It can never allow private addresses. type HostPolicy func(ctx context.Context, scheme, host string, port uint16) bool type RedirectMode int const ( RedirectNever RedirectMode = iota // the 3xx response goes back to the caller unchanged RedirectChecked // follow only targets that pass every check again ) type Options struct { Purpose string // short label for logs, for example "oidc" Hosts HostPolicy // nil: only Config.AllowedHosts Redirects RedirectMode Timeout time.Duration // 0: Config.Timeout; a chapter value may override it MaxResponseBytes int64 // 0: Config.MaxResponseBytes } type Resolver interface { LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) } type Deps struct { Resolver Resolver // nil: net.DefaultResolver RootCAs *x509.CertPool // nil: system roots; never a way to skip verification Logger *slog.Logger } func New(cfg Config, opts Options, deps Deps) (*Client, error) func (c *Client) Do(req *http.Request) (*http.Response, error) // HTTPClient returns an *http.Client whose transport enforces every check, for libraries that // take a client (OIDC, S3). Callers must not replace its Transport or CheckRedirect. func (c *Client) HTTPClient() *http.Client // NewLoopback returns a client that reaches only loopback addresses over plain http, without // redirects; it is the client for `bothy healthcheck`. func NewLoopback(timeout time.Duration) *http.Client var ErrBlocked = errors.New("egress: blocked") var ErrResponseTooLarge = errors.New("egress: response too large") type BlockedError struct { Reason Reason; Host string } // errors.Is(err, ErrBlocked) type Reason string // "scheme_not_allowed", "host_not_allowed", "address_blocked", // "redirect_not_allowed", "url_not_allowed" -
Semgrep rules with fixtures, run by
make semgrep(ruleX.yaml, fixtureX.go):.semgrep/bothy-go-egress-only.yamland.semgrep/bothy-go-egress-only.go: flagshttp.DefaultClient,http.DefaultTransport,http.Get(...),http.Head(...),http.Post(...),http.PostForm(...),http.Client{...},&http.Client{...},new(http.Client),http.Transport{...},&http.Transport{...},net.Dial(...),net.DialTimeout(...),net.Dialer{...},&net.Dialer{...},tls.Dial(...),tls.DialWithDialer(...).paths: exclude: ["*_test.go"]. Insideinternal/platform/egresseach necessary construction carries// nosemgrep: bothy-go-egress-only -- the egress package is the one place that builds transports (19 SEC-NET-1). Fixtureokcases:egress.New(...),client.Do(req),http.NewRequestWithContext(...)..semgrep/bothy-go-tls-verify.yamland.semgrep/bothy-go-tls-verify.go: flagstls.Config{..., InsecureSkipVerify: $V, ...}for any$Vother than the literalfalse, and$C.InsecureSkipVerify = $Vlikewise. No path exclusion, also not for tests.
- Existing code. Run the new rules over the base branch. Every finding in production code is
changed in this ticket to use
egress.Neworegress.NewLoopback(for example a health probe ofbothy healthcheckif M1-11 is already merged). No newnosemgrepoutside the egress package. - Docs and records:
doc.goof the package (rules, examples for a consumer);services/server/README.mdplatform table gets the rowegress; ifservices/server/architecture/modules.yamllists platform packages or their vendor permissions, addegressthere (W9);docs/security/threat-model.md"2. System boundaries" row TB7 names the egress client among the controls;services/server/CHANGELOG.mdunderUnreleased(Security).
Out of scope¶
- Consumers: OIDC discovery and JWKS with their host policy (M1-41), push (M3), webhooks (M2), object storage (M3), transcription (M3). Do not build clients for them here.
- The OTLP exporters of
internal/platform/otel: exempt from the egress client by SEC-NET-1 (collector endpoint configured by the instance operator); nothing to build here. - Extra CA bundles for private PKI beyond
Deps.RootCAs: no configuration key exists. - Incoming TLS and HSTS: M1-21.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-22-egress-client, created from the base branch the launcher names. One writer per worktree. - Conventional Commits with the module or component as scope (for example
feat(egress): check addresses at connection time), 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 (00, 03, 04, 05) is read-only; a gap there ends the ticket with
Verdict: BLOCKED(P-11). - Follow the host rules supplied by the launcher. Tests bind only
127.0.0.1and port 0 and never reach the internet; DNS answers in tests come from a fake resolver. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d(in Go testst.TempDir()), removed at the end. - Never read or print secrets; never open
.envfiles. - No network calls except package downloads for pinned tools.
- Do not lower a threshold, disable a gate or delete a test.
Design notes¶
- Entry format of
egress.allowed_hosts.host[:port][;allow_private]; host is a DNS name (normalised to lower case, no trailing dot) or an IP literal (IPv6 in brackets when a port follows); no wildcard, scheme, path or user info; port1to65535; the only flag isallow_private. In the YAML file the list holds these strings; in the environment the entries are comma separated. Duplicate hosts with the same port are a load error. - Before any connection (in a custom
http.RoundTripperso thatHTTPClient()is covered too): the URL must have no user info (url_not_allowed); schemehttps, orhttponly when aRulewithAllowPrivatematches host and port (scheme_not_allowed); host and port must match aRule(port0matches only the default port of the scheme) or be allowed byOptions.Hosts(host_not_allowed). The resolver is not called for a refused host. - At connection time (custom
DialContext): resolve the host withDeps.Resolver(an IP literal is used as is),Unmap()every address, drop the zone, and dial only addresses that pass the check; dial the checked address itself, never the name again, and recheck the actual socket address innet.Dialer.Controlas a second line. If no address passes:BlockedError{Reason: address_blocked}. Blocked (always, with or withoutallow_private): unspecified and0.0.0.0/8,::; link-local169.254.0.0/16(includes169.254.169.254),fe80::/10; multicast224.0.0.0/4,ff00::/8; broadcast255.255.255.255and reserved240.0.0.0/4; IETF protocol assignments192.0.0.0/24; benchmarking198.18.0.0/15; deprecated site-localfec0::/10; discard-only100::/64; IPv4-compatible::/96; NAT6464:ff9b::/96and64:ff9b:1::/48; 6to42002::/16; Teredo2001::/32. Blocked unless a matchingRulehasAllowPrivate: loopback127.0.0.0/8,::1; RFC 191810.0.0.0/8,172.16.0.0/12,192.168.0.0/16; shared address space100.64.0.0/10(overlay networks); unique localfc00::/7. Documentation ranges (RFC 5737, RFC 3849) are not blocked; they are not routed on the internet and tests use them as public examples (19 SEC-NET-3, R-SE-10). allow_privateexists only inegress.allowed_hosts(instance operator trust); a consumer policy (Options.Hosts) never unlocks a private address. Reason: consumer lists can come from data such as a discovery document; the instance operator's file cannot.- TLS:
MinVersion: tls.VersionTLS12, verification always on,RootCAsfromDeps, server name from the URL host (the transport does this when the dial returns a TCP connection). - Proxies:
Transport.Proxyisnil;HTTP_PROXYandHTTPS_PROXYare ignored, because a proxy would make the dial check see only the proxy address. - Redirects:
RedirectNeverreturns the 3xx response unchanged (http.ErrUseLastResponse);RedirectCheckedruns design notes 2 and 3 for every hop and keeps the built-in redirect limit ofnet/http; a refused hop ends withredirect_not_allowedoraddress_blocked. - Limits and logs: overall time
Options.Timeoutoregress.timeout(client timeout plus request context); response bodies are wrapped so that reading beyond the limit returnsErrResponseTooLarge(never silent truncation; decompressed bytes count). A blocked attempt logs once atWarnwith purpose, reason and host; never path, query, headers or bodies; and incrementsbothy.egress.blockedwithbothy.reason= theReasonvalue (signals.md). - Callers map egress errors to their own error kinds (usually
503 unavailablewith the internal causedependency_unavailable, 02 "14.1 Error kinds"); the egress package does not knowkernel/errskinds of other modules. NewLoopbackdials only loopback addresses after resolution, plainhttp, no redirects; it exists so thatbothy healthcheckhas an allowed client without opening a hole in the lint.
Tests (write them first)¶
In services/server/internal/platform/egress; real servers are httptest servers on
127.0.0.1:0; DNS answers come from a map-based fake Resolver; dial attempts are counted with a
test hook on the unexported dialer.
TestSECT09_BlockedRanges: table with one address per blocked range in IPv4 and IPv6, including169.254.169.254,fe80::1,fc00::1,fd00::1,0.0.0.0,::,224.0.0.1,ff02::1,::ffff:127.0.0.1,::ffff:169.254.169.254,64:ff9b::a9fe:a9fe,2002:a9fe:a9fe::1,2001::1, and the first host address of10.0.0.0/8,172.16.0.0/12,192.168.0.0/16and100.64.0.0/10; expected verdict with and withoutallow_private. Private IPv4 host addresses are derived from their range in code (netip.MustParsePrefix("10.0.0.0/8").Addr().Next()) and never written as literals: the generic white-label rule (tools/denylist/generic.rules) flags literal private IPv4 host addresses in every tracked file and allows only whole ranges in CIDR notation.TestAllowedPublicAddresses:192.0.2.10,198.51.100.1,203.0.113.5,2001:db8::10pass.TestParseRule: valid forms, IPv6 with port, upper case normalised, and rejections (wildcard, scheme, path, port0or above65535, unknown flag).TestHostNotAllowedRefusedBeforeDial: resolver and dialer counters stay 0.TestSECT09_LiteralPrivateAddressRefused: rule127.0.0.1:<port>withoutallow_private;address_blocked; the test server counts 0 requests.TestSECT09_DNSNameWithPrivateAnswerRefused: consumer policy allowsapi.example.com, the fake resolver answers127.0.0.1; refused at dial time; server count 0.TestSECT09_RedirectToPrivateRefused: an allowed server (allow_privaterule) answers302tohttp://internal.example.com:<port>/, which resolves to127.0.0.1and is allowed by the consumer policy only; withRedirectNeverthe caller gets the302, withRedirectCheckedthe error isaddress_blocked; the target server counts 0 in both cases.TestSECT09_MetadataAddressRefusedEvenWithAllowPrivate: rulemetadata.example.com;allow_privateresolving to169.254.169.254; refused, dial counter 0.TestAllowPrivateRuleReachesLoopbackService,TestHTTPSchemeOnlyWithAllowPrivate,TestUserInfoRefused.TestTLSVerificationAlwaysOn:httptest.NewTLSServerbehind anallow_privaterule fails withoutRootCAsand succeeds withRootCAsfrom the server certificate.TestResponseLimit: exactly the limit passes, one byte more returnsErrResponseTooLarge; also with a gzip encoded body.TestTimeout,TestProxyEnvironmentIgnored(t.Setenvof both proxy variables to a counting server; it counts 0),TestBlockedAttemptLog(purpose, reason, host; no path or query) andTestBlockedAttemptCounted(bothy.egress.blockedwith the reason, manual metric reader).TestLoopbackClientRefusesNonLoopback: the fake resolver answers the first host address of10.0.0.0/8(derived as above); refused.- Configuration tests in
internal/platform/configfor the three keys and their env names. - Semgrep rule tests through the fixtures (
ruleid:andok:annotations). SEC-T02 also covers raw SQL; the existing rulebothy-go-sql-string-buildingstays green.
Commands that must pass: make test, make semgrep, make lint, make docs, make check.
Acceptance criteria¶
- SEC-T09: one address from each blocked range in IPv4 and IPv6, the metadata address, a DNS name with a private answer and a redirect to a private address are all refused, and the target is never contacted.
- Hosts that neither a
Rulenor the consumer policy allows are refused before DNS. - SEC-T02: the lint finds
http.DefaultClient,http.GetandInsecureSkipVerifyin the fixtures;make semgrepis green on the repository with no newnosemgrepoutsideinternal/platform/egress. - Time and size limits come from
egress.timeoutandegress.max_response_bytesor a consumer override; oversize bodies fail loudly. - Environment proxies are ignored; TLS is always verified.
- 02 "6. Code layout", 19 "8." and "16.",
configuration.mdsections 2 and 4 agree with the code; W9 is recorded indocs/DECISIONS.md. -
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, a consumer example in
doc.go), README and spec updates,docs/DECISIONS.mdentries, CHANGELOG entry underUnreleasedinservices/server/CHANGELOG.md. Write an ADR only if you take an architecture decision beyond the rulings listed here. 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 (at least: OTLP exporter routing for M1-10). Then write {{DONE_PATH}} with
exit=0 sha=<sha> or exit=1 <reason>.