fix(backend): rate-limit and cap report ingest, drop wildcard CORS (closes #20)
check / check (push) Successful in 11s
check / check (push) Successful in 11s
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
This commit was merged in pull request #63.
This commit is contained in:
+45
-7
@@ -75,13 +75,16 @@ Internal packages in `internal/` follow standard Go project layout:
|
||||
|
||||
### Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------- | -------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `BIND_ADDRESS` | empty | IP address to listen on; empty listens on every interface |
|
||||
| `PORT` | `8080` | HTTP listen port |
|
||||
| `DATA_DIR` | `./data/reports` | Directory for compressed reports |
|
||||
| `DEBUG` | `false` | Enable debug logging |
|
||||
| `TRUSTED_PROXIES` | loopback + RFC1918 | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution |
|
||||
| Variable | Default | Description |
|
||||
| ---------------------- | -------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `BIND_ADDRESS` | empty | IP address to listen on; empty listens on every interface |
|
||||
| `PORT` | `8080` | HTTP listen port |
|
||||
| `DATA_DIR` | `./data/reports` | Directory for compressed reports |
|
||||
| `DATA_DIR_MAX_BYTES` | `1073741824` (1 GiB) | Largest total size of the report files in `DATA_DIR`; see [Report limits](#report-limits) |
|
||||
| `DEBUG` | `false` | Enable debug logging |
|
||||
| `TRUSTED_PROXIES` | loopback + RFC1918 | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution |
|
||||
| `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) |
|
||||
| `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) |
|
||||
|
||||
`TRUSTED_PROXIES` defaults to `127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`.
|
||||
The loopback entries cover the reverse proxy that shares the container; the
|
||||
@@ -103,6 +106,41 @@ Reports are written as `reports-<timestamp>.jsonl.zst` files in `DATA_DIR`.
|
||||
Each file contains one JSON object per line, compressed with zstd. Files are
|
||||
created with `O_EXCL` to prevent overwrites.
|
||||
|
||||
### Report limits
|
||||
|
||||
`POST /api/v1/reports` takes reports from anyone who can reach it, without
|
||||
credentials, so it is bounded instead. Both refusals below answer with the same
|
||||
`{"status":"error"}` body as any other error.
|
||||
|
||||
- **Rate limit.** Each client address, resolved through `TRUSTED_PROXIES`, may
|
||||
send `REPORTS_PER_MINUTE` reports a minute; past that it gets 429 with
|
||||
`Retry-After: 60`. The minute slides: reports from the minute before still
|
||||
count, fading out over the current one, so an address is sure never to be
|
||||
refused only while it sends at most half of `REPORTS_PER_MINUTE` in any 60
|
||||
seconds. The page sends one report a minute from each open tab, so the default
|
||||
of 60 refuses nothing from up to 30 tabs behind one address, such as a
|
||||
household or an office sharing it, however their reports bunch up. Report
|
||||
responses also carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
|
||||
`X-RateLimit-Reset` headers.
|
||||
- **Size cap.** The report files in `DATA_DIR` may total at most
|
||||
`DATA_DIR_MAX_BYTES`, counting the files already there at start. Reports
|
||||
waiting in memory count at their uncompressed size until they are written, so
|
||||
a report that would take the total past the cap is refused with 507, and
|
||||
nothing of it is stored. Deleting report files frees room only at the next
|
||||
start, when the files are counted again. The default of 1 GiB is small enough
|
||||
for any host; set it to the space you can give `DATA_DIR`.
|
||||
|
||||
### CORS
|
||||
|
||||
The page calls the API from the origin it is served from, so by default the
|
||||
server sends no CORS headers, and browsers let no other origin's pages call it.
|
||||
To serve the page from elsewhere, list that origin in `CORS_ALLOWED_ORIGINS`
|
||||
(for example `https://netwatch.example.com`); pages from a listed origin may
|
||||
`GET` and `POST` with a `Content-Type` header. Each entry must be a plain
|
||||
origin, `scheme://host` with an optional `:port`, as browsers send it: no path,
|
||||
not even a trailing `/`, and no `*`. Any other entry stops the server from
|
||||
starting, with an error naming `CORS_ALLOWED_ORIGINS`.
|
||||
|
||||
## TODO
|
||||
|
||||
- Add integration test that POSTs a report and verifies the compressed output
|
||||
|
||||
Reference in New Issue
Block a user