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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<EndpointManager />`. 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -28,6 +29,12 @@ namespace WebhookEngine.API.Controllers;
[ProducesResponseType<ApiErrorResponse>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ApiErrorResponse>(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;
Expand Down
18 changes: 13 additions & 5 deletions src/WebhookEngine.API/Middleware/PortalTokenAuthMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PortalAuthOptions> 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)
Expand Down Expand Up @@ -82,7 +90,7 @@ await WriteUnauthorizedAsync(context, "PORTAL_AUTH_REQUIRED",
JwtSecurityToken unverified;
try
{
unverified = TokenHandler.ReadJwtToken(rawToken);
unverified = _tokenHandler.ReadJwtToken(rawToken);
}
catch (Exception)
{
Expand Down Expand Up @@ -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)
{
Expand Down
9 changes: 9 additions & 0 deletions src/WebhookEngine.Core/Options/PortalAuthOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,13 @@ public class PortalAuthOptions
/// Defaults to 60 seconds.
/// </summary>
public int LookupCacheTtlSeconds { get; set; } = 60;

/// <summary>
/// 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.
/// </summary>
public int MaxTokenSizeBytes { get; set; } = 8 * 1024;
}
44 changes: 34 additions & 10 deletions src/WebhookEngine.Infrastructure/Services/PortalLookupCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpResponseMessage> SendPingAsync(string token)
{
var client = _factory.CreateClient();
Expand Down
Loading