Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,9 @@ Define an IdentityResource/`role` claim and add it to the client's allowed scope
|---|---|
| **Redeem-ticket TTL** | 30s, fixed. The ticket is single-use, session-bound, 128-bit. |
| **CSRF on `/_rask/auth/redeem`** | The secret session-bound ticket defeats classic CSRF; the endpoint additionally **rejects cross-origin `Origin`/`Referer`** (HTTP 403). |
| **Session identity trust model** | A live session is keyed by an unguessable 128-bit id embedded in the page (same model as a Blazor circuit). The WS `hello` handler binds the session's user to the principal authenticated on that socket, so security rests on the **secrecy of the session id** — serve over HTTPS, don't log it or leak it via `Referer`. Cross-origin pages can't read it (same-origin policy). |
| **Cross-Site WebSocket Hijacking** | CORS doesn't apply to WS handshakes and the upgrade carries the auth cookie, so the `/rask/ws` endpoint **rejects a cross-origin handshake** (HTTP 403) using the same host-only same-origin check as redeem. Clients sending no `Origin` (non-browser) are allowed. |
| **Session-store growth (DoS)** | Each session pins a component tree + DI scope. Sessions are reclaimed shortly after their socket disconnects; set `RaskLiveOptions.MaxSessions` for a hard ceiling (a GET over the cap gets `503` + `Retry-After`). `0` = unlimited (default). Pair with a reverse-proxy rate limit. |
| **Sign-out invalidation** | Redeem clears the cookie; the WS reconnect re-seeds `SessionUserProvider` to anonymous. `SessionUserProvider.Clear()` is available for explicit invalidation. |
| **Session expiry → re-auth** | A swept live session pushes `{type:"session",status:"unknown"}`; `rask.js` reloads → fresh GET → route guard challenges to `ChallengePath?returnUrl=…`. |
| **JWT on WebSocket** | Token rides `?access_token=` on the WS URL via `window.Rask.authToken`; pair with `AddJwtBearer`'s `OnMessageReceived`. |
Expand All @@ -967,7 +970,10 @@ Define an IdentityResource/`role` claim and add it to the client's allowed scope
- ☑ If a token must be in the browser, store it **encrypted** (`ProtectedTokenStore`), never plaintext.
- ☑ Short JWT lifetime + app-driven silent refresh so sessions stay smooth without long-lived tokens.
- ☑ Keep `UseAuthentication()` **before** `UseRask()`.
- ☑ Validate redirect targets — Rask sanitizes the `returnUrl` to local paths.
- ☑ Validate redirect targets — Rask sanitizes the `returnUrl` to local same-origin paths (rejects `//`, `/\`, and backslash/control-char variants).
- ☑ Treat the **session id as a bearer secret** — HTTPS only, never logged or placed in URLs that leak via `Referer`.
- ☑ Behind a reverse proxy, wire **ForwardedHeaders** so the host-only same-origin checks (redeem + WS) see the public host.
- ☑ For untrusted-traffic hosts, set `RaskLiveOptions.MaxSessions` and a reverse-proxy rate limit to bound session creation.
- ☑ Rotate signing keys; manage the Data Protection key ring (persisted, encrypted at rest).

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ public async Task<bool> LoginAsync(string username, string password, string? ret
}

await users.RefreshAsync();
nav.Navigate(returnUrl ?? "/members");
// Open-redirect guard: never navigate off-origin from an attacker-supplied returnUrl
// (parity with the server's SanitizeReturnUrl). Unsafe values collapse to "/".
nav.Navigate(LocalUrl.Sanitize(returnUrl ?? "/members"));
return true;
}

Expand Down
15 changes: 13 additions & 2 deletions samples/Rask.Example.Auth.WasmJwt.Host/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,19 @@

var builder = WebApplication.CreateBuilder(args);

var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
builder.Configuration["Jwt:Key"] ?? JwtIssuer.DevKey));
// JwtIssuer.DevKey is a public, hardcoded signing key — fine for the demo, but anyone could forge
// tokens with it. Fail fast rather than silently fall back to it outside Development, so a deploy
// that forgets to set Jwt:Key can't ship a forgeable-token endpoint.
var jwtKey = builder.Configuration["Jwt:Key"];
if (jwtKey is null && !builder.Environment.IsDevelopment())
{
throw new InvalidOperationException(
"Jwt:Key is not configured. Set a strong signing key (e.g. via user-secrets, environment, " +
"or a secret store) before running outside Development — the built-in JwtIssuer.DevKey is " +
"public and lets anyone forge tokens.");
}

var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey ?? JwtIssuer.DevKey));

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
Expand Down
4 changes: 3 additions & 1 deletion samples/Rask.Example.Auth.WasmJwt/Auth/JwtLoginService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ public async Task<bool> LoginAsync(string username, string password, string? ret

await tokens.SetAsync(dto.Token);
await users.RefreshAsync();
nav.Navigate(returnUrl ?? "/members");
// Open-redirect guard: never navigate off-origin from an attacker-supplied returnUrl
// (parity with the server's SanitizeReturnUrl). Unsafe values collapse to "/".
nav.Navigate(LocalUrl.Sanitize(returnUrl ?? "/members"));
return true;
}

Expand Down
12 changes: 9 additions & 3 deletions src/Rask.Core/Components/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,16 @@ public static T Required<T>(string? name = null)

/// <summary>
/// <c>true</c> when a value of type <typeparamref name="T" /> (optionally by
/// <paramref name="name" />) is provided by an enclosing <see cref="Context" />. Does
/// not mark the caller as a consumer.
/// <paramref name="name" />) is provided by an enclosing <see cref="Context" />. Like
/// <see cref="Get{T}" />, marks the caller a context consumer so it re-renders when an
/// ancestor begins/stops providing the value — otherwise a component that gates purely on
/// <c>Has</c> would be render-cached and show stale UI when the provider appears or leaves.
/// </summary>
public static bool Has<T>(string? name = null) => ContextStack.TryGet(typeof(T), name, out _);
public static bool Has<T>(string? name = null)
{
MarkConsumer();
return ContextStack.TryGet(typeof(T), name, out _);
}

private static void MarkConsumer() => LiveRenderContext.Current?.MarkCurrentConsumesContext();
}
7 changes: 5 additions & 2 deletions src/Rask.Core/Live/HandlerSyncContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ internal sealed class HandlerSyncContext : SynchronizationContext

public override void Post(SendOrPostCallback d, object? state)
{
var task = Task.Run(() => RunWithRendersAsync(d, state));
// Schedule AND record under the same lock so DrainAsync can't snapshot _pending in the
// gap between Task.Run and the Add — which could otherwise let the drain return before a
// just-posted render completes. Task.Run always schedules on the pool (never inline), so
// holding the lock around it doesn't run user code under the lock.
lock (_pending)
{
_pending.Add(task);
_pending.Add(Task.Run(() => RunWithRendersAsync(d, state)));
}
}

Expand Down
13 changes: 13 additions & 0 deletions src/Rask.Core/Live/LiveOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ public sealed class RaskLiveOptions
{
public LiveDiffMode DiffMode { get; set; } = LiveDiffMode.Auto;

/// <summary>
/// Maximum number of concurrent live sessions the server will hold. Each session
/// pins a component tree and a DI scope, so an unbounded count is a memory-exhaustion
/// (DoS) surface for hosts exposed to untrusted traffic. <c>0</c> (default) means
/// unlimited, preserving prior behaviour. When set, a GET that would create a session
/// beyond the cap is answered with <c>503 Service Unavailable</c> + <c>Retry-After</c>;
/// existing sessions and auth challenge/forbid redirects are unaffected. This is a
/// coarse backstop — pair it with a reverse-proxy rate limit for precise control.
/// Sessions are reclaimed shortly after their socket disconnects (the grace-period
/// removal), so the live count tracks active clients, not cumulative visits.
/// </summary>
public int MaxSessions { get; set; }

/// <summary>
/// Per-app URL prefix. Empty (default) keeps every framework URL at the
/// origin root. A non-empty value like <c>"/appA"</c> scopes every emitted
Expand Down
48 changes: 48 additions & 0 deletions src/Rask.Core/Routing/LocalUrl.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
namespace Rask.Core.Routing;

/// <summary>
/// Open-redirect guard shared by every Rask redirect path (server post-sign-in
/// <c>returnUrl</c>, the client route-guard challenge, and the WASM login/logout flows). It
/// mirrors ASP.NET's <c>Url.IsLocalUrl</c>: only a same-origin absolute path may pass through;
/// anything that could navigate off the origin collapses to <c>"/"</c>.
/// </summary>
public static class LocalUrl
{
/// <summary>
/// Returns <paramref name="url" /> unchanged when it is a guaranteed-local absolute path,
/// otherwise <c>"/"</c>. Rejects: null/empty; non-rooted values (<c>"evil.com"</c>,
/// <c>"https://evil.com"</c>, <c>"javascript:…"</c>); protocol-relative URLs
/// (<c>"//evil.com"</c>); the backslash variants browsers normalise into them
/// (<c>"/\evil.com"</c>, <c>"\evil.com"</c>); and any value containing a control character
/// (which can smuggle the value past downstream parsers / split headers).
/// </summary>
public static string Sanitize(string? url)
{
if (string.IsNullOrEmpty(url))
{
return "/";
}

if (url[0] != '/'
|| (url.Length > 1 && (url[1] == '/' || url[1] == '\\'))
|| ContainsControlCharacter(url))
{
return "/";
}

return url;
}

private static bool ContainsControlCharacter(string value)
{
foreach (var c in value)
{
if (char.IsControl(c))
{
return true;
}
}

return false;
}
}
7 changes: 7 additions & 0 deletions src/Rask.Core/Routing/Navigator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ private IDownloadSink ResolveSink() =>

internal IDisposable EnterHandler()
{
// Clear any navigation a prior handler queued but never consumed — e.g. it called
// Navigate(...) and then threw before TryConsumeHistory ran. Resetting on entry (rather
// than on scope dispose) starts each dispatch clean so a faulted handler can't leak its
// pending nav (and _replace flag) into the next one, while still allowing the caller to
// consume the navigation after the scope disposes.
_dirty = false;
_replace = false;
_inHandler = true;
return new HandlerScope(this);
}
Expand Down
35 changes: 25 additions & 10 deletions src/Rask.Generators/ComponentFactoryGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -814,7 +814,7 @@ private static void EmitFactory(StringBuilder sb, Candidate c, bool emitNavigati
}

first = false;
sb.Append(p.TypeFqn).Append(' ').Append(p.Name);
sb.Append(p.TypeFqn).Append(' ').Append(p.Escaped);
}

foreach (var p in optionalProps)
Expand All @@ -825,7 +825,7 @@ private static void EmitFactory(StringBuilder sb, Candidate c, bool emitNavigati
}

first = false;
sb.Append(p.TypeFqn).Append(' ').Append(p.Name).Append(" = ")
sb.Append(p.TypeFqn).Append(' ').Append(p.Escaped).Append(" = ")
.Append(DefaultLiteralFor(p));
}

Expand Down Expand Up @@ -1196,7 +1196,7 @@ private static void EmitInitializerBody(StringBuilder sb, IEnumerable<PropInfo>
for (var i = 0; i < entries.Count; i++)
{
var p = entries[i];
sb.Append(" ").Append(p.Name).Append(" = ").Append(p.Name);
sb.Append(" ").Append(p.Escaped).Append(" = ").Append(p.Escaped);
if (i < entries.Count - 1)
{
sb.Append(',');
Expand All @@ -1219,27 +1219,29 @@ private static void EmitSnapshotsAndAssignments(StringBuilder sb,
// nullable annotations round-trip).
foreach (var p in foldProps)
{
sb.Append(" var __old_").Append(p.Name).Append(" = __c.").Append(p.Name).AppendLine(";");
// __old_<Name> is a fresh local (raw Name is a valid identifier even when Name is a
// keyword); the property access __c.<Name> must be '@'-escaped.
sb.Append(" var __old_").Append(p.Name).Append(" = __c.").Append(p.Escaped).AppendLine(";");
}

// Re-apply ALL params (including Key) so cached instances see fresh values. Event-callback
// delegates are wrapped so invoking them re-renders the owning component (see AutoCallback).
foreach (var p in assignProps)
{
sb.Append(" __c.").Append(p.Name).Append(" = ");
sb.Append(" __c.").Append(p.Escaped).Append(" = ");
if (p.IsAutoRerenderDelegate)
{
// Wrap returns a nullable delegate (null in → null out); a non-nullable prop never
// passes null, so the null-forgiving `!` is safe and silences CS8601.
sb.Append("global::Rask.Core.AutoCallback.Wrap(").Append(p.Name).Append(')');
sb.Append("global::Rask.Core.AutoCallback.Wrap(").Append(p.Escaped).Append(')');
if (!p.IsNullable)
{
sb.Append('!');
}
}
else
{
sb.Append(p.Name);
sb.Append(p.Escaped);
}

sb.AppendLine(";");
Expand All @@ -1258,7 +1260,7 @@ private static void EmitSnapshotsAndAssignments(StringBuilder sb,
{
var p = foldProps[0];
sb.Append(" var __propsChanged = !global::System.Collections.Generic.EqualityComparer<")
.Append(p.TypeFqn).Append(">.Default.Equals(__old_").Append(p.Name).Append(", ").Append(p.Name)
.Append(p.TypeFqn).Append(">.Default.Equals(__old_").Append(p.Name).Append(", ").Append(p.Escaped)
.AppendLine(");");
return;
}
Expand All @@ -1268,7 +1270,7 @@ private static void EmitSnapshotsAndAssignments(StringBuilder sb,
{
var p = foldProps[i];
sb.Append(" !global::System.Collections.Generic.EqualityComparer<").Append(p.TypeFqn)
.Append(">.Default.Equals(__old_").Append(p.Name).Append(", ").Append(p.Name).Append(')');
.Append(">.Default.Equals(__old_").Append(p.Name).Append(", ").Append(p.Escaped).Append(')');
sb.AppendLine(i < foldProps.Count - 1 ? " ||" : ";");
}
}
Expand Down Expand Up @@ -1361,6 +1363,15 @@ private readonly record struct ForwarderParamInfo(
string DefaultLiteral,
bool IsParams);

// A property/parameter name as a valid C# identifier in emitted code. ISymbol.Name strips the
// leading '@' from a verbatim identifier (a property declared `@event` has Name "event"), so a
// reserved keyword must be re-escaped with '@' wherever it is emitted as an identifier —
// otherwise the generated factory (`string? event = null`, `__c.event = event`) fails to
// compile in the consumer's build. Use this only for emitted identifiers; comparisons against
// metadata names (modelProperty, "Children", typed-delegate sets) keep the raw Name.
internal static string EscapeIdentifier(string name) =>
SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None ? "@" + name : name;

private readonly record struct PropInfo(
string Name,
string TypeFqn,
Expand All @@ -1371,7 +1382,11 @@ private readonly record struct PropInfo(
string DeclaringFilePath,
int DeclaringSpanStart,
int DeclaringSpanLength,
bool IsAutoRerenderDelegate);
bool IsAutoRerenderDelegate)
{
// The factory-parameter / property identifier, '@'-escaped when Name is a reserved keyword.
public string Escaped => EscapeIdentifier(Name);
}
}

internal readonly struct EquatableArray<T> : IEquatable<EquatableArray<T>>, IEnumerable<T>
Expand Down
19 changes: 16 additions & 3 deletions src/Rask.Generators/RoutesGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,14 @@ private static void Emit(SourceProductionContext spc, ImmutableArray<Candidate>
spc.AddSource(hint, SourceText.From(sb.ToString(), Encoding.UTF8));
}

EmitRegistryInitializer(spc, filtered);
// Deduplicate by fully-qualified type name before emitting the registry. A `partial`
// routed page whose declarations carry attributes on more than one part (e.g. [Route] on
// one and [Obsolete]/a source-gen attribute on another) yields one Candidate per attributed
// declaration — all with the same FQN. Emitting them all produced duplicate
// RouteRegistration entries (competing Route nodes for the same page) and duplicate
// [DynamicDependency] attributes. byFqn already keeps the first Candidate per FQN (its
// Templates reflect every [Route] on the merged symbol), so the registry uses that.
EmitRegistryInitializer(spc, byFqn.Values.ToList());
}

private static void EmitRegistryInitializer(SourceProductionContext spc, IReadOnlyList<Candidate> candidates)
Expand Down Expand Up @@ -905,13 +912,19 @@ private static void EmitFactoryBody(StringBuilder sb, Candidate c, List<Template
{
var ident = qp.Name;
var qpName = qp.QueryParamName ?? qp.Name;
// URL-encode the query KEY at generation time (the value is encoded at runtime via
// EncodeExpr). The key is a compile-time constant, so baking the encoded form costs
// nothing and keeps an explicit [QueryParam("a b")] / a name with '&'/'=' from
// emitting a malformed query string. Property-name-derived keys are valid
// identifiers, so this is a no-op for them.
var encodedKey = Uri.EscapeDataString(qpName);
if (qp.IsNullable)
{
sb.Append(" if (").Append(ident).AppendLine(" is not null)");
sb.AppendLine(" {");
sb.AppendLine(" __qs ??= new global::System.Text.StringBuilder();");
sb.AppendLine(" __qs.Append(__qs.Length == 0 ? '?' : '&');");
sb.Append(" __qs.Append(\"").Append(EscapeForCSharpStringLiteral(qpName))
sb.Append(" __qs.Append(\"").Append(EscapeForCSharpStringLiteral(encodedKey))
.AppendLine("=\");");
sb.Append(" __qs.Append(").Append(EncodeExpr(ident)).AppendLine(");");
sb.AppendLine(" }");
Expand All @@ -921,7 +934,7 @@ private static void EmitFactoryBody(StringBuilder sb, Candidate c, List<Template
// Non-nullable required query param — always emit
sb.AppendLine(" __qs ??= new global::System.Text.StringBuilder();");
sb.AppendLine(" __qs.Append(__qs.Length == 0 ? '?' : '&');");
sb.Append(" __qs.Append(\"").Append(EscapeForCSharpStringLiteral(qpName))
sb.Append(" __qs.Append(\"").Append(EscapeForCSharpStringLiteral(encodedKey))
.AppendLine("=\");");
sb.Append(" __qs.Append(").Append(EncodeExpr(ident)).AppendLine(");");
}
Expand Down
2 changes: 1 addition & 1 deletion src/Rask.Server/Authentication/AuthTicketStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ internal sealed class AuthTicketStore : IAuthTicketStore
public string Issue(AuthAction action, ClaimsPrincipal? principal, string? scheme, string sessionId)
{
ArgumentNullException.ThrowIfNull(sessionId);
var id = Guid.NewGuid().ToString("N");
var id = SecureToken.Create();
var ticket = new AuthTicket(action, principal, scheme, sessionId, DateTime.UtcNow.Add(Ttl));
_tickets[id] = ticket;
MaybeSweep();
Expand Down
Loading
Loading