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: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

## [Unreleased]

### Changed
- **`PATCH /api/v1/portal/endpoints/{id}` replaces `PUT`.** Portal endpoint update was tagged `[HttpPut]` but its body semantics were partial-replace (every field optional, only non-null fields applied) — that is the contract for `PATCH`, not `PUT`. Switching the method aligns the wire surface with the actual behaviour and avoids confusing REST consumers that expect `PUT` to be full-replace. The route, request shape, and response shape are unchanged. Minor breaking change for any direct HTTP caller hitting this route; the embeddable `<EndpointManager />` component already uses the new method.
- **`POST /api/v1/dashboard/applications/{appId}/portal/disable` no longer wipes `AllowedPortalOriginsJson`.** Disable revokes the auth surface (`PortalSigningKey` + `PortalRotatedAt`) but preserves the operator-curated CORS allowlist so a re-enable doesn't lose origin configuration. To explicitly clear the allowlist, send `PUT /portal/origins` with `{"origins": []}`. The audit log entry's `before/after` snapshot still records the change accurately.
- **Validator drift consolidation: new `EndpointValidationRules` extension methods** (`EndpointUrlSyntax`, `EndpointDescription`, `EndpointTransformExpression`, `EndpointCustomHeaders`, `EndpointAllowedIpsCidrs`, `EndpointSecretOverride`). The 4 admin endpoint validators (`CreateEndpoint`, `UpdateEndpoint`, `DashboardCreateEndpoint`, `DashboardUpdateEndpoint`) and the 2 portal endpoint validators (`PortalCreateEndpoint`, `PortalUpdateEndpoint`) now share a single source of truth for URL syntax / description max-length / transform-expression cap / custom-header policy / CIDR shape / `whsec_` prefix rules. Without this helper a tightening on one surface (e.g. a longer allowed transform expression on the portal side) would silently leave the admin side weaker. Behaviour unchanged in this commit; the consolidation is a precondition for the next round of rule tightening.

### Security
- **Portal CORS preflight gains a deny-cache.** `PortalCorsMiddleware.HandlePreflightAsync` now caches both allow and deny outcomes for the same TTL as the per-app signing-key lookup (default 60 s). Browsers don't cache rejected preflights, so before this change every `OPTIONS` against `/api/v1/portal/*` from a disallowed origin re-scanned the portal-enabled application set and the in-process JSON deserialize loop — a low-effort DB hammer vector. New regression test `Preflight_Deny_Decision_Is_Cached_Within_Ttl` pins the behaviour.

### Documentation
- **`docs/API.md` §3.8 — Portal API (Customer-Facing JWT).** Replaces the doc-drift gap surfaced by the v0.2.0 audit (where the portal API surface existed in code and CHANGELOG but was absent from `docs/API.md`). Covers HS256 JWT contract, capability scopes, per-app CORS, rate-limit partition reuse, cross-tenant `404` semantics, every route under `/api/v1/portal/*`, the dashboard portal-admin routes, the portal-specific error code table, and an end-to-end Node.js (`jose`) + cURL probe.
- **`docs/ARCHITECTURE.md` §4.3 — Portal Token Authentication.** Documents the load-bearing middleware ordering (`ApiKeyAuth` → `PortalTokenAuth` → `PortalCors` → `RateLimiter`), the three invariants it encodes, the `PortalLookupCache` TTL + atomic-CTS-swap behaviour, and the JWT validator's defense-in-depth choices (HS256 pin, 8 KiB token cap, `MapInboundClaims=false`, lifetime cap, opaque error bodies).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,11 @@ public async Task<IActionResult> Disable(Guid appId, CancellationToken ct)

var beforeSnapshot = BuildAuditSnapshot(application);

// Disable revokes the auth surface (signing key + rotated-at) but
// preserves the operator-curated CORS allowlist so a re-enable
// doesn't lose the origin configuration. Operators wanting a full
// wipe can clear origins explicitly via PUT /portal/origins.
application.PortalSigningKey = null;
application.AllowedPortalOriginsJson = null;
application.PortalRotatedAt = null;

await _applicationRepository.UpdateAsync(application, ct);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ public async Task<IActionResult> Create(
ApiEnvelope.Success(HttpContext, created.ToPortalDetail()));
}

