fix(backend): rate-limit and cap report ingest, drop wildcard CORS (closes #20) #63

Merged
clawbot merged 1 commits from fix/bound-report-endpoint into next 2026-09-29 04:22:20 +02:00
Collaborator

Bounds POST /api/v1/reports without authentication, per #20 as amended in #20 (comment) and #20 (comment).

  • Rate limit: go-chi/httprate, keyed on the client address TRUSTED_PROXIES resolves, allows REPORTS_PER_MINUTE (default 60) reports a minute, then 429 with Retry-After. The minute slides, so only half the limit is sure to pass: 30 tabs behind one address, at one report a minute each, are never refused.
  • Size cap: reportbuf refuses a report that would take the report files past DATA_DIR_MAX_BYTES (default 1 GiB), counting files already in DATA_DIR and unwritten reports at their uncompressed size; the handler answers 507.
  • CORS: no CORS headers unless CORS_ALLOWED_ORIGINS lists origins.
  • Refusals send the usual {"status":"error"} body. A limit that is not a positive number, or an origin that is not a plain scheme://host[:port] (* included), stops startup.

Not visible in the diff: go-chi/cors treats an empty origin list as "allow every origin", so that case never reaches it, and reads a * anywhere in an entry as a wildcard. httprate always sends Retry-After: 60 and adds X-RateLimit-* headers. Deleting report files frees room only at the next start; pruning is #54.

Disclosures:

  • Deviation: go.mod and go.sum were edited by hand from the Go checksum database, not by go mod tidy; golang.org/x/sys moves to v0.30.0, which httprate requires.
  • Judgement call: listed origins may use only GET, POST and Content-Type.
  • Limitation: IPv6 clients are limited per address, not per /64.
  • A failed flush leaves its reports counted uncompressed until restart.

Model: opus-5-5

Bounds `POST /api/v1/reports` without authentication, per https://git.eeqj.de/sneak/netwatch/issues/20 as amended in https://git.eeqj.de/sneak/netwatch/issues/20#issuecomment-96550 and https://git.eeqj.de/sneak/netwatch/issues/20#issuecomment-104575. - Rate limit: `go-chi/httprate`, keyed on the client address `TRUSTED_PROXIES` resolves, allows `REPORTS_PER_MINUTE` (default 60) reports a minute, then 429 with `Retry-After`. The minute slides, so only half the limit is sure to pass: 30 tabs behind one address, at one report a minute each, are never refused. - Size cap: `reportbuf` refuses a report that would take the report files past `DATA_DIR_MAX_BYTES` (default 1 GiB), counting files already in `DATA_DIR` and unwritten reports at their uncompressed size; the handler answers 507. - CORS: no CORS headers unless `CORS_ALLOWED_ORIGINS` lists origins. - Refusals send the usual `{"status":"error"}` body. A limit that is not a positive number, or an origin that is not a plain `scheme://host[:port]` (`*` included), stops startup. Not visible in the diff: `go-chi/cors` treats an empty origin list as "allow every origin", so that case never reaches it, and reads a `*` anywhere in an entry as a wildcard. httprate always sends `Retry-After: 60` and adds `X-RateLimit-*` headers. Deleting report files frees room only at the next start; pruning is https://git.eeqj.de/sneak/netwatch/issues/54. Disclosures: - Deviation: `go.mod` and `go.sum` were edited by hand from the Go checksum database, not by `go mod tidy`; `golang.org/x/sys` moves to v0.30.0, which httprate requires. - Judgement call: listed origins may use only `GET`, `POST` and `Content-Type`. - Limitation: IPv6 clients are limited per address, not per /64. - A failed flush leaves its reports counted uncompressed until restart. Model: opus-5-5
clawbot added the needs-review label 2026-09-29 02:22:52 +02:00
clawbot self-assigned this 2026-09-29 02:22:52 +02:00
clawbot added needs-rework and removed needs-review labels 2026-09-29 02:39:46 +02:00
Author
Collaborator

Before review: the Go package defaults list, sneak's record of library decisions, names github.com/go-chi/httprate for HTTP rate limiting. The issue and the dispatch brief named golang.org/x/time/rate; that was wrong. Switch the rate limit to httprate, keyed on the client address the trusted-proxy logic resolves, keeping the defaults, the 429 with Retry-After, and the {"status":"error"} body; drop the hand-made bucket cleanup it replaces.

Model: opus-5-5

Before review: the Go package defaults list, sneak's record of library decisions, names `github.com/go-chi/httprate` for HTTP rate limiting. The issue and the dispatch brief named `golang.org/x/time/rate`; that was wrong. Switch the rate limit to `httprate`, keyed on the client address the trusted-proxy logic resolves, keeping the defaults, the 429 with `Retry-After`, and the `{"status":"error"}` body; drop the hand-made bucket cleanup it replaces. Model: opus-5-5
clawbot force-pushed fix/bound-report-endpoint from 2bf52ba7ff to 9f4663cedb 2026-09-29 03:13:42 +02:00 Compare
clawbot added needs-review and removed needs-rework labels 2026-09-29 03:13:48 +02:00
Author
Collaborator
  • #63 (comment): done; RateLimit is now httprate.LimitBy keyed on the address clientIP resolves, and the hand-made buckets, their cleanup test and golang.org/x/time are gone.
  • Consequence: httprate's minute slides, so the default of 60 is sure to pass 30 tabs behind one address rather than 60; backend/README.md and the PR body say so.

Model: opus-5-5

- https://git.eeqj.de/sneak/netwatch/pulls/63#issuecomment-104895: done; `RateLimit` is now `httprate.LimitBy` keyed on the address `clientIP` resolves, and the hand-made buckets, their cleanup test and `golang.org/x/time` are gone. - Consequence: httprate's minute slides, so the default of 60 is sure to pass 30 tabs behind one address rather than 60; `backend/README.md` and the PR body say so. Model: opus-5-5
Author
Collaborator

FAIL (needs-rework):

  1. backend/internal/reportbuf/reportbuf_test.go:188, TestWrittenReportsCountAtFileSize: it shows written reports take less room after a flush, but no test shows they still take any. Nothing proves that report files written while the server runs keep counting toward DATA_DIR_MAX_BYTES. That is the cap's main job. Acceptable: a test that appends and calls Flush repeatedly under a small cap and requires ErrFull once the written files fill it.
  2. backend/internal/reportbuf/reportbuf_test.go: no test sends reports at the same moment against the cap, which is the case the single locked check-and-count in Append (reportbuf.go:126) exists for. Acceptable: a test that appends from many goroutines at once, with room for exactly N reports, and requires exactly N to succeed.
  3. backend/internal/middleware/middleware_test.go:373, TestRateLimitIsPerForwardedClient: it covers only clients behind a trusted proxy. Nothing shows that a peer that is not a trusted proxy can't get a fresh allowance by writing its own X-Forwarded-For, which is what stops the limit from being dodged. Acceptable: a case where such a peer sends a different X-Forwarded-For on each request and is refused once its allowance is spent.
  4. backend/internal/server/routes_test.go: nothing checks that CORS_ALLOWED_ORIGINS reaches the router. The CORS tests pass origins to the middleware directly. Acceptable: a route-level test like TestReportsAreRateLimited that sets CORS_ALLOWED_ORIGINS and checks that a preflight from the listed origin gets Access-Control-Allow-Origin.
  5. backend/internal/config/config.go:102: CORS_ALLOWED_ORIGINS takes any text. * gives every origin Access-Control-Allow-Origin: *, the wildcard that #20 removes. An entry that is not an origin (no scheme, or a path such as a trailing /) still starts up, then silently matches no page. Acceptable: each entry must be a plain origin, scheme://host with an optional port. Anything else, * included, stops start-up with an error naming the setting, the way a malformed TRUSTED_PROXIES entry does, and backend/README.md says so.
  6. backend/README.md:83: "Most the report files in DATA_DIR may total" is missing a word. Acceptable: for example, "Largest total size of the report files in DATA_DIR".

Model: opus-5-5

FAIL (needs-rework): 1. `backend/internal/reportbuf/reportbuf_test.go:188`, `TestWrittenReportsCountAtFileSize`: it shows written reports take less room after a flush, but no test shows they still take any. Nothing proves that report files written while the server runs keep counting toward `DATA_DIR_MAX_BYTES`. That is the cap's main job. Acceptable: a test that appends and calls `Flush` repeatedly under a small cap and requires `ErrFull` once the written files fill it. 2. `backend/internal/reportbuf/reportbuf_test.go`: no test sends reports at the same moment against the cap, which is the case the single locked check-and-count in `Append` (`reportbuf.go:126`) exists for. Acceptable: a test that appends from many goroutines at once, with room for exactly N reports, and requires exactly N to succeed. 3. `backend/internal/middleware/middleware_test.go:373`, `TestRateLimitIsPerForwardedClient`: it covers only clients behind a trusted proxy. Nothing shows that a peer that is not a trusted proxy can't get a fresh allowance by writing its own `X-Forwarded-For`, which is what stops the limit from being dodged. Acceptable: a case where such a peer sends a different `X-Forwarded-For` on each request and is refused once its allowance is spent. 4. `backend/internal/server/routes_test.go`: nothing checks that `CORS_ALLOWED_ORIGINS` reaches the router. The CORS tests pass origins to the middleware directly. Acceptable: a route-level test like `TestReportsAreRateLimited` that sets `CORS_ALLOWED_ORIGINS` and checks that a preflight from the listed origin gets `Access-Control-Allow-Origin`. 5. `backend/internal/config/config.go:102`: `CORS_ALLOWED_ORIGINS` takes any text. `*` gives every origin `Access-Control-Allow-Origin: *`, the wildcard that https://git.eeqj.de/sneak/netwatch/issues/20 removes. An entry that is not an origin (no scheme, or a path such as a trailing `/`) still starts up, then silently matches no page. Acceptable: each entry must be a plain origin, `scheme://host` with an optional port. Anything else, `*` included, stops start-up with an error naming the setting, the way a malformed `TRUSTED_PROXIES` entry does, and `backend/README.md` says so. 6. `backend/README.md:83`: "Most the report files in `DATA_DIR` may total" is missing a word. Acceptable: for example, "Largest total size of the report files in `DATA_DIR`". Model: opus-5-5
clawbot added needs-rework and removed needs-review labels 2026-09-29 03:39:24 +02:00
clawbot added 1 commit 2026-09-29 03:54:16 +02:00
POST /api/v1/reports stays unauthenticated but is bounded. Each client
address, as the trusted-proxy logic resolves it, may send
REPORTS_PER_MINUTE reports a minute (default 60, counted by
go-chi/httprate over a sliding minute); past that it gets 429 with
Retry-After. reportbuf refuses a report that would take the report
files past DATA_DIR_MAX_BYTES (default 1 GiB), counting the files
already in DATA_DIR and unwritten reports at their uncompressed size;
the handler answers 507. CORS adds nothing unless CORS_ALLOWED_ORIGINS
lists origins. A limit that is not a positive number, or an origin
that is not a plain scheme://host[:port], stops the server from
starting.

Model: opus-5-5
clawbot force-pushed fix/bound-report-endpoint from 9f4663cedb to f426513c21 2026-09-29 03:54:16 +02:00 Compare
clawbot added needs-review and removed needs-rework labels 2026-09-29 03:54:22 +02:00
Author
Collaborator

Rework of #63 (comment):

  1. Added TestWrittenReportsKeepCounting: it writes one report file after another under a small cap and requires ErrFull exactly when the files on disk leave no room for the next report.
  2. Added TestConcurrentAppendsStopAtCap: many goroutines append at once with room for exactly five reports, and exactly five must be taken.
  3. Added TestRateLimitIgnoresForwardedForFromUntrustedPeer; it shares a new request helper with TestRateLimitIsPerForwardedClient.
  4. Added TestCORSAllowedOriginsReachTheRouter in routes_test.go.
  5. config.New now refuses any CORS_ALLOWED_ORIGINS entry that is not exactly scheme://host[:port], or that contains *, with an error naming the setting; backend/README.md says so, and TestCORSAllowedOriginsMustBeOrigins covers it.
  6. Reworded as suggested.

Deviation: TestWrittenReportsCountAtFileSize, added earlier in this PR, failed now and then when its shutdown write landed in the same millisecond as its first report file (#61); it now pauses 1 ms between the two, as the new test 1 does.

Partially verified: test 2 fails when the cap check is moved out of the lock, but not reliably when the check and the count sit in two separate locked sections, because the goroutine that unlocks nearly always takes the lock straight back.

Model: opus-5-5

Rework of https://git.eeqj.de/sneak/netwatch/pulls/63#issuecomment-105150: 1. Added `TestWrittenReportsKeepCounting`: it writes one report file after another under a small cap and requires `ErrFull` exactly when the files on disk leave no room for the next report. 2. Added `TestConcurrentAppendsStopAtCap`: many goroutines append at once with room for exactly five reports, and exactly five must be taken. 3. Added `TestRateLimitIgnoresForwardedForFromUntrustedPeer`; it shares a new request helper with `TestRateLimitIsPerForwardedClient`. 4. Added `TestCORSAllowedOriginsReachTheRouter` in `routes_test.go`. 5. `config.New` now refuses any `CORS_ALLOWED_ORIGINS` entry that is not exactly `scheme://host[:port]`, or that contains `*`, with an error naming the setting; `backend/README.md` says so, and `TestCORSAllowedOriginsMustBeOrigins` covers it. 6. Reworded as suggested. Deviation: `TestWrittenReportsCountAtFileSize`, added earlier in this PR, failed now and then when its shutdown write landed in the same millisecond as its first report file (https://git.eeqj.de/sneak/netwatch/issues/61); it now pauses 1 ms between the two, as the new test 1 does. Partially verified: test 2 fails when the cap check is moved out of the lock, but not reliably when the check and the count sit in two separate locked sections, because the goroutine that unlocks nearly always takes the lock straight back. Model: opus-5-5
Author
Collaborator

PASS: all six findings from the last review are fixed, and the change meets the definition of done of #20 as amended, with the rate limit on go-chi/httprate as corrected.

Model: opus-5-5

PASS: all six findings from the last review are fixed, and the change meets the definition of done of https://git.eeqj.de/sneak/netwatch/issues/20 as amended, with the rate limit on `go-chi/httprate` as corrected. Model: opus-5-5
clawbot merged commit ea66caf338 into next 2026-09-29 04:22:20 +02:00
clawbot deleted branch fix/bound-report-endpoint 2026-09-29 04:22:20 +02:00
clawbot removed the needs-review label 2026-09-29 04:22:20 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/netwatch#63