Documentation
¶
Overview ¶
Package controlbytes catches request-derived strings reaching a log, span-attribute, or header sink without a control-byte scrub.
The bug class is terminal/log/header injection: r.URL.Path and r.Header values arrive PERCENT-DECODED, so %0d%0a, %1b and %00 in a request are real CRLF/ESC/NUL by the time middleware handles them. A raw CRLF forges an entry in any line-oriented log consumer; a raw ESC paints attacker bytes into every operator tail; a NUL in a header value reaches recorders and header-copying proxies verbatim (net/http only collapses CR/LF at write time). The 419-probe audit found this shape four times, each fixed the same way — scrub at the sink — and this rule fires on the shape, not the site:
- battery/log accessMiddleware entries (probe TestAccessEntryScrubbedOfControlBytes, fixed 4b7a25d2),
- core/middleware Idempotency's Finish-failure log (probe TestIdempotencyFinishLogKeyScrubbed, fixed b79942f7),
- core/middleware Tracing's span attributes (probe TestTracing_SpanAttrsScrubControlBytes, fixed b79942f7),
- framework/uihost's Link-header alternate path (probe TestLinkAlternatePathControlBytes, fixed a24928c1).
Log sinks are: slog.String/slog.Any values; the key-value logger calls (Debug/Info/Warn/Error and their *Context forms, plus Log) on a *slog.Logger receiver AND package-level slog.* (the default logger writes to stderr); the log MESSAGE as well as the values — a CRLF in a message forges log lines exactly like one in a value; fmt print calls — Fprint* to os.Stdout/os.Stderr and Print/Printf/Println, which write to os.Stdout unconditionally; and the std log package's Print/Printf/Println, package-level or on a *log.Logger receiver.
A value counts as scrubbed when it passes through a callee whose name says so (scrub/sanitize/escape/quote/redact — r.URL.EscapedPath and url.QueryEscape qualify) or through a same-package helper that inspects the value byte by byte (the byte-filter loop the uihost fix shipped inside markdownAlternate; a pass-through helper like TrimRight or truncate never looks at individual bytes and does not clear taint). A name whose only scrub evidence is the substring "clean" does NOT clear on the name: path.Clean and its kin are separator normalizers, not scrubbers, so a clean-named callee — foreign or local — must show the byte-level body evidence instead. Qualified calls into the stdlib path and path/filepath packages (Clean, Base, Dir, Join) never clear taint at all.
A tested value is clean for that variable when a validator-named call (validRequestID(id)) vetted it earlier in the function and the code kept its verdict — the vetting lasts until the variable's next assignment (2026-11-03: `validRequestID(id); id = r.Header.Get("X")` used to stay vetted forever). A call statement or an assignment to _ throws the answer away and vets nothing, ledger I9 of the 2026-10-27 bug bash; a call with no result still counts, since it can only refuse by panicking), or when a map membership (allowed[origin]) appears in the condition of an if statement or switch case that lexically encloses the sink — the sink then runs only for members of the configured set, and a control byte cannot be in that set. The negated-denial spelling counts as the same vetting — membership tested with `!` (directly or through comma-ok) in an if whose denial arm diverges (returns or panics), so code after the if runs only for members (battery/auth's BFF origin guard). A positive dedup/seen lookup gates nothing anywhere else in the function: that sink runs exactly for values the map has never vetted. Residual: a DEDUP map spelled with the negated-diverging form (`if _, dup := seen[p]; !dup { return }`) is lexically indistinguishable from an allowlist and is granted the same credit — the names differ, the shape does not.
Beyond the request, three more values count as untrusted at the seams the 2026-09-02 email round and the probe/log round of the audit drove probes into:
- the recover() value, but only to mark where it lands: a handler that panicked on request data hands request bytes to recover(), and the reporter seam cannot tell which panic did, so a struct whose field some in-package literal filled from recover() (or from any request-derived value — rendered straight, through a wrapper call, or through a method on it like rec.(error).Error()) is CARRYING, and exactly its carrying fields are sources wherever a parameter of that type reaches a sink (battery/log's ErrorReport, read by SlogErrorReporter.Report — probe TestErrorReporterRedScrubsAttrs). An in-function recover-and-log (mcp gates, websocket hooks) is NOT this rule's bug and stays quiet;
- string-bearing fields of a struct type declared in THIS package, reached from a function parameter, at the MESSAGE sinks below: battery/email cannot see who built the Email it serialises, so every field is untrusted exactly where it hits the wire (probes TestEmailRedStripsHeaderControlBytes / ...ParamControlBytes on buildMessage). Elsewhere only carrying fields count, and receiver fields never do: a receiver's config is operator data;
- a parameter named stderr or stdout: child-process output replayed to an operator (probe TestProbeRedScrubsStderrControlBytes on framework's tailForDetail into ProbeResult.Detail, against the scrubTerminalBytes/scrubTerminalOutput standard).
A same-package scrub-named helper clears only with body evidence now: a byte-indexed walk of the parameter whose comparisons name the control range (a literal in 0x09..0x20 or 0x7f — c < 0x20, c == '\t', c != 0x7f), in its own body or one same-package callee hop (scrubTerminalBytes delegates to terminalCtrlByte). The name alone stopped being enough when battery/email's quoteParamValue ("quote", strong scrub name) turned out to strip CR/LF/NUL and pass every other C0 byte and DEL verbatim into quoted MIME parameters, while a pass-through named escape/quote/redact never re-encoded anything at all. Foreign callees stay name-trusted: their bodies are not inspectable here, and url.QueryEscape really does re-encode.
Sinks added with those seams: net/smtp Client.Mail/.Rcpt (command arguments on the wire); the SMTP/MIME header-line writer — WriteString/Write on a strings.Builder or bytes.Buffer whose argument is a concatenation carrying both a CRLF literal and a colon-bearing literal, the shape of "From: " + v + "\r\n" (body-only writes and pure framing lines do not match); the Detail diagnostic field — a composite-literal Detail: or .Detail = — where child stderr/stdout is replayed to the operator inside error text; and http.Redirect's URL argument (the 308 Location, probe TestUihostRedRedirect308StripsControlBytes on framework/uihost handlePage; the partial branch of the same value is guarded by the isSafePartialRedirect validator and stays quiet).
Postures it deliberately stays silent on, because they are not this bug: JSON and HTML encoders escape structurally (encoding/json, html/template), so encoder arguments are left alone; the response BODY is not a sink — it is the response; span NAMES (tracer.Start, span.SetName) and log keys are left alone, only messages and VALUES are checked; fmt.Sprint* without a writer, and Fprint* to any writer other than os.Stdout/os.Stderr themselves or a local every binding of which is one (an http.ResponseWriter or a bytes.Buffer has its own framing); Header.Set/Add on a map whose provenance is an OUTBOUND *http.Request — an http.NewRequest/NewRequestWithContext result, or any request-typed value that is not the inbound handler parameter — because the client transport validates header bytes at write time and rejects control bytes (the response writer's header map, and the inbound parameter's, still fire); taint does not cross function boundaries — a request-derived argument to a helper is the helper's business, and the byte-indexing form above is the whole interprocedural concession; and structured values like a whole *http.Request or an ErrorReport struct are not sources, only the string-bearing request selectors are (Method/Host/RemoteAddr/RequestURI/Header itself/ URL.Path/URL.RawQuery, the Header.Get/Header.Values/FormValue/ PathValue/PostFormValue/Referer/UserAgent/BasicAuth accessors, url.Values.Get, an index into r.Header, and the Value field of a cookie bound from r.Cookie or an element of r.Cookies());
- a whole same-package struct handed to a helper is a payload, not a derivation: flash.put(&formFlash{...}) returns a random token whatever the record's fields carried, so the call's result stays clean (the argument itself is the helper's business, per the boundary posture above);
- fields a same-package struct only ever holds enums in (SecurityEvent.Kind) stay quiet even when another field of the same struct carries: carrying is per field, not per type.
Two arms came from the 2026-11 bug bashes.
A Location is stricter than a header (2a52a980). framework/uihost's scrubCtl percent-encoded C0 and DEL except tab (`c < 0x20 && c != '\t'`), and HTAB is a legal header byte, but the WHATWG URL parser deletes tab, CR and LF from a Location before it parses it: a RedirectPattern target of "/\t/evil.example" was //evil.example in the browser. At the http.Redirect sink and at Header.Set/Add of "Location", a same-package scrub whose body (or one callee hop) holds a != comparison against a literal in 0x01..0x1f — or the keep-side spelling of the same carve-out, an == against a C0 literal beside a printable-boundary comparison in one boolean chain (`c >= 0x20 || c == '\t'`) — lets that byte through and clears nothing. The same scrub still clears a log value or any other header. Fix spelling: encode every C0 byte and DEL.
Guard, then scrub (df472714): `if s.nextID != "" { b.WriteString(stripSSEControlChars(s.nextID)) }` tested the raw value and wrote the stripped one, so an id made only of control bytes wrote an empty "id:" line, which resets the client's Last-Event-ID. The arm fires on an if whose condition tests X for non-emptiness (X != "", len(X) > 0, != 0, >= 1, alone or as a conjunct) and whose body hands the same X to a stripping callee (strip/scrub/sanitize/remove/drop in its name) whose result is written as a field of its own: the argument of a Write*/Set/Add call or a composite-literal field. This arm needs no request source: the empty field is the bug whoever supplied the bytes. Quiet: an escaper (escape/quote/encode never empties a non-empty value); a result concatenated or formatted behind a fixed label ("tail: " + strip(s), Fprintf(w, "fix: %s", strip(s))), where empty is an empty label, not an empty field; a result stored in a local the body tests for emptiness; X reassigned before the strip; a strip of some other value. Fix spelling: strip first, test the result (SSEWriter.writeQueuedID).
Index ¶
Constants ¶
This section is empty.
Variables ¶
var Analyzer = &analysis.Analyzer{
Name: "controlbytes",
Doc: "report request-derived strings reaching log/span/header sinks without a control-byte scrub",
Run: run,
}
Functions ¶
This section is empty.
Types ¶
This section is empty.