fix(proxy): reject declared-empty OTLP bodies before auth#129
Merged
Conversation
A Content-Length: 0 OTLP export carries no records and is already a permanent 400, but only after the auth middleware hashes the key, reads the api_keys row, and writes last_used_at — a DB read and write on every request. A client looping empty exports can therefore drive real per-request work at high volume, saturate the shared ingest fleet's CPU until health checks fail, and cause the load balancer to return 5xx to every tenant. Add a pre-auth guard on /v1/* that returns the same 400 the body-capture path does, before any key hashing or DB access, for requests that declare a zero-length body. Only Content-Length: 0 is fast-rejected; a chunked empty body still falls through to the existing EmptyBodyError path. Registered after cors() so the 400 keeps its CORS headers. New superlog.proxy.ingest.empty_body_rejected counter makes the rejected rate visible per signal.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit fd1b305. Configure here.
…0 message Address review feedback on the pre-auth empty-body guard: - Test-key smoke probes regressed: the guard 400'd a declared-empty POST before the auth middleware's SUPERLOG_TEST / superlog_test_* short-circuit could answer it with a 200, and the outcome depended on whether the probe sent a Content-Length. Extract a shared isTestKey() helper (used by both the guard and auth so they can't drift) and skip the fast-reject for test keys — the check is a cheap header/prefix read, no DB, so it doesn't reopen the flood cost. Empty test probes now behave the same regardless of framing. - The no-body path in forward() still hardcoded the empty-body 400 string; point it at the shared EMPTY_BODY_ERROR_MESSAGE so all three empty-body responses stay identical.
Contributor
Author
|
Both Bugbot findings addressed in 4beb69c:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

An OTLP HTTP export with a zero-length body carries no records. The proxy
already returns a permanent
400for it — but only after the auth middlewarehashes the API key, reads the
api_keysrow, and writeslast_used_at. That isa DB read and write on every request, before the body is even inspected.
A client looping empty exports therefore drives real per-request work at high
volume. Enough of it saturates the shared ingest fleet's CPU until health
checks start failing; with no healthy targets the load balancer returns
5xxto every tenant, not just the misbehaving one. We saw exactly this shape:
one project sent millions of empty-body requests in a tight loop and tipped the
whole ingest path over.
Change
empty-body-guard.ts:isDeclaredEmptyBody(contentLength)— true onlywhen
Content-Lengthis present and provably zero. Absent / non-numericvalues fall through to the normal path (we only fast-reject a body we can
cheaply prove is empty).
/v1/*middleware registered before auth returns the same400(shared
EMPTY_BODY_ERROR_MESSAGE) for a declared-emptyPOST, before anykey hashing or DB access. Registered after
cors()so the400keeps itsCORS headers, and
cors()still answersOPTIONSpreflights itself.superlog.proxy.ingest.empty_body_rejectedcounter (by signal) so therejected rate is visible.
Only
Content-Length: 0is fast-rejected. A chunked request with no declaredlength that turns out empty still gets a
400via the existing body-captureEmptyBodyErrorpath — just not cheaply. Real OTLP exporters setContent-Length, so that gap does not matter in practice.Test
TDD:
empty-body-guard.test.tscovers zero / non-zero / absent / whitespace /malformed. Full proxy suite green (84 tests), typecheck clean.
Note
Medium Risk
Touches the shared ingest hot path and middleware order on
/v1/*; behavior change is narrow (only declared-empty POSTs) but incorrect header parsing could wrongly pass or reject requests.Overview
Adds a pre-auth fast path on
/v1/*soPOSTrequests with a provably zeroContent-Lengthget the same permanent 400 as before, but without API key hashing,api_keyslookup, orlast_used_atupdates—reducing CPU/DB load when clients loop empty OTLP exports.New
empty-body-guard.tsexposesisDeclaredEmptyBody()(regex^\s*0+\s*$so values like0.5/0x0are not misclassified) and sharedEMPTY_BODY_ERROR_MESSAGE; the laterEmptyBodyErrorhandler now uses that constant. Chunked or unknown-length empty bodies still hit the existing body-capture path.Instrumentation:
superlog.proxy.ingest.empty_body_rejectedcounter by OTLP signal (no tenant—rejection happens before key resolution). Middleware runs after CORS so 400s keep CORS headers. Unit tests cover zero, non-zero, absent, whitespace, and malformedContent-Length.Reviewed by Cursor Bugbot for commit fd1b305. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Reject OTLP requests that declare an empty body (
Content-Length: 0) before auth to avoid DB work and protect ingest CPU from empty-export loops. Keeps the same 400 and now skips test keys so smoke probes still return 200./v1/*400s POSTs withContent-Length: 0, runs aftercors, skipping key hash and DB; chunked or unknown-length bodies still error later.SUPERLOG_TEST/superlog_test_*) via sharedisTestKey()so smoke probes still 200; auth uses the same helper.EMPTY_BODY_ERROR_MESSAGEacross all empty-body paths and newisDeclaredEmptyBody(); addedsuperlog.proxy.ingest.empty_body_rejectedcounter and tests for header parsing.Written for commit 4beb69c. Summary will update on new commits.