controlbytes

package
v0.85.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 8, 2026 License: MIT Imports: 8 Imported by: 0

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 anywhere earlier in the function, 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) 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 (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/URL.Path/ URL.RawQuery, the Header.Get/FormValue/PathValue/PostFormValue/ Referer/UserAgent/BasicAuth accessors, url.Values.Get, and the Value field of a cookie bound from r.Cookie);

  • 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.

Index

Constants

This section is empty.

Variables

View Source
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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL