Enforce request body size limit before CSRF middleware parses the form #90
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Tracking issue from the PR #83 review (non-blocking there — pre-existing idiom shared with the login handler).
Problem
Handlers install
http.MaxBytesReader(1 MB) inside the handler, but gorilla/csrf (v1.7.3, helpers.go:113) has already parsed the request body viar.PostFormValueby the time the handler runs. The form is therefore parsed under net/http's default 10 MB cap, and the intended 1 MB limit is never effective for form fields. Affects at least the login and password-change handlers.Definition of done
MaxBytesReadercalls that are now redundant are removed or kept consistent deliberately (documented either way).Implementation requirements
Baseline:
main@4f5ecb1.Confirmed root cause
internal/server/routes.go. chi runsUsemiddleware in registration order, and every group registersCSRF()beforeMaxBodySize(maxFormBodySize):/pages—CSRF()line 93,MaxBodySizeline 95/sources—CSRF()line 118,MaxBodySizeline 121/source/{sourceID}—CSRF()line 128,MaxBodySizeline 131gorilla/csrf calls
r.PostFormValue(helpers.go:113), so the form is parsed under net/http's default 10 MB cap and the intended 1 MB limit never applies to form fields.Worse, and not noted in the original report: the
/user/{username}group (setupUserRoutes, lines 107-116) has noMaxBodySizeat all, and that is wherePOST /passwordfrom #83 lives. So the password-change endpoint currently has no middleware-level body cap whatsoever — only the handler-localhttp.MaxBytesReaderinprofile.go:35, which runs after CSRF has already parsed the body.1. Ordering
In every route group that accepts a POST,
MaxBodySize(maxFormBodySize)must be registered beforeCSRF(). Groups to fix:/pages,/sources,/source/{sourceID}. Groups to fix and add the missing middleware to:/user/{username}.2. Make 413 actually reachable
Reordering alone does not produce a 413.
http.MaxBytesReadersurfaces its error onRead, so CSRF's form parse just fails and gorilla/csrf answers 403 "no token" — the operator sees a misleading CSRF error for what is really an oversized body.So
MaxBodySizemust also reject up front: if the request is a POST/PUT/PATCH andr.ContentLengthis greater than the limit, write 413 and return without callingnext. Keep installinghttp.MaxBytesReaderas well, so a chunked or Content-Length-lying client is still hard-capped rather than buffered. Document that behaviour in the middleware doc comment: declared-oversize gets a clean 413, undeclared-oversize gets truncated at the cap and fails downstream.3. Redundant handler-local limits
These now sit behind a middleware cap and are dead weight:
internal/handlers/auth.go:33internal/handlers/profile.go:35internal/handlers/source_management.golines 131, 390, 413, 729, 789, 812Remove them and let the middleware be the single enforcement point — duplicated limits drift apart. If you keep any, the PR body must say which and why.
maxBodyShiftinhandlers.gois still used bywebhook.go, so do not delete the constant.Out of scope: the
/webhook/{uuid}receiver. It reads through its ownio.LimitReader(r.Body, maxWebhookBodySize+1)inreadWebhookBody, is not CSRF-protected, and is not form-parsed. Leave it alone.4. Tests
Content-Lengthto a form route returns 413 and the handler is never reached (assert with a sentinel handler or an observable side effect, not just the status code)./pagesgroup and thePOST /passwordroute in the/user/{username}group, since the latter had no cap at all.5. Docs
TODO.mdupdated in the same commit as the code.Definition of done
POST /passwordis covered.make checkgreen, via the repo's own entrypoints only (make check/script/cibuild) — never rawgo/golangci-lint..golangci.ymluntouched.(closes #90).Implementation plan
Branch
issue-90-body-limit-before-csrfoffmain@4f5ecb1, single commit titled ending in(closes #90).1.
internal/middleware/middleware.go— makeMaxBodySizeable to answer 413Rewrite the middleware body so that, for
POST/PUT/PATCH:r.ContentLength > maxBytes, log and write413 Request Entity Too Largeand return without callingnext. This is the only way to get a real 413:http.MaxBytesReadersurfaces its error onRead, at which point gorilla/csrf'sr.PostFormValuehas already swallowed it and answers403 Forbidden - invalid CSRF token, which is a misleading diagnosis for an oversized body.r.Bodyinhttp.MaxBytesReader(w, r.Body, maxBytes)afterwards, so a chunked request (ContentLength == -1) or a client that lies about itsContent-Lengthis hard-capped rather than buffered without bound.The doc comment will state both paths explicitly: declared-oversize gets a clean 413 and never reaches downstream middleware or the handler; undeclared-oversize is truncated at the cap and fails downstream (form parse error / CSRF rejection), which is a correctness-preserving fallback rather than a clean diagnostic.
2.
internal/server/routes.go— ordering per route groupMaxBodySize(maxFormBodySize)becomes the firstUsein every group that accepts a POST, ahead ofCSRF():/pages— move existing registration up/sources— move existing registration up/source/{sourceID}— move existing registration up/user/{username}— add it (currently has none at all, andPOST /passwordfrom #83 lives here)3. Remove the now-redundant handler-local caps
Delete the
http.MaxBytesReadercalls atinternal/handlers/auth.go:33,internal/handlers/profile.go:35, andinternal/handlers/source_management.golines 130, 389, 412, 728, 788, 811. The middleware becomes the single enforcement point. I do not plan to keep any of them; if a compile constraint forces one to stay I will say so in the PR body.maxBodyShiftinhandlers.gostays —webhook.gostill uses it formaxWebhookBodySize./webhook/{uuid}is untouched, per the out-of-scope note.4. Tests
Unit tests in
internal/middleware/middleware_test.goforMaxBodySize:nexthandler records that it was never callednextGETpasses through untouched (the cap is POST/PUT/PATCH-scoped)nextbut the read fails at the capRoute-level tests against the real router from
routes.go(not a hand-rebuilt chain, so the test actually guards the registration order): a newinternal/server/export_test.goexposes a helper that constructs aServerwith a testMiddleware/Handlersand returns the configuredchirouter, and a newinternal/server/routes_test.go(externalserver_testpackage) builds the dependency graph withfxtestthe same wayinternal/handlers/handlers_test.godoes. Cases:POST /pages/loginwith an accurate oversizedContent-Lengthreturns 413, and no_gorilla_csrfcookie is set on the response — proving gorilla/csrf never ran, therefore the handler never ranPOST /user/{username}/passwordwith an authenticated session, a valid CSRF token harvested from the rendered profile page, and an oversized body returns 413 and the stored password hash is unchanged — an observable side effect proving the handler was not reachedPOST /pages/loginunder the limit with a CSRF token harvested fromGET /pages/loginstill reaches the handler, proving the reorder did not break CSRF token handlingPOST /pages/loginunder the limit without a token still gets 403 from CSRF5. Docs
README.mddoes document request limits (the middleware list around line 872 and the Security bullet around line 894); both will be updated to include/user/*and the up-front 413 behaviour.TODO.mdupdated in the same commit per its Workflow section.Verification
make fmtthenmake checkonly — no rawgo/golangci-lint..golangci.ymluntouched.clawbot referenced this issue2026-08-11 14:48:08 +02:00