Skip to content

fix: binder required semantics - #36

Merged
juicycleff merged 7 commits into
mainfrom
fix/binder-required-semantics
Jul 30, 2026
Merged

fix: binder required semantics#36
juicycleff merged 7 commits into
mainfrom
fix/binder-required-semantics

Conversation

@juicycleff

Copy link
Copy Markdown
Contributor

No description provided.

v1.1.2 adds ownValues tracking to the pooled http.Ctx: ShareValues now
records that the values map was borrowed, and Cleanup hands the context a
private map before returning it to the pool.

Without that, a context returned to the pool still aliasing another
context's values map lets the next unrelated request clear and overwrite
values belonging to a request still in flight. That map carries request
identity and tenant scope, so the failure mode is cross-request data
corruption rather than a stale read.
CORS wildcard subdomains were matched with strings.HasSuffix, so
"*.example.com" accepted any origin merely ending in "example.com" —
evil-example.com and notexample.com among them. Since getAllowOrigin
reflects the request origin back and this path is reachable with
AllowCredentials, an attacker could register a matching domain and read
authenticated responses. Origins are now parsed and matched on label
boundaries.

The rate limiter keyed on RemoteAddr, which Go sets to "IP:ephemeral-port".
A new port per connection meant a new bucket per connection: 100 of 100
requests from one IP were allowed against a burst of 3, and the bucket map
grew without bound under client control. Keying is now per client IP via
ClientIP, the map is capped, and RateLimitWithKey covers proxy and user-ID
keying. NewRateLimiter's cleanup goroutine also had no way to stop; added
Stop().

Timeout buffered the entire response with no cap and implemented neither
Flusher nor Hijacker, so streaming stalled until the handler returned,
WebSocket upgrades failed through it, and any large response was held in
memory. Buffering is now bounded and abandoned on flush or hijack, and the
timeout path no longer writes a second header over a committed response.

Recovery called logger.Error unguarded — a nil logger panicked inside the
deferred recover, killing the connection instead of returning the 500 it
exists to produce. It now guards the logger, logs debug.Stack() as its
comment always claimed, re-panics http.ErrAbortHandler, and skips writing
over an already-committed response.

Logging's SensitiveHeaders and IncludeHeaders were never read, so the
default redaction list read as protection that did not exist; the measured
duration was discarded too. GetRequestID always returned "" because nothing
ever stored the key it reads, and the inbound X-Request-ID was echoed and
logged unbounded.
A 5xx response body built from a wrapped error embeds whatever the error
carried — driver output, SQL fragments, file paths, internal hostnames — and
anyone able to trigger the error could read it.

Both paths leaked. The router's fallback put err.Error() straight into the
500 body, and InternalError produces an IHTTPError whose ResponseBody
embeds the wrapped text, which DefaultErrorHandler (the path real apps take,
since the app always installs one) serialized verbatim.

The redaction therefore lives in internal/shared, where both paths go
through it: 5xx bodies are replaced with a minimal envelope unless exposure
is enabled, 4xx bodies pass through untouched because validation messages
are exactly what the client needs to correct the request. The app enables
exposure outside production so local debugging is unaffected; detail always
reaches the logs either way.
Three defects that silently crossed request boundaries.

Group interceptor inheritance appended onto the group's own slice. When
that slice had spare capacity — three separate WithGroupInterceptor options
give len 3, cap 4 — each route wrote its interceptor into the group's
backing array, and cfg is shallow-copied into route.config and closed over
by applyMiddlewareAndInterceptors, so the aliasing outlived registration. A
route registered earlier silently inherited a later sibling's interceptor:
a route guarded by requireAuth ran publicOnly instead. Both the interceptor
and middleware slices are now cloned before appending.

