The event-log page renders stored bodies untruncated, so buffered rendering can hold ~25 MB per request #135

Closed
opened 2026-08-12 11:56:52 +02:00 by clawbot · 2 comments
Collaborator

Found in the independent review of #131. Not milestoned 1.0.0: the page is authenticated and the size is bounded and paginated, so by the tag's blocking rule it belongs to the next cycle. It is filed because #131 changed the memory profile of this page and that change should not go unrecorded.

templates/source_logs.html:39 renders {{.Body}} untruncated. With paginationPerPage = 25 (internal/handlers/handlers.go:31) and the 1 MB ingest cap (internal/handlers/webhook.go:17), one page can carry ~25 MB of raw bodies — before HTML escaping, which inflates adversarial payloads several-fold (html/template escapes + to + and similar).

Before #131 this streamed to the socket, so the peak was small. That PR buffers the whole page in memory to fix partial-page renders (#123) — the right trade, but it converts this page's size from a bandwidth cost into a resident-memory cost of up to tens of MB per concurrent viewer.

REPO_POLICIES.md:297 asks for a maximum response size where applicable, which this page arguably is.

Definition of done

  • The event-log page truncates each rendered body to a fixed cap (8 KB is a reasonable starting point) with a visible "truncated" marker, so page size is bounded by the cap rather than by stored payload size.
  • The full body remains retrievable — truncation is a rendering concern, not a data-loss one. Say in the PR where the untruncated value is still reachable, or file the follow-up if it is not.
  • A test asserting the cap holds for a stored body far above it.

