diff --git a/CHANGELOG.md b/CHANGELOG.md index df80aa7..e0e452f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Security +- **Portal stack hardening — three post-merge fixes from the v0.2.0 audit.** (1) `PortalEndpointsController` now carries `[EnableRateLimiting("send-by-appid")]` at the controller level; previously a leaked or misbehaving portal token could spam the mutating routes (notably `/test`, which fires a real outbound HTTP POST) without sharing the per-tenant token-bucket budget that the public API has always enforced. (2) `PortalTokenAuthMiddleware` switches from a static `JwtSecurityTokenHandler` to a per-instance handler with `MapInboundClaims = false` and `MaximumTokenSizeInBytes = PortalAuthOptions.MaxTokenSizeBytes` (default 8 KiB, down from the .NET default ~250 KiB); a 16 KiB Bearer payload is now rejected before the JWT parser runs, killing a DoS amplification path. (3) `PortalLookupCache.Set` now atomically swaps the per-app `CancellationTokenSource` via `AddOrUpdate` and cancels-and-disposes the previous one, instead of `GetOrAdd`-reusing it; this closes a window where a `Set` racing an `InvalidateApplication` could bind a fresh cache entry to a disposed token. New `Portal_Request_With_Oversized_Token_Returns_401_Without_Parsing` regression test pins the size cap. + ### Added - **npm publish workflow** (`publish-portal.yml`). New `.github/workflows/publish-portal.yml` fires on `portal-v*` tags and publishes `@webhookengine/endpoint-manager` to npmjs.com with sigstore provenance (`--provenance`). Includes a pre-publish guard that fails with a human-readable error if `private:true` is still set in `package.json`, preventing accidental publishes from dev branches. - **`samples/portal-host/` reference application.** Standalone Vite + React app demonstrating host-SaaS integration with ``. Uses a browser-native Web Crypto HS256 mint (`mint-token.ts`) and an in-memory fetch shim (`mock-fetch.ts`) — no engine instance required to run. Shows prop wiring, CSS custom-property theme overrides, and the server-side token-mint pattern. diff --git a/src/WebhookEngine.API/Controllers/PortalEndpointsController.cs b/src/WebhookEngine.API/Controllers/PortalEndpointsController.cs index 622c3f3..10d753f 100644 --- a/src/WebhookEngine.API/Controllers/PortalEndpointsController.cs +++ b/src/WebhookEngine.API/Controllers/PortalEndpointsController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using WebhookEngine.API.Contracts; using WebhookEngine.API.Contracts.Portal; using WebhookEngine.Core.Enums; @@ -28,6 +29,12 @@ namespace WebhookEngine.API.Controllers; [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] [Route("api/v1/portal")] +// Portal tokens are short-lived but the host SaaS can mint one per page +// render — without rate limiting, a leaked token (or a misbehaving host) +// could spam mutating routes (notably /test, which fires a real HTTP POST +// out of the engine). Reuses the existing send-by-appid partition so the +// portal shares the same per-tenant budget as the public API. +[EnableRateLimiting("send-by-appid")] public class PortalEndpointsController : ControllerBase { private readonly EndpointRepository _endpointRepo; diff --git a/src/WebhookEngine.API/Middleware/PortalTokenAuthMiddleware.cs b/src/WebhookEngine.API/Middleware/PortalTokenAuthMiddleware.cs index e8fb616..ab79ce7 100644 --- a/src/WebhookEngine.API/Middleware/PortalTokenAuthMiddleware.cs +++ b/src/WebhookEngine.API/Middleware/PortalTokenAuthMiddleware.cs @@ -32,13 +32,21 @@ public class PortalTokenAuthMiddleware private const string AppIdClaim = "appId"; private const string CapabilitiesClaim = "capabilities"; - private static readonly JwtSecurityTokenHandler TokenHandler = new(); - private readonly RequestDelegate _next; + private readonly JwtSecurityTokenHandler _tokenHandler; - public PortalTokenAuthMiddleware(RequestDelegate next) + public PortalTokenAuthMiddleware(RequestDelegate next, IOptions options) { _next = next; + _tokenHandler = new JwtSecurityTokenHandler + { + // MapInboundClaims rewrites well-known short claims into long + // .NET URI claim names every request. We rely on raw JWT claim + // keys (`appId`, `capabilities`) so the mapping is pure overhead; + // turning it off also removes a small attack surface. + MapInboundClaims = false, + MaximumTokenSizeInBytes = options.Value.MaxTokenSizeBytes + }; } public async Task InvokeAsync(HttpContext context) @@ -82,7 +90,7 @@ await WriteUnauthorizedAsync(context, "PORTAL_AUTH_REQUIRED", JwtSecurityToken unverified; try { - unverified = TokenHandler.ReadJwtToken(rawToken); + unverified = _tokenHandler.ReadJwtToken(rawToken); } catch (Exception) { @@ -132,7 +140,7 @@ await WriteUnauthorizedAsync(context, "PORTAL_NOT_ENABLED", SecurityToken validatedToken; try { - principal = TokenHandler.ValidateToken(rawToken, validationParameters, out validatedToken); + principal = _tokenHandler.ValidateToken(rawToken, validationParameters, out validatedToken); } catch (SecurityTokenExpiredException) { diff --git a/src/WebhookEngine.Core/Options/PortalAuthOptions.cs b/src/WebhookEngine.Core/Options/PortalAuthOptions.cs index de8da57..0f4320a 100644 --- a/src/WebhookEngine.Core/Options/PortalAuthOptions.cs +++ b/src/WebhookEngine.Core/Options/PortalAuthOptions.cs @@ -30,4 +30,13 @@ public class PortalAuthOptions /// Defaults to 60 seconds. /// public int LookupCacheTtlSeconds { get; set; } = 60; + + /// + /// Maximum accepted JWT size in bytes. Defends against DoS where an + /// attacker sends a multi-hundred-KB token that JwtSecurityTokenHandler + /// would otherwise parse before rejecting. Portal tokens are typically + /// 0.5-2 KB; 8 KiB leaves comfortable headroom. The .NET default is + /// ~250 KB which is far too generous for this surface. + /// + public int MaxTokenSizeBytes { get; set; } = 8 * 1024; } diff --git a/src/WebhookEngine.Infrastructure/Services/PortalLookupCache.cs b/src/WebhookEngine.Infrastructure/Services/PortalLookupCache.cs index 52b92d5..30663ee 100644 --- a/src/WebhookEngine.Infrastructure/Services/PortalLookupCache.cs +++ b/src/WebhookEngine.Infrastructure/Services/PortalLookupCache.cs @@ -67,29 +67,53 @@ public static void InvalidateApplication(Guid appId) { if (AppTokens.TryRemove(appId, out var source)) { - try - { - source.Cancel(); - } - finally - { - source.Dispose(); - } + CancelAndDispose(source); } } private void Set(Guid appId, string key, PortalAppLookup value) { - var token = AppTokens.GetOrAdd(appId, _ => new CancellationTokenSource()); + // A fresh CTS per Set so a previous InvalidateApplication that + // already cancelled+disposed the prior token can't cause a + // CancellationChangeToken to be constructed over a disposed + // source. AddOrUpdate atomically swaps; the previous token is + // cancelled (so any in-flight cache entries bound to it are + // invalidated) and disposed. + var fresh = new CancellationTokenSource(); + var current = AppTokens.AddOrUpdate( + appId, + fresh, + (_, existing) => + { + CancelAndDispose(existing); + return fresh; + }); + var entryOptions = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = _ttl, Size = 1 }; - entryOptions.AddExpirationToken(new CancellationChangeToken(token.Token)); + entryOptions.AddExpirationToken(new CancellationChangeToken(current.Token)); _cache.Set(key, value, entryOptions); } + private static void CancelAndDispose(CancellationTokenSource source) + { + try + { + source.Cancel(); + } + catch (ObjectDisposedException) + { + // Concurrent Invalidate / Set already disposed it; safe to swallow. + } + finally + { + source.Dispose(); + } + } + private static string[] ParseOrigins(string? json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/tests/WebhookEngine.API.Tests/Portal/PortalTokenAuthMiddlewareTests.cs b/tests/WebhookEngine.API.Tests/Portal/PortalTokenAuthMiddlewareTests.cs index 163410a..8bfd756 100644 --- a/tests/WebhookEngine.API.Tests/Portal/PortalTokenAuthMiddlewareTests.cs +++ b/tests/WebhookEngine.API.Tests/Portal/PortalTokenAuthMiddlewareTests.cs @@ -165,6 +165,23 @@ public async Task Portal_Request_With_HS384_Token_Is_Rejected_As_Invalid_Signatu code.Should().Be("PORTAL_AUTH_INVALID_SIGNATURE"); } + [Fact] + public async Task Portal_Request_With_Oversized_Token_Returns_401_Without_Parsing() + { + // Default MaxTokenSizeBytes = 8 KiB. A 16 KiB Bearer payload must + // be rejected before JwtSecurityTokenHandler even attempts to parse + // it; without the cap the .NET default (~250 KiB) would happily + // consume the bytes first and reject only on a downstream parse + // failure, giving an attacker a much larger DoS amplification. + var oversize = new string('A', 16 * 1024); + + var response = await SendPingAsync(oversize); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + var code = await ReadErrorCodeAsync(response); + code.Should().Be("PORTAL_AUTH_INVALID_TOKEN"); + } + private async Task SendPingAsync(string token) { var client = _factory.CreateClient();