Bound every slog line against client-chosen text (closes #176)
All checks were successful
check / check (push) Successful in 2m55s
All checks were successful
check / check (push) Successful in 2m55s
This commit was merged in pull request #180.
This commit is contained in:
111
README.md
111
README.md
@@ -994,15 +994,16 @@ costs; log volume it caps rather than eliminates. A path that names no
|
||||
entrypoint is recorded by the handler at `DEBUG`, and the aggregate
|
||||
limiter logs its own rejections at `DEBUG` and without the path, so
|
||||
neither appears at all under the default level. The per-entrypoint
|
||||
limiter is the loud one: it still logs every rejection at `WARN` with
|
||||
the request path, which on this route is attacker-controlled text. A
|
||||
client hammering a single invented path is served `RECEIVER_RATE_LIMIT`
|
||||
limiter is the loud one: it logs every rejection at `WARN` with the
|
||||
request path, which on this route is attacker-controlled text. A client
|
||||
hammering a single invented path is served `RECEIVER_RATE_LIMIT`
|
||||
requests and has the rest of its aggregate budget rejected there, so
|
||||
the aggregate limit is what bounds those `WARN` lines — to under ten
|
||||
times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 at the
|
||||
defaults, where before it there was no bound at all. The access log is
|
||||
bounded by neither limit: every request is recorded once at `INFO`,
|
||||
served or rejected alike.
|
||||
the aggregate limit is what bounds the _number_ of those `WARN` lines —
|
||||
to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080
|
||||
at the defaults, where before it there was no bound at all. Their
|
||||
_width_ is bounded by the field budgets below, the same ones the access
|
||||
log spends. The access log is bounded by neither limit: every request
|
||||
is recorded once at `INFO`, served or rejected alike.
|
||||
|
||||
What the access log does bound is the _content_ of those lines. A 3xx
|
||||
or 4xx response logs the chi route pattern — `/webhook/{uuid}`,
|
||||
@@ -1152,6 +1153,100 @@ that the rate is not bounded by the limits above on every route:
|
||||
`/.well-known/healthcheck` and `/s/*` sit behind no limiter, so there
|
||||
the multiplier is whatever the deployment will serve.
|
||||
|
||||
**The same ceiling covers every other line the service writes through
|
||||
`slog` that carries text an unauthenticated client supplies.** The
|
||||
access log is not the only line a client can put its own text into, and
|
||||
a budget that held for one line and not the others would be worse than
|
||||
no stated budget at all. Every `slog` call an unauthenticated request
|
||||
can reach spends the same per-field budget through `internal/logfield`,
|
||||
and each carries strictly fewer client-supplied fields than the access
|
||||
log does, so none of them can be wider than it:
|
||||
|
||||
| Log line | Level | Client-chosen value | Reachable unauthenticated |
|
||||
| ------------------------------------------ | ------- | ------------------- | ----------------------------------------- |
|
||||
| `request body exceeds limit` (413) | `WARN` | path, method | yes — `MaxBodySize` precedes `RequireAuth` |
|
||||
| `csrf: token validation failed` (403) | `WARN` | path, method | yes — `CSRF` precedes `RequireAuth` |
|
||||
| `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver |
|
||||
| `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition |
|
||||
| `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver |
|
||||
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the login form |
|
||||
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the login form |
|
||||
| `password verification capacity exhausted` | `WARN` | path | yes, on the login form |
|
||||
|
||||
`DEBUG` being off by default is not a bound. An operator turning it on
|
||||
to diagnose a flood must not thereby hand the flood an unbounded write,
|
||||
so those lines are capped too.
|
||||
|
||||
The last two rows are capped defensively rather than against a
|
||||
demonstrated width: chi routes `POST /pages/login` on a static pattern,
|
||||
so `r.URL.Path` there is the 12-byte constant `/pages/login` and each
|
||||
line lands near 120 bytes. `RecordLoginFailure` is nonetheless an
|
||||
exported method taking any `*http.Request`, and a future caller on a
|
||||
route with a URL parameter would widen the line. Since no request
|
||||
through the mux can, both caps are pinned by tests that call those two
|
||||
entry points directly with the path such a caller would supply.
|
||||
Removing either cap fails 14 subtests.
|
||||
|
||||
`internal/middleware/logbound_test.go` and
|
||||
`internal/handlers/logbound_test.go` drive 8 KB of client-chosen text
|
||||
at each of these — 1 KB at `invalid password`, whose accounts are
|
||||
shared with the successful-login line, where a username past 4 KB
|
||||
overflows the session cookie and answers 500 before that line is
|
||||
written — through both handlers, and through seven fills: plain text
|
||||
as the baseline, and then the quotation mark, backslash, tab, newline,
|
||||
C0 control and astral non-printable, six characters the wider of the
|
||||
two handlers spends more on than the client spent sending them. Every
|
||||
case holds each line to the 2,560-byte ceiling. That per-line ceiling
|
||||
is what the figure above states, and every row establishes it.
|
||||
|
||||
Three of the sites go further and bound the whole flood's output — the
|
||||
total bytes a run of distinct invented values wrote, which is the
|
||||
shape an operator sizing storage cares about. They are
|
||||
`request body exceeds limit`
|
||||
(`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`),
|
||||
`entrypoint not found` and `user not found` (the last two through
|
||||
`assertBoundedFlood`). The other rows carry no aggregate assertion;
|
||||
the per-line ceiling is what they establish.
|
||||
|
||||
`internal/logfield/logfield_test.go` measures the per-rune charge
|
||||
against what the handlers really emit, over roughly 3,000 code points on
|
||||
each, so an undercharged rune fails a test rather than quietly
|
||||
falsifying the ceiling.
|
||||
|
||||
What that ceiling does **not** cover, stated here so the figure is not
|
||||
read as more than it is:
|
||||
|
||||
- **Lines carrying an authenticated operator's own input**, which are
|
||||
not truncated at all. `webhook created` logs the submitted `name`
|
||||
verbatim and `target URL blocked by SSRF protection` logs the target
|
||||
host (both `internal/handlers/source_management.go`), as do the
|
||||
`target_name` lines in `internal/delivery/engine.go` and
|
||||
`internal/delivery/target_http.go`. The only bound on any of them is
|
||||
the 1 MB form body cap, so a 100 KB `name` writes a single line of
|
||||
roughly 600 KB — measured. This is deliberate: every one of these
|
||||
requires an authenticated operator on a service with no
|
||||
self-registration, and truncating the operator's own configuration
|
||||
echoed back would cost debuggability against no adversary. It does
|
||||
mean the 2,560-byte figure sizes unauthenticated traffic, not the
|
||||
operator's own administrative requests.
|
||||
- The **`log` delivery target**, which writes the whole inbound event —
|
||||
headers and body — to the log. This one is deliberate: capping it
|
||||
would defeat the target, since emitting the payload is the delivery.
|
||||
It costs nothing unless an authenticated operator creates a target of
|
||||
that type on a specific webhook, and each line it writes is bounded
|
||||
per event by the 1 MB receiver body cap. Adding one is a decision to
|
||||
spend log volume on that webhook's payloads.
|
||||
- **GORM's default logger**, which prints the fully interpolated SQL to
|
||||
stdout on every record-not-found — including the client-chosen path
|
||||
on `/webhook/{uuid}` and the submitted username on the login form.
|
||||
This one is not deliberate and not yet fixed; it does not go through
|
||||
`internal/logger` at all, so no level the operator sets and no budget
|
||||
above applies to it. Tracked at
|
||||
<https://git.eeqj.de/sneak/webhooker/issues/178>. Until it is fixed,
|
||||
an unauthenticated flood can still write text of its own choosing and
|
||||
its own length to the operator's stdout, and the ceiling above
|
||||
describes only the `slog` half of the picture.
|
||||
|
||||
Every limiter here — receiver, login, and password change — identifies
|
||||
the client the same way, through one shared key function: the
|
||||
connection's own address, unless the peer is listed in
|
||||
|
||||
Reference in New Issue
Block a user