Four defects in POST /api/v1/reports and the surrounding handler layer. Verified on main at fbfe1df.
1. Returns 200 {"status":"ok"} when the report was NOT stored
backend/internal/handlers/report.go:70-80:
bufErr:=s.buf.Append(rpt)ifbufErr!=nil{s.log.Error("failed to buffer report","error",bufErr)}s.respondJSON(w,r,&response{Status:"ok"},http.StatusOK)
The error is logged and then discarded. The client is told its report was accepted when it was dropped. Any client-side retry logic is defeated, and the failure is invisible to everything except whoever is reading server logs.
The same pattern exists on the shutdown path: internal/reportbuf/reportbuf.go:78-83's OnStop ignores the result of the final flush, and writeFile (reportbuf.go:161) only logs. A failed final flush loses data silently and the process still exits 0. (The lifecycle half of that is tracked in #22; the error-propagation half belongs here.)
2. Oversize bodies return 400, not 413
report.go:39-56: http.MaxBytesReader is correctly applied, but the decode error path never distinguishes a *http.MaxBytesError from malformed JSON. Both produce 400 Bad Request. A client that sent a valid but too-large report gets told its JSON is broken. CODE_STYLEGUIDE_GO.md shows the intended http.StatusRequestEntityTooLarge behaviour.
3. Body size limit is per-route, not global
Policy requires a maximum request body size "enforced on all endpoints". MaxBytesReader appears only in report.go. There is no body-limit middleware in internal/middleware/middleware.go and none registered in routes.go. The healthcheck route and every future route accept unbounded input.
4. Raw untrusted request body is logged at Info level
report.go:62-68 logs "geo", string(rpt.Geo) — the raw, unvalidated, attacker-controlled json.RawMessage declared at report.go:25, up to the full 1 MiB body cap, on every request. client_id and timestamp are logged verbatim too.
This is a log-injection and log-volume amplification vector: an attacker controls both the content and the size of what lands in the log pipeline, at 1 MiB per request, with no authentication (#20).
Also in scope — trivial, same files
No decodeJSON helper.GO_HTTP_SERVER_CONVENTIONS.md defines decodeJSON alongside respondJSON as a base handler helper. handlers.go:59-73 defines only respondJSON; report.go:44 decodes inline.
chi middleware.Recoverer writes panic stack traces to stderr as unstructured plain text, bypassing slog. The client correctly gets a bare 500 with no body — that part is fine — but the stack should go through the structured logger.
Not defects, do not regress them: the client-facing error body is already clean ({"status":"error"}, no internals leaked), and Content-Type is correctly set before WriteHeader in handlers.go:65-66.
Definition of done
A storage failure results in a non-2xx response. The client is never told "ok" for a report that was not persisted.
Oversize bodies return 413; malformed JSON returns 400. Distinguish via errors.As on *http.MaxBytesError.
A body-size-limit middleware is applied to all routes, with the per-route limit remaining configurable where a route needs a different bound.
The raw geo blob is no longer logged. If geo data must be observable, log a bounded, validated projection of it (for example a length, or specific parsed fields), never the raw attacker-controlled bytes. Apply the same reasoning to client_id: bound its logged length.
Error responses still leak nothing internal — no paths, no buffer state, no sizes.
decodeJSON helper added to handlers.go and used by report.go.
Panic output from Recoverer is routed through slog as structured JSON rather than unstructured stderr text.
Tests cover each behaviour change: storage failure -> non-2xx; oversize -> 413; malformed -> 400; oversize on a non-report route is also rejected. Use httptest.
cd backend && make check passes; root make check passes.
TODO.md updated in the same commit.
Commit title ends with (closes #N).
Implementation requirements
Follow GO_HTTP_SERVER_CONVENTIONS.md for the middleware and handler-helper shapes, and CODE_STYLEGUIDE_GO.md for error handling.
Do not change the CORS posture, add authentication, or add rate limiting here — that is #20.
Do not change the shutdown lifecycle here — that is #22. This issue covers only the error-propagation aspect of the write path.
Choosing the failure status code for item 1 is a judgement call; state your choice and reasoning in the PR description.
make targets only; never raw go invocations.
No attribution trailers in the commit message.
## Problem
Four defects in `POST /api/v1/reports` and the surrounding handler layer. Verified on `main` at `fbfe1df`.
### 1. Returns `200 {"status":"ok"}` when the report was NOT stored
`backend/internal/handlers/report.go:70-80`:
```go
bufErr := s.buf.Append(rpt)
if bufErr != nil {
s.log.Error("failed to buffer report", "error", bufErr)
}
s.respondJSON(w, r, &response{Status: "ok"}, http.StatusOK)
```
The error is logged and then **discarded**. The client is told its report was accepted when it was dropped. Any client-side retry logic is defeated, and the failure is invisible to everything except whoever is reading server logs.
The same pattern exists on the shutdown path: `internal/reportbuf/reportbuf.go:78-83`'s `OnStop` ignores the result of the final flush, and `writeFile` (`reportbuf.go:161`) only logs. A failed final flush loses data silently and the process still exits `0`. (The lifecycle half of that is tracked in #22; the error-propagation half belongs here.)
### 2. Oversize bodies return `400`, not `413`
`report.go:39-56`: `http.MaxBytesReader` is correctly applied, but the decode error path never distinguishes a `*http.MaxBytesError` from malformed JSON. Both produce `400 Bad Request`. A client that sent a valid but too-large report gets told its JSON is broken. `CODE_STYLEGUIDE_GO.md` shows the intended `http.StatusRequestEntityTooLarge` behaviour.
### 3. Body size limit is per-route, not global
Policy requires a maximum request body size "enforced **on all endpoints**". `MaxBytesReader` appears only in `report.go`. There is no body-limit middleware in `internal/middleware/middleware.go` and none registered in `routes.go`. The healthcheck route and every future route accept unbounded input.
### 4. Raw untrusted request body is logged at Info level
`report.go:62-68` logs `"geo", string(rpt.Geo)` — the raw, unvalidated, attacker-controlled `json.RawMessage` declared at `report.go:25`, up to the full 1 MiB body cap, on every request. `client_id` and `timestamp` are logged verbatim too.
This is a log-injection and log-volume amplification vector: an attacker controls both the content and the size of what lands in the log pipeline, at 1 MiB per request, with no authentication (#20).
### Also in scope — trivial, same files
- **No `decodeJSON` helper.** `GO_HTTP_SERVER_CONVENTIONS.md` defines `decodeJSON` alongside `respondJSON` as a base handler helper. `handlers.go:59-73` defines only `respondJSON`; `report.go:44` decodes inline.
- **`chi middleware.Recoverer` writes panic stack traces to stderr as unstructured plain text**, bypassing slog. The client correctly gets a bare 500 with no body — that part is fine — but the stack should go through the structured logger.
Not defects, do not regress them: the client-facing error body is already clean (`{"status":"error"}`, no internals leaked), and `Content-Type` is correctly set before `WriteHeader` in `handlers.go:65-66`.
## Definition of done
- [ ] A storage failure results in a non-2xx response. The client is never told "ok" for a report that was not persisted.
- [ ] Oversize bodies return `413`; malformed JSON returns `400`. Distinguish via `errors.As` on `*http.MaxBytesError`.
- [ ] A body-size-limit middleware is applied to all routes, with the per-route limit remaining configurable where a route needs a different bound.
- [ ] The raw `geo` blob is no longer logged. If geo data must be observable, log a bounded, validated projection of it (for example a length, or specific parsed fields), never the raw attacker-controlled bytes. Apply the same reasoning to `client_id`: bound its logged length.
- [ ] Error responses still leak nothing internal — no paths, no buffer state, no sizes.
- [ ] `decodeJSON` helper added to `handlers.go` and used by `report.go`.
- [ ] Panic output from `Recoverer` is routed through slog as structured JSON rather than unstructured stderr text.
- [ ] Tests cover each behaviour change: storage failure -> non-2xx; oversize -> 413; malformed -> 400; oversize on a non-report route is also rejected. Use `httptest`.
- [ ] `cd backend && make check` passes; root `make check` passes.
- [ ] `TODO.md` updated in the same commit.
- [ ] Commit title ends with ` (closes #N)`.
## Implementation requirements
- Follow `GO_HTTP_SERVER_CONVENTIONS.md` for the middleware and handler-helper shapes, and `CODE_STYLEGUIDE_GO.md` for error handling.
- Do not change the CORS posture, add authentication, or add rate limiting here — that is #20.
- Do not change the shutdown lifecycle here — that is #22. This issue covers only the error-propagation aspect of the write path.
- Choosing the failure status code for item 1 is a judgement call; state your choice and reasoning in the PR description.
- `make` targets only; never raw `go` invocations.
- No attribution trailers in the commit message.
clawbot
added this to the 1.0.0 milestone 2026-08-09 03:42:45 +02:00
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.
Problem
Four defects in
POST /api/v1/reportsand the surrounding handler layer. Verified onmainatfbfe1df.1. Returns
200 {"status":"ok"}when the report was NOT storedbackend/internal/handlers/report.go:70-80:The error is logged and then discarded. The client is told its report was accepted when it was dropped. Any client-side retry logic is defeated, and the failure is invisible to everything except whoever is reading server logs.
The same pattern exists on the shutdown path:
internal/reportbuf/reportbuf.go:78-83'sOnStopignores the result of the final flush, andwriteFile(reportbuf.go:161) only logs. A failed final flush loses data silently and the process still exits0. (The lifecycle half of that is tracked in #22; the error-propagation half belongs here.)2. Oversize bodies return
400, not413report.go:39-56:http.MaxBytesReaderis correctly applied, but the decode error path never distinguishes a*http.MaxBytesErrorfrom malformed JSON. Both produce400 Bad Request. A client that sent a valid but too-large report gets told its JSON is broken.CODE_STYLEGUIDE_GO.mdshows the intendedhttp.StatusRequestEntityTooLargebehaviour.3. Body size limit is per-route, not global
Policy requires a maximum request body size "enforced on all endpoints".
MaxBytesReaderappears only inreport.go. There is no body-limit middleware ininternal/middleware/middleware.goand none registered inroutes.go. The healthcheck route and every future route accept unbounded input.4. Raw untrusted request body is logged at Info level
report.go:62-68logs"geo", string(rpt.Geo)— the raw, unvalidated, attacker-controlledjson.RawMessagedeclared atreport.go:25, up to the full 1 MiB body cap, on every request.client_idandtimestampare logged verbatim too.This is a log-injection and log-volume amplification vector: an attacker controls both the content and the size of what lands in the log pipeline, at 1 MiB per request, with no authentication (#20).
Also in scope — trivial, same files
decodeJSONhelper.GO_HTTP_SERVER_CONVENTIONS.mddefinesdecodeJSONalongsiderespondJSONas a base handler helper.handlers.go:59-73defines onlyrespondJSON;report.go:44decodes inline.chi middleware.Recovererwrites panic stack traces to stderr as unstructured plain text, bypassing slog. The client correctly gets a bare 500 with no body — that part is fine — but the stack should go through the structured logger.Not defects, do not regress them: the client-facing error body is already clean (
{"status":"error"}, no internals leaked), andContent-Typeis correctly set beforeWriteHeaderinhandlers.go:65-66.Definition of done
413; malformed JSON returns400. Distinguish viaerrors.Ason*http.MaxBytesError.geoblob is no longer logged. If geo data must be observable, log a bounded, validated projection of it (for example a length, or specific parsed fields), never the raw attacker-controlled bytes. Apply the same reasoning toclient_id: bound its logged length.decodeJSONhelper added tohandlers.goand used byreport.go.Recovereris routed through slog as structured JSON rather than unstructured stderr text.httptest.cd backend && make checkpasses; rootmake checkpasses.TODO.mdupdated in the same commit.(closes #N).Implementation requirements
GO_HTTP_SERVER_CONVENTIONS.mdfor the middleware and handler-helper shapes, andCODE_STYLEGUIDE_GO.mdfor error handling.maketargets only; never rawgoinvocations.