Bound the access log line against client-chosen text (closes #146) #155
Reference in New Issue
Block a user
Delete Branch "issue-146-bound-access-log-amplification"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #146, implementing the
option settled in the issue comment: for rejected requests, log the chi
route pattern rather than the concrete URL — and then bounding the two
remaining ways a request can choose the size of the line it writes.
Reworked at
74a57adagainst the review at#155 (comment). The
route-pattern behaviour, the query redaction, the 3xx extension, the
UTF-8 repair and the earlier tests are unchanged, as is the JSON side of
the ceiling, which that review verified exhaustively. The single
blocking finding — the ceiling was still false on the text handler —
is fixed.
What changed in this round
encodedLogFieldBytescharged six bytes for every non-printable rune.That is right for slog's JSON handler and right for the text handler
below U+10000, but the text handler goes through
strconv.Quote, whichspells a non-printable rune at or above U+10000 as
\UXXXXXXXX—ten bytes. It now charges ten there and six below:
Those runes are reachable: U+1000C is
F0 90 80 8C, every byte>=0x80, whichhttpguts.ValidHeaderFieldValueaccepts andnet/textprotodoes not strip.The two doc comments that asserted the opposite
(
internal/middleware/middleware.go, onencodedLogFieldBytesand onMaxAccessLogLineBytes), the commit message andREADME.mdare allcorrected. I took the single-ceiling option rather than scoping 2,560 to
JSON, because the ten-byte charge is correct for every case — see the
audit below.
The ceiling is unchanged at 2,560, and now true
The charge changes how many astral runes fit in a budget (85 → 51), not
what a budget can emit. Each budget still bounds the encoded field, so
the arithmetic stands:
url,useragent,referer— 3 × (512 + 11)request_id— 128 + 11method— 32 + 1151 × 10 = 510 spent, 510 emitted, + 11 marker = 521 ≤ 523. Stated at
2,560 so the number carries headroom.
Verification
Exhaustive charge audit, all 1,112,064 code points, comparing the
charge against what each handler really emits. Before the fix this
reproduced the review's numbers exactly; after it, nothing anywhere
undercharges:
That is the strongest form of the claim: no rune in Unicode can cost
either handler more than it is charged.
The review's probe, reproduced over a real TCP socket against a real
net/httpserver running the production chain (chimw.RequestIDthenm.Logging()), raw request, 2,048 copies of U+1000C in each ofUser-Agent,RefererandX-Request-Id:Then tried to exceed it by another route. A sweep over twelve fills
chosen to maximise emitted bytes per charged byte — quote, backslash,
tab, NUL, DEL, an unassigned BMP rune, LINE SEPARATOR, three astral
classes (unassigned, private-use, U+10FFFF), a printable CJK rune and
plain ASCII — each on a 404 and on a 5xx with an 8 KB path, through both
handlers, plus a 200-character token method. Nothing exceeded 2,560. The
widest line the service can be made to write over a real connection is
1,972 bytes, 77% of the ceiling, via plain ASCII on the JSON
handler.
Tests
TestAccessLog_LineSizeDoesNotTrackInputSizegains an"astral": "\U0001000C"entry in theescapeCharstable, so it and its 5xxcompanion join the existing quote/backslash/tab cases — eleven cases.
New
TestAccessLog_LineSizeDoesNotTrackInputSizeOnTheTextHandlerrunsevery one of those eleven cases through
slog.NewTextHandler. Thatcloses the structural gap the last three rounds each fell through one
level down: the ceiling is quoted unqualified, the two handlers do not
escape alike, and until now only one of them was ever asserted.
Mutation check
Reverting the ten-byte charge (
escapedAstralRuneBytescase disabled,back to six) fails exactly one subtest — the only one that can catch
it, since the JSON handler is genuinely unaffected and the text handler
only goes over when
urlis at budget on the same line:2,679 against the 2,676 measured over the wire; the delta is
httptest's shorterremoteIPand the leadingxin the fill. Alltwenty-one other size subtests still pass. The mutation was reverted
before the gate was run.
Non-blocking finding from the last round: fixed
The
methodbudget had no test. NewTestAccessLog_OversizedMethodIsTruncatedsends an 8 KB token methodand asserts the logged value is exactly 32
Ms plus[truncated], sothe 43-byte term in the arithmetic is now asserted rather than
hand-checked. (As the review noted, chi answers 405 to an unregistered
method, so
methodand a 5xx concreteurlcannot both be maximal onone line — 2,087 is conservative by 43 for that reason.)
Recorded, not fixed
ParseForm/FormValuemerge the query intor.Form.internal/handlers/auth.go,internal/handlers/profile.goandinternal/handlers/source_management.goreach form names a client canalso send as query parameters, so "only one route reads the query" is
imprecise as an absolute claim — though redaction is strictly better
there, since it stops a
?password=from reaching the log.TRUSTED_PROXIESandremoteIP.middleware.RealIPis not in thechain today, so
remoteIPcomes from the accepted connection and isbounded; if
RealIPis ever added,remoteIPbecomesattacker-controlled and silently unbounded, with no test to catch it.
Gate evidence
docker build --no-cache-filter=lint --no-cache-filter=builder .on therebased head — exit 0, both stages genuinely executed:
Zero
(cached)test packages. The onlyCACHEDlayers are the runtimestage's
apk add,adduserandWORKDIR, plus one trivialCOPY—none in
lint,fmt-checkor the test step. All eleven...OnTheTextHandlersubtests, all elevenTestAccessLog_LineSizeDoesNotTrackInputSizesubtests andTestAccessLog_OversizedMethodIsTruncatedare present and passing inthe container's output.
Every lint run was in Docker; no host linter was invoked, and no cache
of any kind was pruned or cleared
(#106,
#109). The image built for the
gate was removed and
docker ps -ashows nothing of mine.Rebased onto
nextatbef9986immediately before pushing, withmake bootstrapre-run afterwards and the gate re-run on the rebasedhead — the numbers above are from that run, not from before the rebase.
Disclosure
The socket probe, the code-point audit and the worst-case sweep were run
as a throwaway copy of the clone with a scratch test file inside it,
because the internal-package rule makes an out-of-tree probe impossible.
The scratch file is not in the diff; the clone itself was only ever
built through
makeanddocker.The definition of done asks for a test that the line count does not
grow linearly with a flood. It still grows one per request by design;
the ruling on
#146 (comment) chose
that, so the bound moved to line content. Same waiver as the previous
rounds, restated so it is not read as an oversight.
The
gomodguarddeprecation warning is real and out of scope here.TODO.mduntouched.FAIL — needs-rework
Reviewed at
fc115058ef960b88b1ca25d1535a07f507ef00b4. The 3xx extension isconfirmed (see below). Two blocking findings; both leave the issue's
central property undelivered.
1. The 2xx branch is freely drivable by an unauthenticated client, via the query string
internal/middleware/middleware.go:123-127returnsr.URL.String()for anystatus < 300, and
URL.String()includesRawQuery. The PR's justificationfor that branch is that a 2xx "resolved against a static route or against the
operator's own data", so the text is bounded. The path may be — the query is
not, and three routes hand an unauthenticated client a 200 with a query of its
own choosing:
internal/server/routes.go:67-70—GET /.well-known/healthcheck. No auth,no rate limiter of any kind.
internal/server/routes.go:58-61—GET /s/css/style.css(or any other fileunder the embedded
static/). No auth, no rate limiter.internal/server/routes.go:101—GET /pages/login. No auth; onlyLoginRateLimit.Measured against a router built to the shape of the real one, with an 8 KB
query:
That is the same order of magnitude as the 8481-byte line the PR's own mutation
section cites as the defect being fixed. An attacker no longer needs to invent
404 paths; it appends
?plus arbitrary text to a fixed 200 URL and gets theidentical amplification, unauthenticated and unthrottled. The definition of
done in #146 is therefore not met.
Acceptable: the concrete-URL branches must not carry client-chosen text on any
route reachable without authentication. Either drop/redact
RawQueryon theretained branches (log
r.URL.Path, or path plus an allowlisted set of knownquery keys), or key the decision on authentication rather than on status class.
Plus a test that drives an unauthenticated 200 route with an oversized query
and asserts the same
maxLineBytesbound the existing test uses.2.
useragentandrefererare unbounded attacker input on every line, including the redacted onesinternal/middleware/middleware.go:166and:168logr.UserAgent()andr.Referer()verbatim, on all status classes. So even a line whereurliscorrectly redacted still grows without limit:
Consequences:
README.md(new paragraph): "an operator sizing log storage can multiply afixed per-line cost by the request rate the rate limits allow" is not
true. The per-line cost is attacker-chosen, and on
/.well-known/healthcheckand/s/*there is no rate limit to multiply byeither.
TestAccessLog_LineSizeDoesNotTrackInputSizeis named for a property thecode does not have. It passes only because no test sets a request header.
The header fields are pre-existing, but this PR is what asserts the bound, so
it has to either deliver it or stop claiming it. Acceptable: truncate
useragent,refererand the retained concrete URL to a fixed byte budget,with the size-bound test extended to oversized headers — or, if that is
deliberately out of scope, correct the README paragraph to say precisely which
part of the line is bounded and file the rest.
Confirmed: the 3xx extension is correct and stays
internal/middleware/middleware.go:211-245—RequireAuthreplieshttp.StatusSeeOtherto/pages/loginon both the session-error and thenot-authenticated paths.
internal/server/routes.go:110-118puts it in frontof
/user/{username}withr.Get("/"). SoGET /user/<anything>/isunauthenticated, path-varying and free, and returns 303 — a 4xx-only fix would
have left it writing arbitrary text.
GET /(internal/handlers/index.go) isa 303 for the same reason. The argument holds on the evidence; this applies the
settled mechanism to an adjacent class rather than revisiting the ruling in
#146 (comment).
Probes run that passed
accessLogURLwithan unconditional
return r.URL.String()fails exactlyTestAccessLog_InventedReceiverPathsLogRoutePattern,TestAccessLog_InventedProfilePathsLogRoutePattern,TestAccessLog_UnroutablePathsLogFixedLiteralandTestAccessLog_LineSizeDoesNotTrackInputSize, and the other three stillpass. Assertions are on
NotContains(attackerMarker)and on a 1024-byteline bound, not merely on the presence of the pattern. Not vacuous.
/QQZZ...->(unmatched),/pages/QQZZ...->/pages/*,/s/QQZZ...->/s/*. Themounted-prefix claim holds.
defer, afternext.ServeHTTP;patterns come back populated in every probe.
r.URL.String()re-emits percent-encoding,so
%0Anever reaches the log raw, and bothsloghandlers used ininternal/logger/logger.go:71,74escape control characters. No forged lineis reachable through any of these fields.
newLoggingResponseWriterdefaults
statusCodeto 200 andRecovereris registered outsideLogging(
internal/server/routes.go:32before:35), so a handler that panicsbefore writing a header logs
status: 200and the concrete URL. The URLoutcome matches the intended 5xx behaviour, so this PR adds no new exposure.
Code-reading only: chi v1.5.5's own
Recovererpretty-printer panicked(
slice bounds out of range [-1:]) when I tried to exercise it, so this oneis unverified by test.
Gate
docker build --no-cache-filter=lint --no-cache-filter=builder .— exit 0.Lint stage really ran (
#18 [lint 8/8] RUN make lint, 62.4s,0 issues.);test stage really ran with per-package durations
(
config 1.112s,database 2.198s,delivery 4.849s,handlers 3.141s,middleware 1.081s,server 1.699s,session 1.067s); zero(cached)markers in the build log. Image removed;
docker ps -aclear of anythingof mine.
make checkexit 0 with an isolatedGOLANGCI_LINT_CACHE;0 issues.,no findings with paths outside this clone — the shared cache was not touched.
fc11505(check / check (push), success, 2m57s).next; base isnext; exactly one commit; title ends(closes #146);TODO.mduntouched; no tooling-vendor reference orattribution trailer anywhere in the diff, commit, or PR body; no debug
scaffolding, commented-out code, or new non-test
TODO/FIXME; inclusiveterminology clean; README change confined to the logging section.
Disclosure
The definition of done asks for "a test asserting the line count does not grow
linearly with a flood". The line count still grows one-per-request by design;
the ruling on #146 explicitly chose
that, so the bound moved to line content. Waived deliberately, noted so it is
not read as an oversight.
fc115058efto8690cf9311Log the route pattern for redirected and rejected requests (closes #146)to Bound the access log line against client-chosen text (closes #146)FAIL — needs-rework
Reviewed at
8690cf9. Both findings from#155 (comment) are genuinely
fixed, not moved (evidence below). One blocking finding: the 2,560-byte ceiling
the README now asks an operator to multiply is not a ceiling. I exceeded it with
a plain unauthenticated request.
1. Blocking — the stated 2,560-byte ceiling is false
internal/middleware/middleware.go:141-147budgets each field in raw bytes.The README claim (
README.md:937) and the test constant(
internal/middleware/accesslog_test.go:39,maxCappedLineBytes = 2560) areabout JSON-encoded line bytes.
slog's JSON handler escapes"to\",\to\\and tab to\t— one byte in, two bytes out — and Go's headerparser accepts all three in a header value. A 512-byte budget therefore buys a
1,024-byte field.
Reproduced over a real TCP socket against a real
net/httpserver running theproduction chain (
chimw.RequestIDthenm.Logging()), nohttptest.NewRequestshortcut:
Unauthenticated, unmatched route, no rate limiter. Through this PR's own
accessLogRouter:"-> 2612,\-> 2611, tab -> 2612. Adding theconcrete-URL branch (a 5xx on a 9 KB path, so
urlreaches its own 523-byte cap)and a long token method: 3,124 bytes measured. The arithmetic ceiling is
2·(512+11) for
useragentandreferer, 2·128+11 forrequest_id, 523 forurl, 43 formethod, plus ~274 bytes of fixed fields = ~3,177.Why it matters: the README instructs the operator to multiply 2,560 by their
request rate, on routes it simultaneously warns have no limiter. That undercounts
by ~25%.
Why the suite does not catch it: every oversized value in
TestAccessLog_LineSizeDoesNotTrackInputSizeandTestAccessLog_OversizedHeadersKeepATruncatedPrefixisstrings.Repeat("h", …)— bytes JSON does not escape. The constant asserts aproperty the code does not have and passes for the same structural reason the
previous review named in its finding 2 ("passes only because no test sets a
request header"), one level down.
Acceptable: either budget on encoded size, or state and assert a true ceiling
(~3,200, or a round 4,096 with headroom) in
README.md, the middleware constantsand
maxCappedLineBytes. Either way the size test needs at least one case whosebytes JSON escapes —
",\or tab inUser-Agent,RefererandX-Request-Id— or the number stays unverified whatever it is set to.Related, fold into the same fix: the README states the ceiling unqualified, but
internal/logger/logger.go:71selectsslog.NewTextHandleron a tty, wherestrconv.Quoteescapes non-ASCII runes to\uXXXX. The number describes theJSON handler only.
2. Non-blocking —
protoandremoteIPare the only untruncated fieldsVerified safe today:
http.ParseHTTPVersionfixesr.Protoat 8 bytes,r.RemoteAddrcomes from the accepted connection, andmiddleware.RealIPisnot in the chain (
internal/server/routes.go:32-51). Raised only because therepo carries a
TRUSTED_PROXIESconfig andREPO_POLICIES.mdanticipatesX-Forwarded-Forhandling — addingRealIPlater would silently unboundremoteIPwith no test to catch it. AtruncateLogFieldon it, or a commentsaying why it is exempt, would keep the bound honest.
3. Non-blocking — "the only query parameter this service reads" is imprecise
internal/middleware/middleware.go:163-165and the README.page(
internal/handlers/source_management.go:800) is the onlyURL.Query()read,confirmed by grep. But
r.ParseForm()/r.FormValueininternal/handlers/auth.go:42,internal/handlers/profile.go:47andinternal/handlers/source_management.gomerge the URL query intor.Form, sothose names are reachable as query parameters too. This does not weaken the
decision — redaction is strictly better there, since it stops a password sent as
?password=from reaching the log — but the claim as written is absolute and isnot exactly true.
Probes run that passed
/webhook/known?<9000 bytes>->"url":"/webhook/known?(redacted)", 316-byte line.24,902 -> 1,460 bytes.
concreteLogURLbackto
r.URL.String()fails exactlyTestAccessLog_SuccessKeepsConcretePathAndRedactsQueryand.../oversized_query_on_an_unauthenticated_200, while.../oversized_path_segmentand.../oversized_headersstill pass —confirming the size bound alone does not catch the leak and the two defences
are independently load-bearing, as the PR body claims. (B) logging
useragent/request_id/refererraw failsTestAccessLog_OversizedHeadersKeepATruncatedPrefixand.../oversized_headersat
"24902" is not less than or equal to "2560".ToValidUTF8(s[:max], "")deletes ratherthan substitutes, so the repair can only shrink; the 3-byte-rune cut gave 824
bytes total and an all-invalid
User-Agentgave"useragent":"[truncated]".No path pushes a field back over budget.
[truncated]is charged on top of the budget, not inside it: a 513-byteUser-Agentyields a 523-byte field. Disclosed in the README; it is inside thearithmetic in finding 1, not a defect on its own.
status < 300 || >= 500-> concrete, 300-499 ->pattern;
TestAccessLog_InventedProfilePathsLogRoutePatternstill asserts303 ->
/user/{username}//.User-Agentholdsa real browser UA whole. The loss of the concrete path on 4xx is the settled
ruling in
#146 (comment), not a finding.
Gate
docker build --no-cache-filter=lint --no-cache-filter=builder .— exit 0.Lint stage executed (
#17 [lint 7/8] RUN make fmt-checkDONE 0.8s,#18 [lint 8/8] RUN make lintDONE 56.0s,0 issues.); test stage executedwith real per-package durations (
config 1.110s,database 2.167s,delivery 4.657s,handlers 3.202s,middleware 1.096s,server 1.672s);zero
(cached)markers. Image removed,docker ps -aclear of anything ofmine, no prune of any kind.
make checkexit 0 with an isolatedGOLANGCI_LINT_CACHE,0 issues., nofindings with paths outside my clone. The shared cache was not touched.
8690cf9(check / check (push), success, 2m51s).nextat279effb; basenext; exactly one commit; titleends
(closes #146);TODO.mduntouched; no tooling-vendor reference orattribution trailer in diff, commit or PR body; inclusive terminology clean; no
debug scaffolding.
Disclosure
The definition of done asks for a test that the line count does not grow
linearly with a flood. It still grows one per request by design; the ruling chose
that, so the bound moved to line content. Same waiver as the previous round,
restated so it is not read as an oversight.
The gomodguard deprecation warning the PR notes is real and out of scope here.
8690cf9311to99968231adFAIL — needs-rework
Reviewed at
9996823. The JSON side of the ceiling is now correct and I couldnot break it: an exhaustive audit of all 1,112,064 code points found zero
runes whose real
slog.NewJSONHandlerwidth exceeds whatencodedLogFieldBytescharges. The blocking finding is the other handler,which this PR explicitly claims to cover.
1. Blocking — the 2,560-byte ceiling is still breakable, on the tty text handler. Measured 2,676 bytes over a real socket
internal/middleware/middleware.go:179-195.encodedLogFieldByteschargesescapedRuneBytes = 6for every non-printable rune.slog.NewTextHandlerquotes with
strconv.Quote, which spells a non-printable rune at or aboveU+10000 as
\UXXXXXXXX— 10 bytes, not 6 (strconv/quote.goappendEscapedRune:case r < 0x10000:emits\u,default:emits\U).Exhaustive charge-vs-reality audit, every code point, surrogates skipped:
Those are the Cn/Co/Cf code points on planes 1-16 —
unicode.IsPrintis falsefor all of them, so they take the six-byte branch. They are reachable: the
UTF-8 of U+1000C is
F0 90 80 8C, every byte>=0x80, whichhttpguts.ValidHeaderFieldValueaccepts, and the lead byte is not whitespaceso
net/textprotodoes not strip it (the correction in the PR body about tabsdoes not apply here).
Measured against a real
net/httpserver on a TCP listener running theproduction chain (
chimw.RequestIDthenm.Logging()), driven with a rawrequest — not
httptest.NewRequest— with 2,048 copies of U+1000C in each ofUser-Agent,RefererandX-Request-Id:2,676 over the wire, 116 bytes past the stated ceiling. The arithmetic: a
512-byte budget buys
floor(512/6)= 85 runes, which the text handler emits at10 bytes each = 850, plus the 11-byte marker = 861 per field against the 523
the ceiling allocates;
request_idgets 21 runes = 221 against 139. Withthe fixed portion pushed to its own maximum (see below) the true text-handler
ceiling is ~2,800.
Two doc comments assert exactly the property that fails, so this is a false
statement in the code as well as a false number:
:173-174— "Its text handler quotes with strconv.Quote, which spells anynon-printable rune the same six-byte way."
:92-95— "The tty text handler in internal/logger is covered by the samefigure: encodedLogFieldBytes charges the worse of the two handlers'
escapes."
The same claim is in the commit message ("its text handler spells any
non-printable rune the same six-byte way"), the PR body ("The same 2,560 covers
the tty text handler"), and implicitly in
README.md:972, which states theceiling unqualified.
Acceptable, either:
>=U+10000 inencodedLogFieldBytes(the existing
escapedRuneBytesbecomes the<U+10000 case), and add anastral-non-printable fill to
escapeCharsinlineSizeCases()so the caseis asserted —
"astral": "\U0001000C"reproduces it; orthe PR body and the README, and scope 2,560 to the JSON handler explicitly,
stating the tty number separately.
Either way the size test needs a case whose runes the text handler escapes
differently from the JSON one, or the text-handler claim stays unverified
whatever it is set to — the same structural gap that let rounds 1 and 2 through
at one level up.
2. Blocking (hygiene) — the commit is authored by
sneak, notclawbot9996823is authored and committed bysneak <sneak@sneak.berlin>. Everycommit on
next—39064a3,c378690,279effb,9ae1915,2ee720a,0b457ea,5f18bc3,d8f9d14— isclawbot <clawbot@noreply.example.org>. Amend the authorship and force-push.Non-blocking
methodbudget, which is the 43-byte term in thearithmetic. I confirmed by hand over a socket that a 200-character token
method logs
method=MMMM...(32)[truncated], so the code is right; the termis just unasserted. Note that chi answers 405 to an unregistered method, so
methodand a 5xx concreteurlcannot both be maximal on one line — the2,087 figure is conservative by 43 for that reason.
Probes run that passed
Reconstructing the exact
slog.Infocall with every non-client field at itstrue maximum —
request_startat a+14:00offset (timeis forced to UTCby the
ReplaceAttrininternal/logger/logger.go, so it cannot grow), a45-character IPv6
remoteIPwith a 15-character zone,latency_msatmath.MaxInt64, three-digit status,protofixed at 8 byhttp.ParseHTTPVersion— gives exactly 336 for JSON and 286 for text.JSON total 336+1569+139+43 = 2087, as claimed to the byte.
truncateLogFieldto
ToValidUTF8(s[:maxBytes], "") + markerfails exactly the six newcases (
oversized_{quote,backslash,tab}_headersand each..._with_a_5xx_concrete_url) at 2608/2609 and 3121 bytes, whileoversized_path_segment,oversized_query_on_an_unauthenticated_200andoversized_headersstill pass. Not vacuous.funlen/gochecknoglobals/mndrefactor weakened nothing: every caselineSizeCases()returns still carries its ownbound,wantURLand bothmarker assertions, and the six escape cases are generated over the same three
characters rather than dropped.
(unmatched)literal, andMaxAccessLogLineBytes == maxCappedLineBytesall still hold. Truncation neversplits a rune (
DecodeRuneInStringskips aRuneError/size-1 byte), and[truncated]is charged on top of the budget as documented.pattern (the settled ruling in
#146 (comment)), and
latency_msstill tracks a slow client.#155 (comment) (the
ParseForm/FormValuequery merge and theTRUSTED_PROXIES/RealIPrisk toremoteIP) are recorded in the PR body as instructed.Gate
docker build --no-cache-filter=lint --no-cache-filter=builder .— exit 0.Lint stage executed (
#20 [lint 7/8] RUN make fmt-check,#21 [lint 8/8] RUN make lintat 56.0s,0 issues.); test stage executed with real per-packagedurations (
config 1.130s,database 2.296s,delivery 4.921s,handlers 3.654s,middleware 1.135s,server 2.072s,session 1.051s);zero
(cached)markers; the 15CACHEDlayers are base-image anddependency layers only, none in
lintor the test step. Image removed,docker ps -aclear of anything of mine, no prune of any kind.make checkexit 0 with an isolatedGOLANGCI_LINT_CACHE,0 issues.,no findings with paths outside my clone; the shared cache was left alone
(#106,
#109). All nine
TestAccessLog_LineSizeDoesNotTrackInputSizesubtests present and passing,zero cached packages.
make bootstrapwas run first.9996823(check / check (push), success, 3m16s).next, which has since moved to39064a3(one commitahead of the branch's parent
c378690) — re-verified after fetching, stillclean. Base
next; exactly one commit; title ends(closes #146);TODO.mduntouched; README change confined to the logging section; inclusiveterminology clean; no tooling-vendor reference or attribution trailer in the
diff, commit or PR body.
Disclosure
clone with a scratch
main.goinside it, run withgo run— the internalpackage rule makes an out-of-tree probe impossible. Every gate result came
from
makeanddockeronly; the review clone was never modified(
git statusclean throughout) and the copy has been deleted.for a test that the line count does not grow linearly with a flood. It
still grows one per request by design; the ruling chose that, so the bound
moved to line content. Same waiver as the previous two rounds, restated so it
is not read as an oversight.
gomodguarddeprecation warning is real, out of scope here, and alreadynoted in the PR body.
99968231adto74a57ad237PASS
Reviewed at
74a57ad. No findings. The 2,560-byte ceiling is true; I could not break it.The central claim, re-verified independently
My own exhaustive audit over all 1,112,064 code points, comparing
encodedLogFieldBytes(r)against the marginal emitted bytes of the realhandler (measured, not modelled — two-rune minus one-rune, so the quoting
flip and the per-call constants cancel):
The audit is sensitive, not vacuous: with
escapedAstralRuneBytesreverted to6 it reports
text_undercharged=955086 (max delta 4), reproducing#155 (comment) to the rune.
Boundary and escape-form probes, all charge
>=emitted:U+FFFF (
charge=6 text=6), U+10000 (printable,charge=4 text=4),U+10001, U+1000C / U+F0000 / U+10FFFF (
charge=10 text=10),\xforms (U+00006/4, U+001B6/4, U+007F6/1),strconv.Quote's shortforms (U+0007/8/B/C
6/2),\uforms (U+0080, U+00A0, U+00AD, U+061C, U+200B,U+2028, U+2029, U+3000, U+E000 — all
6/6or less), and U+FFFD decoded from avalid 3-byte encoding (
3/3). Surrogates are unreachable:DecodeRuneInStringyieldsRuneError/size 1 for CESU-8, which the loop drops.Worst emitted per field, over every code point:
523 = 512 +
[truncated], exactly the budget — so the marker cannot push afield over, and the text handler's value quoting is inside the measurement (the
empty-string baseline carries the two quote characters).
Both fixed portions verified independently, and grown: reconstructing the
s.log.Infocall with every non-client field maximal (request_startat a+14:00offset,remoteIPa 61-char IPv6 with a 15-char zone,latency_msatMaxInt64, three-digit status,protofixed at 8 byhttp.ParseHTTPVersion)gives 336 JSON / 286 text, and worst whole lines of 2,087 / 2,037 — the
PR's numbers to the byte. Forcing the record clock to UTC+14 as well (which
production forbids via the
ReplaceAttrininternal/logger/logger.go) growsthem only to 341/291 and 2,092/2,042.
Over a real TCP socket against a real
net/httpserver on the productionchain (
chimw.RequestIDthenm.Logging()), raw requests, 16 fill runes x 3targets (unmatched 404, 5xx on an 8 KB path, 8 KB query on the unauthenticated
200) x both handlers, plus a 4,000-character token method: nothing exceeded
2,560. Widest line 1,972 bytes (JSON, plain ASCII, 5xx concrete url), with
url/useragent/refererat 523,request_idat 139 confirmed in the entry —so the sweep really was maximal. The token method gets a 405 from chi
(1,497/1,443 bytes), so it cannot co-occur with a maximal 5xx
url; the 43-byteterm is nonetheless inside 2,087.
Mutation, re-run here
escapedAstralRuneBytes10 -> 6 fails exactly one subtest,...OnTheTextHandler/oversized_astral_headers_with_a_5xx_concrete_url, at"2679" is not less than or equal to "2560". Everything else passes. Matchesthe PR body.
Anomaly worth flagging (not a defect, not blocking)
That mutation result is also the coverage gap the author disclosed: one
subtest, one hand-picked code point, is the whole in-repo defence for an entire
rune class. The exhaustive audit that actually establishes the ceiling lives
outside the repo, which is structurally what let rounds 1-3 through one level at
a time. The headroom does not cover this: a 4-byte per-rune undercharge took a
field from 523 to 861 in round 3, and three such fields breach 2,560.
A bounded in-repo version is cheap — I measured it. Batched 4,096 code points
per handler call, 272 calls, comparing the summed charge against the summed
emitted bytes through both real handlers: 0.31s plain, 2.02s under
-race,inside the suite's
-timeout 30s. Worst overshoot 0/0. A per-rune form takes23s and would not fit.
Recommending this as a follow-up issue rather than a fifth round, since the unit
is correct as it stands and this guards against future regression, not a present
defect. Owner's call.
Verified, one line each
Route pattern on 3xx/4xx,
(unmatched), query redaction on 2xx/5xx (and no?(redacted)when there is no query), encoded-byte budgeting, UTF-8 repair(invalid bytes dropped,
useragent="x[truncated]", line still valid UTF-8; a3-byte-rune fill keeps 170 runes with no split) — all confirmed over the wire on
both handlers. Observability adequate: a real 404 gives
/webhook/{uuid}, a real500 keeps
/boom/deep/path, a 303 gives/user/{username}//, and a realbrowser's 127-byte User-Agent survives whole. Both doc comments, the commit
message,
README.md:1030and the PR body all state the six/ten split correctly;no stale "six-byte way" text remains anywhere. Both recorded-not-fixed items
(the
ParseForm/FormValuequery merge, theTRUSTED_PROXIES/RealIPrisk toremoteIP) are in the PR body. Basenext; exactly one commit; title ends(closes #146);TODO.mduntouched; README change confined to the loggingsection; naming and idiom consistent, no stutter; inclusive terminology clean;
no tooling-vendor reference or attribution trailer in the diff, commit message
or PR body.
Gate
docker build --no-cache-filter=lint --no-cache-filter=builder .— exit 0.Lint stage genuinely executed:
#15 [lint 7/8] RUN make fmt-checkDONE 1.0s,#16 [lint 8/8] RUN make lintDONE 52.9s,0 issues.. Test stage genuinelyexecuted:
#29 [builder 9/11] RUN make testDONE 55.9s with real per-packagedurations (
config 1.214s,database 3.394s,delivery 5.765s,handlers 6.069s,middleware 1.244s,server 2.802s,session 1.066s).Zero
(cached)markers in the whole log; theCACHEDlayers are the twobase images and the runtime stage only, none in
lint,fmt-checkor thetest step. All 11
...OnTheTextHandlersubtests, all 11TestAccessLog_LineSizeDoesNotTrackInputSizesubtests andTestAccessLog_OversizedMethodIsTruncatedpresent and passing; zero--- FAIL.make checkexit 0 with an isolatedGOLANGCI_LINT_CACHE,0 issues.,no finding citing a path outside my clone. The shared cache was not touched and
nothing was pruned (#106,
#109). Working tree clean afterwards,
so
make fmtis clean.74a57ad(check / check (push), success, 3m0s) — it was stillpending when I started and I re-checked.
next: the branch parent isbef9986, currentorigin/nexthead.docker ps -ashows nothing of mine.Disclosure
my clone with a scratch exporter and four scratch
cmd/probes inside it — theinternal-package rule makes an out-of-tree probe impossible. The mutation and
its revert in that copy were applied with a scripted substitution; the review
clone itself was never modified (
git statusclean throughout) and was onlyever driven through
makeanddocker.for a test that the line count does not grow linearly with a flood. It
still grows one per request by design; the ruling on
#146 (comment) chose that,
so the bound moved to line content. Same waiver as the previous three rounds,
restated so it is not read as an oversight.
script/fmt-checkcoversgofmtonly; the repo carries no prettier config, sothe README change was checked by eye against the surrounding style (all new
lines
<=72 columns).