[HttpPut("endpoints/{endpointId:guid}")]
[HttpPatch("endpoints/{endpointId:guid}")]
public async Task<IActionResult> Update(
Guid endpointId,
[FromBody] PortalUpdateEndpointRequest request,
Expand Down
26 changes: 24 additions & 2 deletions src/WebhookEngine.API/Middleware/PortalCorsMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using WebhookEngine.Core.Options;
using WebhookEngine.Core.Utilities;
using WebhookEngine.Infrastructure.Repositories;
using WebhookEngine.Infrastructure.Services;
Expand Down Expand Up @@ -76,8 +79,27 @@ public async Task InvokeAsync(HttpContext context)

private static async Task HandlePreflightAsync(HttpContext context, string origin)
{
var appRepo = context.RequestServices.GetRequiredService<ApplicationRepository>();
var allowed = await appRepo.AnyAllowsPortalOriginAsync(origin, context.RequestAborted);
// Deny-cache the preflight decision so an attacker can't trigger an
// unbounded per-app candidate scan via OPTIONS spam (browsers don't
// cache rejected preflights, so the absence of a server-side cache
// turned every disallowed OPTIONS into a fresh DB sweep). Both
// allow and deny outcomes are cached for the same TTL as the
// signing-key lookup; eventual consistency on origin updates is
// bounded by that TTL, mirroring the existing per-app cache.
var cache = context.RequestServices.GetRequiredService<IMemoryCache>();
var options = context.RequestServices.GetRequiredService<IOptions<PortalAuthOptions>>().Value;
var cacheKey = $"portal:cors:{origin.ToLowerInvariant()}";

if (!cache.TryGetValue<bool>(cacheKey, out var allowed))
{
var appRepo = context.RequestServices.GetRequiredService<ApplicationRepository>();
allowed = await appRepo.AnyAllowsPortalOriginAsync(origin, context.RequestAborted);
cache.Set(cacheKey, allowed, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(options.LookupCacheTtlSeconds),
Size = 1
});
}

var logger = context.RequestServices
.GetRequiredService<ILoggerFactory>()
Expand Down
90 changes: 90 additions & 0 deletions src/WebhookEngine.API/Validators/EndpointValidationRules.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using FluentValidation;
using WebhookEngine.Infrastructure.Services;

namespace WebhookEngine.API.Validators;

/// <summary>
/// Single source of truth for the field-shape rules shared between the admin
/// (`/api/v1/endpoints` + `/api/v1/dashboard/endpoints`) and customer-facing
/// (`/api/v1/portal/endpoints`) validators. Without this, the two surfaces
/// drift over time — the v0.2.0 audit flagged that the 32-char `whsec_`
/// prefix rule, the 4 KiB transform-expression cap, and the custom-header
/// policy were each duplicated in 4-6 places, so a tightening on one side
/// quietly leaves the other side weaker.
///
/// Async URL host-safety (`EndpointUrlPolicy.CheckHostSafeAsync`) is NOT
/// covered here because the FluentValidation `DependentRules` + `CustomAsync`
/// pattern needs to call back into the surrounding validator with the full
/// property selector — keeping it in each validator avoids a fragile
/// reflection-driven helper. The synchronous URL syntax check IS shared.
/// </summary>
public static class EndpointValidationRules
{
public const int MaxDescriptionLength = 500;
public const int MaxTransformExpressionLength = 4096;
public const int MinSecretOverrideLength = 32;
public const int MaxSecretOverrideLength = 128;
public const string SecretOverridePrefix = "whsec_";

/// <summary>
/// Synchronous URL syntax + SSRF-classification (no DNS lookup). Pair with
/// a `DependentRules { RuleFor(...).CustomAsync(... CheckHostSafeAsync ...) }`
/// in the host validator for the eager DNS-resolution check.
/// </summary>
public static IRuleBuilderOptions<T, string?> EndpointUrlSyntax<T>(
this IRuleBuilder<T, string?> rule,
EndpointUrlPolicy urlPolicy)
{
return rule
.Must(urlPolicy.IsValid)
.WithMessage(urlPolicy.ValidationMessage);
}

public static IRuleBuilderOptions<T, string?> EndpointDescription<T>(
this IRuleBuilder<T, string?> rule)
{
return rule.MaximumLength(MaxDescriptionLength);
}

public static IRuleBuilderOptions<T, string?> EndpointTransformExpression<T>(
this IRuleBuilder<T, string?> rule)
{
return rule
.MaximumLength(MaxTransformExpressionLength)
.WithMessage($"TransformExpression must not exceed {MaxTransformExpressionLength} characters.");
}

public static IRuleBuilderOptions<T, IDictionary<string, string>?> EndpointCustomHeaders<T>(
this IRuleBuilder<T, IDictionary<string, string>?> rule)
{
return rule
.Must(headers => CustomHeaderPolicy.Validate(headers) is null)
.WithMessage(_ => CustomHeaderPolicy.Validate(default(IDictionary<string, string>?)) ?? "Invalid custom headers.");
}

public static IRuleBuilderOptions<T, IList<string>?> EndpointAllowedIpsCidrs<T>(
this IRuleBuilder<T, IList<string>?> rule)
{
return rule
.Must(list => list!.All(cidr => IpAllowlistMatcher.TryParseCidr(cidr, out _)))
.WithMessage("AllowedIps must contain valid CIDR notations (e.g. \"203.0.113.0/24\").");
}

/// <summary>
/// Customer-typed override secret. Requires the `whsec_` prefix and at
/// least 32 chars so a hand-typed weak password ("password123") cannot
/// quietly undermine HMAC authenticity on every signed delivery.
/// </summary>
public static IRuleBuilderOptions<T, string?> EndpointSecretOverride<T>(
this IRuleBuilder<T, string?> rule)
{
return rule
.MinimumLength(MinSecretOverrideLength)
.MaximumLength(MaxSecretOverrideLength)
.Must(s => s!.StartsWith(SecretOverridePrefix, StringComparison.Ordinal))
.WithMessage(
$"SecretOverride must start with the '{SecretOverridePrefix}' prefix and be at least " +
$"{MinSecretOverrideLength} characters. Use the portal's rotate-secret action to generate one " +
"rather than typing a password.");
}
}
26 changes: 8 additions & 18 deletions src/WebhookEngine.API/Validators/PortalRequestValidators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ public PortalCreateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
{
RuleFor(x => x.Url)
.NotEmpty()
.Must(urlPolicy.IsValid)
.WithMessage(urlPolicy.ValidationMessage)
.EndpointUrlSyntax(urlPolicy)
.DependentRules(() =>
{
RuleFor(x => x.Url).CustomAsync(async (url, ctx, ct) =>
Expand All @@ -22,19 +21,15 @@ public PortalCreateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
});

RuleFor(x => x.Description)
.MaximumLength(500)
.EndpointDescription()
.When(x => x.Description is not null);

RuleFor(x => x.CustomHeaders)
.Must(headers => CustomHeaderPolicy.Validate(headers) is null)
.WithMessage(x => CustomHeaderPolicy.Validate(x.CustomHeaders) ?? "Invalid custom headers.")
.EndpointCustomHeaders()
.When(x => x.CustomHeaders is not null);

RuleFor(x => x.SecretOverride)
.MinimumLength(32)
.MaximumLength(128)
.Must(s => s!.StartsWith("whsec_", StringComparison.Ordinal))
.WithMessage("SecretOverride must start with the 'whsec_' prefix and be at least 32 characters. Use the portal's rotate-secret action to generate one rather than typing a password.")
.EndpointSecretOverride()
.When(x => x.SecretOverride is not null);
}
}
Expand All @@ -44,9 +39,8 @@ public class PortalUpdateEndpointRequestValidator : AbstractValidator<PortalUpda
public PortalUpdateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
{
RuleFor(x => x.Url)
.Must(urlPolicy.IsValid)
.EndpointUrlSyntax(urlPolicy)
.When(x => x.Url is not null)
.WithMessage(urlPolicy.ValidationMessage)
.DependentRules(() =>
{
RuleFor(x => x.Url!).CustomAsync(async (url, ctx, ct) =>
Expand All @@ -57,19 +51,15 @@ public PortalUpdateEndpointRequestValidator(EndpointUrlPolicy urlPolicy)
});

RuleFor(x => x.Description)
.MaximumLength(500)
.EndpointDescription()
.When(x => x.Description is not null);

RuleFor(x => x.CustomHeaders)
.Must(headers => CustomHeaderPolicy.Validate(headers) is null)
.WithMessage(x => CustomHeaderPolicy.Validate(x.CustomHeaders) ?? "Invalid custom headers.")
.EndpointCustomHeaders()
.When(x => x.CustomHeaders is not null);

RuleFor(x => x.SecretOverride)
.MinimumLength(32)
.MaximumLength(128)
.Must(s => s!.StartsWith("whsec_", StringComparison.Ordinal))
.WithMessage("SecretOverride must start with the 'whsec_' prefix and be at least 32 characters. Use the portal's rotate-secret action to generate one rather than typing a password.")
.EndpointSecretOverride()
.When(x => x.SecretOverride is not null);

RuleFor(x => x)
Expand Down
Loading
Loading