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();