shareContextValues aliases the lender's values map into the borrower for
O(1) propagation, but the borrower was then returned to the shared pool
still holding that alias. The next unrelated request drew it out and
NewContext's clear() wiped, then overwrote, the lender's map mid-flight —
and that map holds tenant scope (see forge.SetScope), making this a
tenant-isolation failure. The borrow is now released before pooling, using
the context's own ReleaseSharedValues where available (go-utils >= v1.1.2)
and falling back otherwise.

Parallel and ParallelAny handed the same context to N goroutines. The
context has no internal synchronization — Set is a bare map write and
Scope() an unsynchronized lazy init — so interceptors that touch it raced,
and an unsynchronized map write is a fatal runtime error recover() cannot
catch: it takes the process down rather than failing the request. The
documented Enrich pattern triggers it. Interceptors now receive a
syncContext sharing one guard.

The guard is detachable, which matters more than the mutex: ParallelAny
returns on first success and leaves goroutines running, and a lock makes
concurrent access safe without stopping a straggler from writing to a
context the handler already returned and the pool may have reassigned.

Each regression test was confirmed to fail against the pre-fix code.
WebSocket upgrades performed no Origin check. Browsers do not apply CORS to
upgrades but do send cookies with them, so any site a logged-in user visited
could open an authenticated socket as that user. WebTransport already had a
checker, but an empty allow-list meant "allow everything", leaving its
default open too.

Both now share one checker that defaults to same-origin. Requests with no
Origin are still allowed, since non-browser clients do not send one and
browsers always do; cross-origin clients are permitted explicitly via
WithWebSocketOrigins. Wildcard entries match on label boundaries, so
"*.example.com" does not accept evil-example.com.

SSE wrote "event: %s" and "data: %s" with no validation. A newline in the
event name or a multi-line payload terminates the field and lets the rest
parse as further SSE fields — event forgery wherever any part of the value
is user-influenced, and silent corruption of multi-line data regardless.
Event names and comments are now rejected if they contain a newline, and
data is encoded one "data: " line per line as the grammar requires.

Connection IDs came from time.Now().UnixNano(), which collides for upgrades
landing in the same clock tick; two live connections sharing an ID means
cross-talk wherever connections are tracked by ID, and the value was
trivially guessable. Now UUIDs.

WebSocket reads were unbounded, letting a peer request an arbitrarily large
allocation via the advertised frame length, and concurrent readers could
interleave partial frames. Reads are now capped and serialized.

SSE, WebSocket and WebTransport handlers never called Cleanup, the only
place scope.End() and MultipartForm.RemoveAll() run — so every long-lived
connection leaked its DI scope, and any that parsed a multipart form leaked
temp files, for the process lifetime. These are the longest-lived contexts
in the system, so this was the most consequential leak.

Also dropped the wildcard Access-Control-Allow-Origin on the AsyncAPI spec
endpoint, which made the channel and message map readable by any origin.
Nothing capped request bodies. Binding decoded straight from r.Body, so a
single unauthenticated request could drive allocation as large as the client
chose to send. MaxRequestBodySize now defaults to 10 MiB and is enforced via
MaxBytesReader before any handler, binder or middleware reads the body, with
WithMaxBodySize to raise it on upload routes or drop the limit.

WebSocketOrigins exposes the upgrade allow-list; unset means same-origin.

pprof had no authorization seam. Once enabled the endpoints dump heap and
goroutine state, let a caller pin a CPU for an arbitrary duration, and
/cmdline discloses the process argv — which commonly carries credentials
passed as flags. WithPprofGuard authorizes each request and 404s on denial
so the endpoints stay undiscoverable; enabling pprof without a guard now
logs a warning.

Internal error exposure is wired to Environment, on everywhere but
production.
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
forge Ready Ready Preview Jul 30, 2026 5:20pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 6 follow conventional format

@github-actions github-actions Bot added the fix label Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Conventional Commits Validation

PR Title: valid
Commits: all 7 follow conventional format

@github-actions github-actions Bot added fix and removed fix labels Jul 30, 2026
@juicycleff
juicycleff merged commit eb36114 into main Jul 30, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant