Enforce the body size limit before CSRF parses the form (closes #90) #91
Reference in New Issue
Block a user
Delete Branch "issue-90-body-limit-before-csrf"
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?
Fixes the ordering bug from the #83 review: the 1 MB form body cap was registered after the CSRF middleware, so it never applied to form fields.
The bug
chi runs
Usemiddleware in registration order. Every form route group ininternal/server/routes.goregisteredCSRF()beforeMaxBodySize(maxFormBodySize). gorilla/csrf (v1.7.3,helpers.go:113) callsr.PostFormValue, which parses the body — so by the timeMaxBodySizeinstalled its reader, the form had already been parsed under net/http's default 10 MB cap. The intended 1 MB limit was dead code for form fields on every one of those routes.Ordering fix, per route group
MaxBodySize(maxFormBodySize)is now the firstUsein each group, ahead ofCSRF():/pagesCSRF,NoCache,MaxBodySizeMaxBodySize,CSRF,NoCache/sourcesCSRF,NoCache,RequireAuth,MaxBodySizeMaxBodySize,CSRF,NoCache,RequireAuth/source/{sourceID}CSRF,NoCache,RequireAuth,MaxBodySizeMaxBodySize,CSRF,NoCache,RequireAuth/user/{username}CSRF,NoCache,RequireAuth— no cap at allMaxBodySize,CSRF,NoCache,RequireAuthEach group carries a comment stating why the cap has to precede CSRF, so the order is not silently "tidied" back later.
The missing cap on
/user/{username}Worth calling out separately, because it is worse than the reported bug:
setupUserRouteshad noMaxBodySizeregistration whatsoever. That is the group wherePOST /passwordfrom #83 lives, so the password-change endpoint had no middleware-level body cap at all — its only limit was the handler-localhttp.MaxBytesReaderinprofile.go:35, which ran after CSRF had already parsed the body and was therefore useless for form fields. This PR adds the middleware to that group.Why
MaxBytesReaderalone could not produce a 413Reordering by itself does not give you a 413.
http.MaxBytesReaderdoes not reject anything at wrap time — it reports the overflow as an error fromRead. With the reader correctly installed ahead of CSRF, an oversized body makes gorilla/csrf's form parse fail, and gorilla/csrf converts that into its own403 Forbidden - invalid CSRF token. The operator would see a bogus CSRF error for what is really an oversized request.So
MaxBodySizenow also checks up front. ForPOST/PUT/PATCH, ifr.ContentLengthexceeds the limit it logs, writes413 Request Entity Too Large, and returns without callingnext— neither CSRF nor the endpoint handler runs.http.MaxBytesReaderis still installed afterwards, so the two paths are:ContentLength == -1, or a client lying about its length) — nothing to check up front, so the reader hard-caps the body at the limit and the request fails downstream at form-parse time. Less precise as a diagnostic, but the body is still never buffered past the cap, which is the property that matters.Both paths are spelled out in the middleware's doc comment.
Handler-local
MaxBytesReadercalls removedAll of them, so there is a single enforcement point and no duplicated limits to drift apart. Each site keeps a one-line comment pointing at the middleware:
internal/handlers/auth.go(login submit)internal/handlers/profile.go(password change)internal/handlers/source_management.go— six sites: source create submit, source edit submit,applyWebhookEdit, entrypoint create, target create submit,processTargetCreateNone were kept. The
maxBodyShiftconstant inhandlers.gostays —webhook.gostill uses it formaxWebhookBodySize.Out of scope, untouched: the
/webhook/{uuid}receiver. It bounds itself viaio.LimitReader(r.Body, maxWebhookBodySize+1)inreadWebhookBody, is not CSRF-protected, and is not form-parsed.Tests
Middleware unit tests (
internal/middleware/middleware_test.go), all against a sentinelnexthandler that records whether it ran and how many bytes it read:GETis untouched (the cap is POST/PUT/PATCH-scoped)ContentLength == -1) reaches the sentinel but the read errors at exactly the cap — the fallback path, pinned so it cannot silently become unboundedRoute-level tests (
internal/server/routes_test.go, new) run against the real router produced bySetupRoutes, not a hand-rebuilt chain, so they guard the registration order itself. A newinternal/server/export_test.goexposes a helper that builds aServerwith a testMiddleware/Handlersand returns the configured router; the dependency graph is wired withfxtestthe same wayinternal/handlers/handlers_test.godoes.POST /pages/loginwith an accurate oversizedContent-Lengthreturns 413 and no_gorilla_csrfcookie is set — gorilla/csrf issues its cookie whenever it runs, so the cookie's absence is positive evidence that CSRF, and therefore the handler, never executed.POST /pages/loginunder the limit without a token returns 403 and does set the_gorilla_csrfcookie. This is the control for the test above: without it, the missing-cookie assertion would prove nothing.POST /pages/loginunder the limit with a token harvested from the rendered login form still reaches the handler (401 plus "Invalid username or password"), proving the reorder did not break CSRF token handling.POST /user/{username}/passwordwith a valid session, a valid CSRF token from the rendered profile page, and an oversized body returns 413 and the stored password hash is unchanged — an observable side effect, not just a status code, proving the handler was not reached on the route that previously had no cap.POST /user/{username}/passwordunder the limit still succeeds and changes the hash, proving the newly added middleware did not break the route it guards.I verified the route-level tests actually bite: temporarily restoring the old
CSRF-before-MaxBodySizeorder in the/pagesgroup makesTestPagesLogin_OversizeBody_RejectedBeforeCSRFfail on both assertions (403 instead of 413, and a CSRF cookie present), while the two under-limit tests keep passing.One test-only wrinkle worth noting for the reviewer:
html/templateescapes+as+in attribute values and gorilla/csrf tokens are standard base64, so a token scraped out of the rendered markup must be run throughhtml.UnescapeStringbefore it is submitted. Without that the valid-token cases fail with "CSRF token invalid".Docs
README.md: the middleware section now lists/user/*among the capped groups and explains the ordering requirement and the two enforcement paths; the Security bullet notes the cap runs before CSRF parses the form.TODO.md: updated in the same commit as the code. The entry went to the top of Completed Steps; the Next Step (event retention cleanup) is unrelated to this issue and was left in place.Verification
make fmtrun;make fmt-checkclean.make testgreen across all packages.script/cibuild(docker build .) green end to end — this is the authoritative gate, since it runs the Dockerfile lint stage with the hash-pinned golangci-lint v2.12.2 plusmake testandmake build.make lintreports one finding,G704(gosec) atinternal/delivery/client_ssrf_test.go:78, in a file this PR does not touch. I confirmed it is pre-existing by stashing the branch and re-running lint on the clean baseline — identical single finding. The host linter is newer than the pinned CI one; the pinned linter inscript/cibuilddoes not report it..golangci.ymluntouched (sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb), golangci-lint pin in theDockerfileuntouched.chi runs Use middleware in registration order, and every form route group registered CSRF() before MaxBodySize(). gorilla/csrf calls r.PostFormValue, so the form was parsed under net/http's 10 MB default and the intended 1 MB cap never applied to form fields. The /user/{username} group, which carries POST /password, had no MaxBodySize registration at all. - Register MaxBodySize ahead of CSRF in /pages, /sources, and /source/{sourceID}, and add it to /user/{username}. - Reject a declared-oversize body up front with 413. Reordering alone cannot produce one: http.MaxBytesReader surfaces its error on Read, so the form parse fails and gorilla/csrf answers 403 "no token" for what is really an oversized body. MaxBytesReader is still installed afterwards so chunked or length-lying clients stay hard-capped. - Drop the handler-local MaxBytesReader calls in auth.go, profile.go, and source_management.go now that the middleware is the single enforcement point. maxBodyShift stays; webhook.go still uses it. The /webhook/{uuid} receiver is untouched: it bounds itself with io.LimitReader in readWebhookBody and is neither CSRF-protected nor form-parsed. Tests cover the middleware in isolation (declared oversize is rejected without reaching a sentinel handler; at-limit and under-limit bodies pass through intact; GET is unaffected; an undeclared oversize body is truncated at the cap) and the real router built by SetupRoutes, so the registration order itself is guarded: an oversized POST to /pages/login returns 413 with no gorilla/csrf cookie issued, an oversized POST /password with a valid session and CSRF token returns 413 and leaves the stored hash unchanged, and under-limit requests still complete through the normal CSRF path.What I built
One commit,
08c9c1a, onissue-90-body-limit-before-csrfoffmain@4f5ecb1.Ordering.
MaxBodySize(maxFormBodySize)is now registered ahead ofCSRF()in all four form route groups —/pages,/sources,/source/{sourceID}, and/user/{username}. The last of those had no cap registered at all, which is whyPOST /passwordwas completely unbounded at the middleware layer. Each group has a comment explaining the constraint.Real 413s.
MaxBodySizenow rejects aPOST/PUT/PATCHwhoser.ContentLengthexceeds the limit before callingnext. Reordering alone could not do this:http.MaxBytesReaderonly errors onRead, so the oversized body would surface as gorilla/csrf's 403 "no token" instead.MaxBytesReaderis still installed afterwards so chunked and length-lying clients remain hard-capped, and the doc comment documents both paths.Cleanup. All eight handler-local
http.MaxBytesReadercalls removed (auth.go,profile.go, six insource_management.go) — none kept.maxBodyShiftstays becausewebhook.goneeds it. The/webhook/{uuid}receiver is untouched per the out-of-scope note.How I verified it
script/cibuildgreen end to end. That is the real gate: the Dockerfile lint stage runs the hash-pinned golangci-lint v2.12.2, thenmake testandmake build.make fmtrun,make fmt-checkclean,make testgreen in all nine packages.make lintshows oneG704gosec finding atinternal/delivery/client_ssrf_test.go:78, a file this PR does not touch. I stashed the branch and re-ran lint against the clean baseline: identical single finding, so it is pre-existing host-linter noise, and the pinned CI linter does not report it..golangci.ymlbyte-identical (sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb).Mutation check. I did not just assert that the new tests pass — I confirmed they fail on the bug. Temporarily restoring the old
CSRF-before-MaxBodySizeorder in/pagesmakesTestPagesLogin_OversizeBody_RejectedBeforeCSRFfail on both of its assertions (403 rather than 413, and a_gorilla_csrfcookie present), while the under-limit tests continue to pass. Then I reverted the mutation.The route-level tests run against the actual router from
SetupRoutesrather than a hand-rebuilt middleware chain, so they guard the registration order itself and cannot drift fromroutes.go. The "handler never reached" claim is backed by observable side effects, not just status codes: the absence of a_gorilla_csrfcookie on the login route (with an under-limit control test proving the cookie does appear when CSRF runs), and an unchanged stored password hash onPOST /password.One thing a reviewer may trip over if they extend these tests:
html/templateescapes+as+in attribute values, and gorilla/csrf tokens are standard base64, so a token scraped from rendered markup has to go throughhtml.UnescapeStringbefore submission. Without it the valid-token cases fail with "CSRF token invalid" — that cost me a debugging cycle.Review: PR #91 — verdict PASS
Reviewed at head
08c9c1aagainst base4f5ecb1(still the tip ofmain, so this merges cleanly with no rebase needed).No blocking findings. Five non-blocking nits below.
Spec conformance (issue #90 "Implementation requirements")
1. Ordering — verified by reading
internal/server/routes.goand byexecution.
MaxBodySize(maxFormBodySize)is the firstUsein allfour groups:
/pages(line 95),/user/{username}(113),/sources(128),
/source/{sourceID}(140). I enumerated every POST-acceptingroute in the file to check for bypasses:
/pageslogin sub-group (r.Groupat line 99, addingLoginRateLimit) inherits the parent group'sUsestack, soMaxBodySizestill runs first forPOST /pages/login.POST /pages/logout(105) sits directly on the parent group. Capped.r.With(s.mw.PasswordChangeRateLimit()).Post("/password", ...)(118-120) —
Withappends to the group stack rather than replacingit, so the cap still precedes CSRF. Confirmed by execution, see
mutation testing below.
r.Postregistrations in/source/{sourceID}(146-169) areall on the capped group.
/webhook/{uuid}(HandleFunc, 174), which is out of scope.2. The 413 path — verified by reading
internal/middleware/middleware.go:318-354.r.ContentLength > maxBytesis anint64 > int64comparison, so the unknown-lengthsentinel
-1falls through correctly rather than tripping therejection, and a body exactly at the limit is admitted (matching
TestMaxBodySize_AtLimit_PassesThrough). The unknown-length case isstill hard-capped:
http.MaxBytesReader(w, r.Body, maxBytes)isinstalled on line 349 on the fall-through path, and
TestMaxBodySize_UndeclaredOversize_TruncatedAtCappins that thehandler sees exactly
maxBytesand gets a read error, not anunbounded buffer. No sign or width mistakes.
3. Handler-local removals — verified by reading; each traced to a
covering group. All eight sites are gone and none is reachable from
an uncapped route:
auth.goHandleLoginSubmit→POST /pages/login;profile.goHandlePasswordChange→POST /user/{username}/password;source_management.goHandleSourceCreateSubmit→POST /sources/new,HandleSourceEditSubmit→POST /source/{sourceID}/edit,HandleEntrypointCreate→POST /source/{sourceID}/entrypoints,HandleTargetCreate→POST /source/{sourceID}/targets, plusapplyWebhookEditandprocessTargetCreate, which are internalhelpers called only from the two submit handlers above (and were
already dead code, since the caller had run
ParseFormbefore invokingthem).
maxBodyShiftis correctly retained inhandlers.go:26andstill consumed by
webhook.go:17.4. Tests — the "handler never reached" claim holds, verified by
execution. I did not take the PR body's mutation claim on trust; I
reproduced it in a throwaway worktree and reverted both mutations:
r.Use(s.mw.MaxBodySize(...))fromsetupUserRoutes(restoring
main's state) failsTestPasswordChange_OversizeBody_RejectedAndPasswordUnchangedonboth assertions — 413 vs. the actual response, and the stored
Argon2id hash changes. So the side-effect assertion is genuinely
load-bearing, not decorative. Worth noting because the obvious
objection — that a password-hashing input length limit would leave
the hash unchanged anyway and make the assertion vacuous — does not
apply here: this repo uses Argon2id, which has no bcrypt-style
72-byte input ceiling, so the oversized value really is persisted
when the cap is absent.
/pagesback toCSRFbeforeMaxBodySizefailsTestPagesLogin_OversizeBody_RejectedBeforeCSRFwithexpected: 413, actual: 403, while both under-limit tests keep passing.The
_gorilla_csrf-cookie-absence signal is properly controlled byTestPagesLogin_UnderLimit_NoToken_CSRFRejects, which asserts thecookie is issued on a 403 — without that control the absence
assertion would prove nothing. Running against the real router from
SetupRoutesrather than a hand-rebuilt chain is the right call; it iswhat makes the ordering itself testable.
5.
/webhook/{uuid}— verified by reading. Untouched by the diff.readWebhookBody(internal/handlers/webhook.go:135-164) still readsthrough
io.LimitReader(r.Body, maxWebhookBodySize+1)and returns 413when the result exceeds the limit. Correct out-of-scope handling.
Repo policy
.golangci.ymlunmodified — sha256 on the head tree is021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb,and
git diff origin/main..HEAD -- .golangci.yml Dockerfile script/bootstrapis empty, so the golangci-lint v2.12.2 pin isintact.
TODO.mdupdated in the same commit as the code. The Next Step(event retention cleanup) was correctly left in place, since this
work was issue-driven rather than the Next Step.
Enforce the body size limit before CSRF parses the form (closes #90)ends with the required(closes #N). Body iswrapped and explains the why.
commit message or the tree.
Verification runs
script/cibuild— exit 0 (executed).08c9c1a—check / check (push)success in 3m6s. It wasstill pending when I started; it has since gone green.
make checkon the head tree — all packages pass, including the fivenew
internal/serverroute tests and the five newinternal/middlewareMaxBodySizetests. It exits non-zero on asingle
G704(gosec) finding atinternal/delivery/client_ssrf_test.go:78. I did not take the PRbody's word for this being pre-existing: I ran
make lintin aseparate worktree on a clean
origin/mainand got the identicalsingle finding. It is host-linter-version skew (the host golangci is
newer than the pinned CI v2.12.2), untouched by this PR, and not
attributable to it.
make fmt-check— clean.make checkleft the tree unmodified(
git status --porcelainempty).Non-blocking nits
internal/middleware/middleware_test.go:435— the doc comment reads"
maxBodySizeHandlerwraps a sentinel handler..." but thedeclaration it sits on is
type maxBodySizeResult struct. There isno
maxBodySizeHandleridentifier anywhere; the comment appears todescribe
runMaxBodySize, which is the function immediately belowand is itself undocumented. Go doc comments must begin with the name
of the thing they document. Acceptable: retitle the comment to
maxBodySizeResult records what the sentinel handler observed...and move the wrapping description onto
runMaxBodySize.README.md(the new MaxBodySize paragraph, ~line 875) — "isanswered with
413 Request Entity Too Largebefore any othermiddleware or handler runs" is inaccurate as written. It sits
immediately after the enumerated list of eight global middlewares
(Recoverer, RequestID, SecurityHeaders, Logging, Metrics, CORS,
Timeout, Sentry), all of which do run before it. The middleware's own
doc comment gets this right ("neither CSRF nor the endpoint handler
runs"). Acceptable: scope the README claim the same way — "before any
other middleware in the route group, and before the handler".
Route-level ordering is only test-guarded for
/pagesand/user/{username}. That satisfies the spec, which asked for exactlythose two, but
/sourcesand/source/{sourceID}are guarded byreading alone and can silently regress if someone reorders the
Usecalls. Acceptable: a table-driven case over all four groups, each
asserting 413 plus no
_gorilla_csrfcookie, which would cost only afew lines given the helpers already present in
routes_test.go.MaxBodySizeis scoped to POST/PUT/PATCH, andTestMaxBodySize_GetWithOversizeBody_NotCappednow pins that asdeliberate. Since the handler-local readers are gone, the middleware
is the single enforcement point, so a GET or DELETE carrying a large
body to a form route has no cap at all. Not exploitable today — no
handler on those routes reads
r.Bodyon a non-POST method — and nota regression from
main, where the handler-local readers were alsoonly on POST paths. Acceptable: one sentence in the
MaxBodySizedoccomment recording that non-body methods are intentionally
unrestricted, so the gap is a decision rather than an oversight.
internal/server/export_test.go:27-32—NewRouterForTestbuilds&Server{...}field by field instead of going through theconstructor. If
SetupRouteslater reads aServerfield that thisliteral does not set (
sentryEnabledis already in that category),tests will silently exercise the zero value. The tradeoff is
documented in the function comment and is reasonable for avoiding the
fx lifecycle; flagging only so it is a known cost.
Manager note
Independent review verdict: PASS, no blocking findings. The reviewer did not author this change.
Two things I want to highlight because they raise my confidence beyond a read-only review:
MaxBodySizeregistration insetupUserRoutesfailsTestPasswordChange_OversizeBody_RejectedAndPasswordUnchangedon both assertions, and reverting the/pagesordering failsTestPagesLogin_OversizeBody_RejectedBeforeCSRFwithexpected: 413, actual: 403. So the tests genuinely pin the fix rather than passing incidentally.Also independently confirmed:
script/cibuildexit 0, Gitea CI green on08c9c1a,.golangci.ymlbyte-identical, base4f5ecb1still the tip ofmainso this merges without a rebase. The onegosecG704 finding a hostmake lintreports ininternal/delivery/client_ssrf_test.goreproduces on a cleanorigin/mainand is host-linter version skew, not this change.The five non-blocking nits are now tracked as #93 rather than round-tripping this PR — they are two documentation inaccuracies, two coverage/scope observations, and one test-harness tradeoff, none of which affect the correctness of the fix.
Labeled
merge-readyand assigned to @sneak for merge. Note for merge ordering: PR #92 (#80) also touchesTODO.md, so whichever of the two lands second will need a trivialTODO.mdrebase.Verification re-check: the green is real
A fleet-wide warning came in that
script/cibuildcan report a green it did not earn — it is a plaindocker build .with no cache control, and the Dockerfile doesCOPY . .thenRUN make check, so on an unchanged tree Docker serves the check layer from cache and the build exits 0 without running anything. Observed elsewhere as a SUCCESS in 0.262 seconds with every layerCACHED.This PR cites
script/cibuildexit 0 as its authoritative lint evidence, and in this repo that matters: the host golangci-lint (v2.10.1) disagrees with the pinned v2.12.2, so a cached layer would leave the pinned-linter result unproven.Re-checked against Gitea CI, which builds each commit on a runner independently of any local Docker cache:
08c9c1a—check / check (push): success in 3m6s (run 97)A cached build finishes in under a second. Three minutes is a genuine execution inside the pinned v2.12.2 image. The claim stands. No re-label, no pull-back.
All five currently merge-ready PRs were re-checked the same way and all have genuine multi-minute CI runs: #87 2m37s, #91 3m6s, #92 6m3s, #96 2m43s, #100 3m3s.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.