Implementation requirements

  • Branch from next, PR based on next, single commit, title ending (closes #N).
  • Do not modify TODO.md (see #112).
  • Gate on make check plus the Docker lint path with the cache defeated (#119).
Found in the independent review of https://git.eeqj.de/sneak/webhooker/pulls/131. Not milestoned 1.0.0: the page is authenticated and the size is bounded and paginated, so by the tag's blocking rule it belongs to the next cycle. It is filed because https://git.eeqj.de/sneak/webhooker/pulls/131 changed the memory profile of this page and that change should not go unrecorded. `templates/source_logs.html:39` renders `{{.Body}}` untruncated. With `paginationPerPage = 25` (`internal/handlers/handlers.go:31`) and the 1 MB ingest cap (`internal/handlers/webhook.go:17`), one page can carry ~25 MB of raw bodies — before HTML escaping, which inflates adversarial payloads several-fold (`html/template` escapes `+` to `+` and similar). Before https://git.eeqj.de/sneak/webhooker/pulls/131 this streamed to the socket, so the peak was small. That PR buffers the whole page in memory to fix partial-page renders (https://git.eeqj.de/sneak/webhooker/issues/123) — the right trade, but it converts this page's size from a bandwidth cost into a resident-memory cost of up to tens of MB per concurrent viewer. `REPO_POLICIES.md:297` asks for a maximum response size where applicable, which this page arguably is. ## Definition of done - The event-log page truncates each rendered body to a fixed cap (8 KB is a reasonable starting point) with a visible "truncated" marker, so page size is bounded by the cap rather than by stored payload size. - The full body remains retrievable — truncation is a rendering concern, not a data-loss one. Say in the PR where the untruncated value is still reachable, or file the follow-up if it is not. - A test asserting the cap holds for a stored body far above it. ## Implementation requirements - Branch from `next`, PR based on `next`, single commit, title ending ` (closes #N)`. - Do not modify `TODO.md` (see https://git.eeqj.de/sneak/webhooker/issues/112). - Gate on `make check` plus the Docker lint path with the cache defeated (https://git.eeqj.de/sneak/webhooker/issues/119).
clawbot self-assigned this 2026-08-12 11:56:52 +02:00
Author
Collaborator

Plan: bound the body in the query layer, not the template.

loadEventsWithDeliveries will stop loading []database.Event and instead select a projection whose body column is substr(cast(body as blob), 1, 8192), plus length(cast(body as blob)) for the real size. The cast makes both byte-wise rather than character-wise, so the bound is bytes. A stored body over the cap therefore never becomes a Go string at all — the buffered render is bounded by cap x page size regardless of what was ingested, which template-side truncation would not achieve (it would still materialise the full string, and truncating post-escape would be worse still).

SQLite cuts at an arbitrary byte, so the tail can be a partial UTF-8 sequence. A helper walks back at most utf8.UTFMax bytes to the last utf8.RuneStart byte and drops that sequence if utf8.FullRune says it is incomplete; bytes that are merely invalid UTF-8 (a binary payload) are left alone.

Events become EventLogView alongside the existing DeliveryView/TargetView projections, carrying BodyTruncated and BodyBytes so templates/source_logs.html can show a visible marker with the real byte count.

Also corrects the now-false "these pages are small" comment at internal/handlers/handlers.go:232.

Plan: bound the body in the query layer, not the template. `loadEventsWithDeliveries` will stop loading `[]database.Event` and instead select a projection whose body column is `substr(cast(body as blob), 1, 8192)`, plus `length(cast(body as blob))` for the real size. The cast makes both byte-wise rather than character-wise, so the bound is bytes. A stored body over the cap therefore never becomes a Go string at all — the buffered render is bounded by cap x page size regardless of what was ingested, which template-side truncation would not achieve (it would still materialise the full string, and truncating post-escape would be worse still). SQLite cuts at an arbitrary byte, so the tail can be a partial UTF-8 sequence. A helper walks back at most `utf8.UTFMax` bytes to the last `utf8.RuneStart` byte and drops that sequence if `utf8.FullRune` says it is incomplete; bytes that are merely invalid UTF-8 (a binary payload) are left alone. Events become `EventLogView` alongside the existing `DeliveryView`/`TargetView` projections, carrying `BodyTruncated` and `BodyBytes` so `templates/source_logs.html` can show a visible marker with the real byte count. Also corrects the now-false "these pages are small" comment at `internal/handlers/handlers.go:232`.
clawbot added this to the 1.0.0 milestone 2026-08-17 22:32:39 +02:00
Author
Collaborator

Moved INTO the 1.0.0 milestone, reversing the "Not milestoned" line in the body above.

Reason the original call no longer holds: "the page is authenticated" bounds who TRIGGERS the render, not who SUPPLIES the payload. The bodies come from the unauthenticated public receiver, so an attacker fills a page with 25 x 1 MB bodies for free and the operator detonates it by opening the event log — several times that resident after escaping inflation, per concurrent viewer. Remote attacker-controlled memory exhaustion with an ordinary admin action as the trigger is on the wrong side of the 1.0 bar, and REPO_POLICIES.md:297 already asks for a bounded response size here.

The other half is timing: when this was filed, #123 had not landed and renderTemplate still streamed to the socket, so the exposure was hypothetical. 123 is now on next (0b457ea), so it is live.

The plan in the comment above is approved as written — bound it in the query layer via substr(cast(body as blob), 1, 8192) rather than in the template, so an oversized body never becomes a Go string. Template-side truncation would still materialise the full value and miss the point.

Moved INTO the `1.0.0` milestone, reversing the "Not milestoned" line in the body above. Reason the original call no longer holds: "the page is authenticated" bounds who TRIGGERS the render, not who SUPPLIES the payload. The bodies come from the unauthenticated public receiver, so an attacker fills a page with 25 x 1 MB bodies for free and the operator detonates it by opening the event log — several times that resident after escaping inflation, per concurrent viewer. Remote attacker-controlled memory exhaustion with an ordinary admin action as the trigger is on the wrong side of the 1.0 bar, and `REPO_POLICIES.md:297` already asks for a bounded response size here. The other half is timing: when this was filed, https://git.eeqj.de/sneak/webhooker/issues/123 had not landed and `renderTemplate` still streamed to the socket, so the exposure was hypothetical. `123` is now on `next` (`0b457ea`), so it is live. The plan in the comment above is approved as written — bound it in the query layer via `substr(cast(body as blob), 1, 8192)` rather than in the template, so an oversized body never becomes a Go string. Template-side truncation would still materialise the full value and miss the point.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#135