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 via r.PostFormValue by 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
The body size limit is applied as middleware BEFORE the CSRF middleware in the chain (route-group scoped), so form parsing happens under the intended cap.
Handler-local MaxBytesReader calls that are now redundant are removed or kept consistent deliberately (documented either way).
Test: an oversized POST body is rejected (413) and the handler is never reached.
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 via `r.PostFormValue` by 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
- The body size limit is applied as middleware BEFORE the CSRF middleware in the chain (route-group scoped), so form parsing happens under the intended cap.
- Handler-local `MaxBytesReader` calls that are now redundant are removed or kept consistent deliberately (documented either way).
- Test: an oversized POST body is rejected (413) and the handler is never reached.
internal/server/routes.go. chi runs Use middleware in registration order, and every group registers CSRF()beforeMaxBodySize(maxFormBodySize):
/pages — CSRF() line 93, MaxBodySize line 95
/sources — CSRF() line 118, MaxBodySize line 121
/source/{sourceID} — CSRF() line 128, MaxBodySize line 131
gorilla/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 noMaxBodySize at all, and that is where POST /password from #83 lives. So the password-change endpoint currently has no middleware-level body cap whatsoever — only the handler-local http.MaxBytesReader in profile.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 before CSRF(). 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.MaxBytesReader surfaces its error on Read, 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 MaxBodySize must also reject up front: if the request is a POST/PUT/PATCH and r.ContentLength is greater than the limit, write 413 and return without calling next. Keep installing http.MaxBytesReader as 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:
Remove 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. maxBodyShift in handlers.go is still used by webhook.go, so do not delete the constant.
Out of scope: the /webhook/{uuid} receiver. It reads through its own io.LimitReader(r.Body, maxWebhookBodySize+1) in readWebhookBody, is not CSRF-protected, and is not form-parsed. Leave it alone.
4. Tests
An oversized POST with an accurate Content-Length to 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).
A request at or under the limit still succeeds through the normal CSRF path — proves the reorder did not break CSRF token handling.
Cover at least one route from the /pages group and the POST /password route in the /user/{username} group, since the latter had no cap at all.
5. Docs
TODO.md updated in the same commit as the code.
README only if it documents request limits.
Definition of done
Body cap is enforced before any form parsing on every form route.
POST /password is covered.
make check green, via the repo's own entrypoints only (make check / script/cibuild) — never raw go/golangci-lint.
.golangci.yml untouched.
Single commit, title ending in (closes #90).
No attribution trailers.
## Implementation requirements
Baseline: `main` @ `4f5ecb1`.
### Confirmed root cause
`internal/server/routes.go`. chi runs `Use` middleware in registration order, and every group registers `CSRF()` **before** `MaxBodySize(maxFormBodySize)`:
- `/pages` — `CSRF()` line 93, `MaxBodySize` line 95
- `/sources` — `CSRF()` line 118, `MaxBodySize` line 121
- `/source/{sourceID}` — `CSRF()` line 128, `MaxBodySize` line 131
gorilla/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 **no** `MaxBodySize` at all, and that is where `POST /password` from #83 lives. So the password-change endpoint currently has no middleware-level body cap whatsoever — only the handler-local `http.MaxBytesReader` in `profile.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 before `CSRF()`. 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.MaxBytesReader` surfaces its error on `Read`, 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 `MaxBodySize` must also reject up front: if the request is a POST/PUT/PATCH and `r.ContentLength` is greater than the limit, write 413 and return without calling `next`. Keep installing `http.MaxBytesReader` as 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:33`
- `internal/handlers/profile.go:35`
- `internal/handlers/source_management.go` lines 131, 390, 413, 729, 789, 812
Remove 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. `maxBodyShift` in `handlers.go` is still used by `webhook.go`, so do not delete the constant.
**Out of scope:** the `/webhook/{uuid}` receiver. It reads through its own `io.LimitReader(r.Body, maxWebhookBodySize+1)` in `readWebhookBody`, is not CSRF-protected, and is not form-parsed. Leave it alone.
### 4. Tests
- An oversized POST with an accurate `Content-Length` to 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).
- A request at or under the limit still succeeds through the normal CSRF path — proves the reorder did not break CSRF token handling.
- Cover at least one route from the `/pages` group and the `POST /password` route in the `/user/{username}` group, since the latter had no cap at all.
### 5. Docs
- `TODO.md` updated in the **same commit** as the code.
- README only if it documents request limits.
### Definition of done
- Body cap is enforced before any form parsing on every form route.
- `POST /password` is covered.
- `make check` green, via the repo's own entrypoints only (`make check` / `script/cibuild`) — never raw `go`/`golangci-lint`.
- `.golangci.yml` untouched.
- Single commit, title ending in ` (closes #90)`.
- No attribution trailers.
Branch issue-90-body-limit-before-csrf off main @ 4f5ecb1, single commit titled ending in (closes #90).
1. internal/middleware/middleware.go — make MaxBodySize able to answer 413
Rewrite the middleware body so that, for POST/PUT/PATCH:
If r.ContentLength > maxBytes, log and write 413 Request Entity Too Large and return without calling next. This is the only way to get a real 413: http.MaxBytesReader surfaces its error on Read, at which point gorilla/csrf's r.PostFormValue has already swallowed it and answers 403 Forbidden - invalid CSRF token, which is a misleading diagnosis for an oversized body.
Still wrap r.Body in http.MaxBytesReader(w, r.Body, maxBytes) afterwards, so a chunked request (ContentLength == -1) or a client that lies about its Content-Length is 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 group
MaxBodySize(maxFormBodySize) becomes the firstUse in every group that accepts a POST, ahead of CSRF():
/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, and POST /password from #83 lives here)
3. Remove the now-redundant handler-local caps
Delete the http.MaxBytesReader calls at internal/handlers/auth.go:33, internal/handlers/profile.go:35, and internal/handlers/source_management.go lines 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. maxBodyShift in handlers.go stays — webhook.go still uses it for maxWebhookBodySize.
/webhook/{uuid} is untouched, per the out-of-scope note.
4. Tests
Unit tests in internal/middleware/middleware_test.go for MaxBodySize:
declared-oversize POST returns 413 and a sentinel next handler records that it was never called
at-limit and under-limit POSTs pass through and the body is fully readable by next
oversized GET passes through untouched (the cap is POST/PUT/PATCH-scoped)
chunked/undeclared oversize reaches next but the read fails at the cap
Route-level tests against the real router from routes.go (not a hand-rebuilt chain, so the test actually guards the registration order): a new internal/server/export_test.go exposes a helper that constructs a Server with a test Middleware/Handlers and returns the configured chi router, and a new internal/server/routes_test.go (external server_test package) builds the dependency graph with fxtest the same way internal/handlers/handlers_test.go does. Cases:
POST /pages/login with an accurate oversized Content-Length returns 413, and no _gorilla_csrf cookie is set on the response — proving gorilla/csrf never ran, therefore the handler never ran
POST /user/{username}/password with 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 reached
POST /pages/login under the limit with a CSRF token harvested from GET /pages/login still reaches the handler, proving the reorder did not break CSRF token handling
POST /pages/login under the limit without a token still gets 403 from CSRF
5. Docs
README.md does 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.md updated in the same commit per its Workflow section.
Verification
make fmt then make check only — no raw go/golangci-lint. .golangci.yml untouched.
## Implementation plan
Branch `issue-90-body-limit-before-csrf` off `main` @ `4f5ecb1`, single commit titled ending in ` (closes #90)`.
### 1. `internal/middleware/middleware.go` — make `MaxBodySize` able to answer 413
Rewrite the middleware body so that, for `POST`/`PUT`/`PATCH`:
1. If `r.ContentLength > maxBytes`, log and write `413 Request Entity Too Large` and return **without** calling `next`. This is the only way to get a real 413: `http.MaxBytesReader` surfaces its error on `Read`, at which point gorilla/csrf's `r.PostFormValue` has already swallowed it and answers `403 Forbidden - invalid CSRF token`, which is a misleading diagnosis for an oversized body.
2. Still wrap `r.Body` in `http.MaxBytesReader(w, r.Body, maxBytes)` afterwards, so a chunked request (`ContentLength == -1`) or a client that lies about its `Content-Length` is 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 group
`MaxBodySize(maxFormBodySize)` becomes the **first** `Use` in every group that accepts a POST, ahead of `CSRF()`:
- `/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, and `POST /password` from #83 lives here)
### 3. Remove the now-redundant handler-local caps
Delete the `http.MaxBytesReader` calls at `internal/handlers/auth.go:33`, `internal/handlers/profile.go:35`, and `internal/handlers/source_management.go` lines 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. `maxBodyShift` in `handlers.go` stays — `webhook.go` still uses it for `maxWebhookBodySize`.
`/webhook/{uuid}` is untouched, per the out-of-scope note.
### 4. Tests
Unit tests in `internal/middleware/middleware_test.go` for `MaxBodySize`:
- declared-oversize POST returns 413 and a sentinel `next` handler records that it was never called
- at-limit and under-limit POSTs pass through and the body is fully readable by `next`
- oversized `GET` passes through untouched (the cap is POST/PUT/PATCH-scoped)
- chunked/undeclared oversize reaches `next` but the read fails at the cap
Route-level tests against the **real** router from `routes.go` (not a hand-rebuilt chain, so the test actually guards the registration order): a new `internal/server/export_test.go` exposes a helper that constructs a `Server` with a test `Middleware`/`Handlers` and returns the configured `chi` router, and a new `internal/server/routes_test.go` (external `server_test` package) builds the dependency graph with `fxtest` the same way `internal/handlers/handlers_test.go` does. Cases:
- `POST /pages/login` with an accurate oversized `Content-Length` returns 413, and no `_gorilla_csrf` cookie is set on the response — proving gorilla/csrf never ran, therefore the handler never ran
- `POST /user/{username}/password` with 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 reached
- `POST /pages/login` under the limit with a CSRF token harvested from `GET /pages/login` still reaches the handler, proving the reorder did not break CSRF token handling
- `POST /pages/login` under the limit without a token still gets 403 from CSRF
### 5. Docs
- `README.md` does 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.md` updated in the same commit per its Workflow section.
### Verification
`make fmt` then `make check` only — no raw `go`/`golangci-lint`. `.golangci.yml` untouched.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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