Skip to content

Harden live-session security: returnUrl, WS origin, reconnect race, session cap - #34

Merged
pal-tamas merged 10 commits into
mainfrom
security/live-session-hardening
Jun 10, 2026
Merged

Harden live-session security: returnUrl, WS origin, reconnect race, session cap#34
pal-tamas merged 10 commits into
mainfrom
security/live-session-hardening

Conversation

@pal-tamas

Copy link
Copy Markdown
Owner

Context

Output of a full-solution code review. A parallel agent sweep produced ~50 raw findings; a verification pass against the source filtered out the false positives (e.g. a claimed "Critical" keyed-diff bug that the code's own comment shows doesn't exist; an EditContext "CTS race" that's a non-issue under single-threaded dispatch; "XSS via Raw" which is by-design Blazor MarkupString parity). The Roslyn generators audited clean. This PR fixes the findings that survived verification.

Changes

# Fix Severity
1 SanitizeReturnUrl open-redirect — also reject backslash (/\evil.com, \evil.com) and control-char return URLs that browsers normalise into protocol-relative redirects (mirrors ASP.NET Url.IsLocalUrl). Medium
2 AttachSocket dedup-baseline race — the dedup baselines (_lastSentBuffer/_lastSentHtml) were written from a possibly-background thread while RenderAndSendAsync read/swapped them under _renderLock. A reconnect now sets a volatile _forceResend flag consumed inside the render lock, removing the unsynchronised cross-thread access. Low–Med
3 WebSocket Origin check (CSWSH)/rask/ws rejects cross-origin handshakes (403), reusing the existing host-only IsSameOrigin helper already used by the redeem endpoint. Medium
4 Trust model documenteddocs/authentication.md now spells out that the sessionId is a bearer secret and the hello handler rebinds session identity (Blazor-circuit parity). doc
5 Session cap (DoS) — opt-in RaskLiveOptions.MaxSessions; an over-cap GET returns 503 + Retry-After. Default 0 = unlimited, so no behaviour change unless configured. Low–Med

Two course-corrections during implementation: (a) #3 was refactored to reuse the existing IsSameOrigin helper — its host-only semantics are deliberate (TLS-terminating proxies), so a stricter scheme+port check would have regressed legitimate handshakes; (b) the cap is a per-store instance value, not a global static, so concurrent hosts/tests don't interfere.

Tests

pal-tamas added 10 commits June 9, 2026 15:36
…ession cap

Verified findings from a full-solution review (false positives filtered out):

- SanitizeReturnUrl: reject backslash ("/\evil.com", "\evil.com") and control-char
  return URLs that browsers normalise into protocol-relative redirects. Mirrors
  ASP.NET Url.IsLocalUrl.
- LiveSession.AttachSocket: stop writing the dedup baselines (_lastSentBuffer/
  _lastSentHtml) from a possibly-background thread. A reconnect now sets a volatile
  _forceResend flag that RenderAndSendAsync consumes inside _renderLock, removing the
  unsynchronised cross-thread field access.
- WebSocket endpoint: reject cross-origin handshakes (CSWSH) via the existing host-only
  IsSameOrigin check used by the redeem endpoint.
- RaskLiveOptions.MaxSessions: opt-in cap on concurrent live sessions; an over-cap GET
  returns 503 + Retry-After. Default 0 = unlimited (no behaviour change).

Docs: document the sessionId trust model, the WS origin check, and the session cap in
docs/authentication.md.

Tests: SanitizeReturnUrlTests, WebSocketOriginTests, SessionCapTests. Full non-E2E
suite green; auth + session-lifecycle E2E (13) green.
… re-auth on dispatch, returnUrl parity

- H1: emit Cache-Control: no-store on the authenticated GET shell so the
  embedded session id (the de-facto WS/upload/download bearer) can't be cached
  by a shared proxy/bfcache and replayed by another principal.
- H3: gate the upload and download endpoints (id-addressed, antiforgery-exempt)
  with IsSameOrigin + a session-owner check, so a leaked sessionId can't be used
  cross-origin or by a different signed-in user. Origin check runs before the
  one-shot download TryTake so a rejected attempt doesn't consume the entry.
- M2: re-evaluate the route authorization guard for the current principal before
  invoking a WS event handler; a user whose access was revoked mid-session now
  gets a challenge redirect instead of one free handler invocation.
- H4: introduce shared Rask.Core.Routing.LocalUrl.Sanitize (single source of
  truth for the IsLocalUrl rule); server SanitizeReturnUrl now delegates to it,
  and the WASM login flows (rask-wasm/rask-wasm-hosted templates + WasmCookie/
  WasmJwt samples) sanitize returnUrl before Navigate, closing the open-redirect
  parity gap with the server.

Tests: RootGetEndpointTests (no-store), UploadDownloadEndpointTests (cross-origin
rejected, entry not consumed), RevokedAuthDispatchTests (handler skipped +
redirect after revocation), Core LocalUrlTests. Server 155/155, Core 1436/1437.
…al route registrations

- M8: a component property named with a C# keyword via a verbatim identifier
  (e.g. `public string? @event`) emitted invalid C# in the generated factory
  (`string? event = null`, `__c.event = event`) because ISymbol.Name strips the
  leading '@'. Add ComponentFactoryGenerator.EscapeIdentifier + PropInfo.Escaped
  and use it at every emitted identifier site (signature params, object
  initializer, property accesses, param refs); local names like `__old_<name>`
  keep the raw Name (a valid identifier even for keywords). No-op for ordinary
  names, so the common path is byte-identical. (The rare keyword-prop-on-
  [FactoryGeneric] combo is left as-is — no regression, no real-world instance.)
- M9: a partial routed page with attributes on more than one declaration (e.g.
  [Route] on one part, [Obsolete]/a source-gen attribute on another) produced
  one Candidate per attributed declaration, emitting duplicate RouteRegistration
  entries (competing Route nodes for the same page) + duplicate
  [DynamicDependency]. Emit the registry from byFqn (deduped per FQN) instead of
  the raw candidate list.

Tests: KeywordIdentifierAndPartialRouteTests — M8 verified by COMPILING the
generated source (GeneratorRun.GeneratedCompileErrors), M9 by registration count.
Generator suite 132/132; full solution build clean.
…/session ids

- M1: the multi-fragment WS receive loop appended into an ArrayBufferWriter with
  no cap, so a client could stream an unbounded fragmented frame and force the
  server to buffer it whole before JsonDocument.Parse (per-socket memory DoS).
  Only the single-fragment path was bounded (16KB receive buffer). Add an
  8MB MaxInboundFrameBytes cap (test-tunable) to the reassembly loop and
  ws.Abort() past it. Client→server frames are small; uploads use HTTP.
- M5: redeem tickets (authority to set the auth cookie) and live-session ids
  (WS/upload/download bearer) were Guid.NewGuid() — only ~122 random bits and no
  contractual cryptographic-strength guarantee. New SecureToken.Create() uses
  RandomNumberGenerator.GetHexString (128 bits, same 32-lowercase-hex shape as
  Guid "N", a drop-in). Both sites call it.

M4 (require Origin on the WS handshake) assessed and DECLINED: a cross-origin
browser CSWSH attack always sends Origin (already rejected); the absent-Origin
path can't be driven by a browser carrying the victim's cookie, the session is
also gated by the unguessable sessionId, and ~38/40 existing WS connections (and
non-browser tooling) connect without Origin by documented design. Marginal
benefit, real breakage.

Tests: WebSocketFrameSizeTests, SecureTokenTests. Server suite 159/159.
…download sink

- M10: WasmLiveSession subscribed to IUserProvider.Changed in its ctor but never
  unsubscribed in Dispose, leaving an asymmetric teardown — a Changed raised
  after dispose would fire OnUserChanged on the disposed _lock. Not a live leak
  today (one session per page, Dispose uncalled in production, provider shares
  the session lifetime), but the implemented Dispose should be correct: store
  _userProvider and `-= OnUserChanged` first in Dispose.
- M12: WasmDownloadSink only removed token entries on Pull, so an orphaned stage
  (a second Stage before the first is consumed, a coalesced render, or a token
  the browser never pulls after navigating away) leaked its byte[] for the whole
  page lifetime, unbounded. Add a FIFO cap (16) evicting the oldest on Stage;
  the normal one-stage-one-pull flow is untouched.

M6 (EditContext timers/CTS) assessed and found to be a NON-ISSUE: the StickyTimer
is one-shot (self-clears ~200ms) and the per-field CTS is disposed in every
validation path (sync finally, async supersession, async-completion finally), so
there is no persistent leak — only a bounded ~200ms post-unmount hold. A
structural IDisposable change would add complexity for no benefit.

M11 (IJSObjectReference map never released) is real but low-reachability and a
shared Server+WASM design limitation; deferred to a dedicated unified change.

Tests: DisposalTests (provider unsubscribe; download-sink bound + round-trip).
WASM suite 46/46.
…UseAuthorization

- M13: the rask-server and rask-wasm-hosted Host templates scaffolded a cookie
  with only Name/LoginPath set (default SecurePolicy=SameAsRequest) and no
  transport security, over an http-only launch profile — so the auth cookie went
  out without the Secure flag. Set Cookie.SecurePolicy=Always + SameSite=Lax,
  add UseHttpsRedirection() and UseHsts() (guarded out of Development, outside the
  //#if (auth) block so HTTPS applies regardless), and switch launchSettings to
  HTTPS-primary (added one for the Host, which had none) so the Secure cookie and
  redirect work in development.
- M14: the hosted Host omitted app.UseAuthorization(), so a [Authorize] /
  RequireAuthorization() a consumer adds would be silently unenforced. Added it.
  The /api/login CSRF concern is mitigated by the now-SameSite=Lax auth cookie;
  a full antiforgery system is intentionally not added (it needs client-side
  token plumbing and is unusual for a JSON SPA login) — documented in a comment.

Templates are not compiled in-repo (placeholder package ref + Compile Remove),
so changes were verified by inspection, standard-API reasoning, JSON validation,
and a full solution build. The cookie hardening stays inside //#if (auth); the
no-auth scaffold is unaffected.
… race

- Context.Has<T>() now marks the caller a context consumer (like Get<T>()), so a
  component that gates purely on Has bypasses the render cache and re-runs when
  the provider re-renders — previously it stayed cached and showed stale UI when
  the value appeared/changed.
- Navigator.EnterHandler() resets the pending-navigation state (_dirty/_replace)
  on entry, so a handler that queued a Navigate(...) and then threw before
  TryConsumeHistory ran can't leak that navigation (and its replace flag) into
  the next dispatch and fire a nav the user never triggered. Reset on entry (not
  scope dispose) so the established consume-after-dispose pattern still works.
- HandlerSyncContext.Post() now schedules and records the task under one lock, so
  DrainAsync can't snapshot _pending in the gap between Task.Run and the Add and
  return before a just-posted render completes.

Tests: ContextTests.HasGate_MarksConsumer_*, NavigatorTests.UnconsumedNavigation_*.
Core suite 1438/1439.

Also triaged as NON-ISSUES (no change): the one-time Context.Get consumer latch
(permanent latch is necessary — a skipped component can't be known to read
context without executing it); Virtualize _activeFetch (WASM single-threaded,
Server swap under the session lock, reference-field access atomic, CTS
Dispose/IsCancellationRequested safe post-dispose); the WS navigate handler
(self-navigation, no escalation, JSON-escaped); the coalesce-loop dropped render
(already logged; auth/history sent on the first render); async lifecycle-hook
faults after an await (the terminal ContinueWith checks IsFaulted BEFORE the
PostFired short-circuit, and MoveNext captures the throw — faults ARE reported).
- Generator now URL-encodes the query-param KEY at generation time (the value was
  already encoded at runtime), so an explicit [QueryParam("a b&c")] or a name with
  '&'/'=' can't emit a malformed query string. No-op for property-name-derived
  keys (valid identifiers).
- Rask.Example.Auth.WasmJwt.Host fails fast when Jwt:Key is unset outside
  Development instead of silently falling back to the public, forgeable
  JwtIssuer.DevKey. Dev convenience (DevKey) is preserved in Development.

Tests: RoutesGeneratorTests.QueryParam_NameWithSpecialChars_*; sample compiles.
Generator suite 133/133.
The fail-fast guard in WasmJwt.Host (no Jwt:Key outside Development) crashed
the E2E host on startup, since the fixture runs every host with
DOTNET_ENVIRONMENT=Production. Add an overridable ExtraEnvironment hook to
ExampleAppFixture and have WasmJwtAuthAppFixture supply a test-only signing
key, keeping the production hardening intact.
@pal-tamas
pal-tamas enabled auto-merge (squash) June 10, 2026 08:33
@pal-tamas
pal-tamas merged commit 42a57dd into main Jun 10, 2026
5 checks passed
@pal-tamas
pal-tamas deleted the security/live-session-hardening branch June 10, 2026 14:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant