From 8e386a02461e28a9a4baec0c0b0b4c79d8240ab8 Mon Sep 17 00:00:00 2001 From: Voyvodka Date: Mon, 11 May 2026 11:17:15 +0300 Subject: [PATCH] =?UTF-8?q?fix(portal):=20P1=20behaviour=20fixes=20from=20?= =?UTF-8?q?v0.2.0=20audit=20(CORS=20deny-cache,=20validator=20merge,=20PUT?= =?UTF-8?q?=E2=86=92PATCH,=20disable=20preserves=20origins)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the four P1 behaviour findings from the v0.2.0 portal audit. Tur 1 (#101) shipped the P0 security fixes, Tur 2 (#102) shipped the test coverage, Tur 3 (#103) shipped the docs; this Tur 4 closes the behaviour delta. 1. PortalCorsMiddleware deny-cache. HandlePreflightAsync now caches both allow and deny outcomes via IMemoryCache for the same TTL as the per-app signing-key lookup (default 60s). Browsers don't cache rejected preflights, so every OPTIONS from a disallowed origin used to re-scan the portal-enabled app set + deserialize the JSON allowlist — a free DB hammer vector for any caller that knew the portal prefix. Cache key is lowercased-origin scoped (RFC 6454 §4 case-insensitive). New test Preflight_Deny_Decision_Is_Cached_Within_Ttl pins the behaviour by mutating the DB to allow the origin after a 403 and asserting the second call still 403s within the TTL window. 2. EndpointValidationRules helper consolidation. New extension methods (EndpointUrlSyntax, EndpointDescription, EndpointTransformExpression, EndpointCustomHeaders, EndpointAllowedIpsCidrs, EndpointSecretOverride) become the single source of truth for the field-shape rules shared between the 4 admin endpoint validators and the 2 portal endpoint validators. Without this consolidation, tightening a rule on one surface silently leaves the other surface weaker — exactly the drift pattern the audit flagged. Async DNS host-safety check stays in each validator (DependentRules + CustomAsync needs the full property selector). Behaviour unchanged. 3. PortalEndpointsController.Update → [HttpPatch]. The action's body semantics were always partial-replace (every field optional, only non-null fields applied) — that's PATCH, not PUT. Switching the verb aligns the wire surface with reality and stops misleading REST consumers that expect PUT to be full-replace. Route, request shape, response shape unchanged. Two existing tests ported from PutAsJsonAsync to PatchAsync + JsonContent.Create. 4. DashboardPortalController.Disable preserves origins. Removed the line that nulled AllowedPortalOriginsJson on disable. Disable now revokes only the auth surface (PortalSigningKey, PortalRotatedAt) and keeps the operator-curated CORS allowlist so re-enable doesn't force re-curation. Explicit clear path remains: PUT /portal/origins with {origins: []}. Renamed test Disable_Clears_SigningKey_And_Origins → Disable_Clears_SigningKey_But_Preserves_Origins with assertion flip. --- CHANGELOG.md | 8 ++ .../Controllers/DashboardPortalController.cs | 5 +- .../Controllers/PortalEndpointsController.cs | 2 +- .../Middleware/PortalCorsMiddleware.cs | 26 +++++- .../Validators/EndpointValidationRules.cs | 90 +++++++++++++++++++ .../Validators/PortalRequestValidators.cs | 26 ++---- .../Validators/RequestValidators.cs | 64 +++++-------- .../Portal/DashboardPortalControllerTests.cs | 7 +- .../Portal/PortalCorsMiddlewareTests.cs | 30 +++++++ .../Portal/PortalEndpointsControllerTests.cs | 11 +-- 10 files changed, 200 insertions(+), 69 deletions(-) create mode 100644 src/WebhookEngine.API/Validators/EndpointValidationRules.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c3919..bfb1262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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). diff --git a/src/WebhookEngine.API/Controllers/DashboardPortalController.cs b/src/WebhookEngine.API/Controllers/DashboardPortalController.cs index 0998904..e72021b 100644 --- a/src/WebhookEngine.API/Controllers/DashboardPortalController.cs +++ b/src/WebhookEngine.API/Controllers/DashboardPortalController.cs @@ -157,8 +157,11 @@ public async Task 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); diff --git a/src/WebhookEngine.API/Controllers/PortalEndpointsController.cs b/src/WebhookEngine.API/Controllers/PortalEndpointsController.cs index 10d753f..dc0d253 100644 --- a/src/WebhookEngine.API/Controllers/PortalEndpointsController.cs +++ b/src/WebhookEngine.API/Controllers/PortalEndpointsController.cs @@ -157,7 +157,7 @@ public async Task Create( ApiEnvelope.Success(HttpContext, created.ToPortalDetail())); } - [HttpPut("endpoints/{endpointId:guid}")] + [HttpPatch("endpoints/{endpointId:guid}")] public async Task Update( Guid endpointId, [FromBody] PortalUpdateEndpointRequest request, diff --git a/src/WebhookEngine.API/Middleware/PortalCorsMiddleware.cs b/src/WebhookEngine.API/Middleware/PortalCorsMiddleware.cs index 56f70e6..9d2bc3b 100644 --- a/src/WebhookEngine.API/Middleware/PortalCorsMiddleware.cs +++ b/src/WebhookEngine.API/Middleware/PortalCorsMiddleware.cs @@ -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; @@ -76,8 +79,27 @@ public async Task InvokeAsync(HttpContext context) private static async Task HandlePreflightAsync(HttpContext context, string origin) { - var appRepo = context.RequestServices.GetRequiredService(); - 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(); + var options = context.RequestServices.GetRequiredService>().Value; + var cacheKey = $"portal:cors:{origin.ToLowerInvariant()}"; + + if (!cache.TryGetValue(cacheKey, out var allowed)) + { + var appRepo = context.RequestServices.GetRequiredService(); + 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() diff --git a/src/WebhookEngine.API/Validators/EndpointValidationRules.cs b/src/WebhookEngine.API/Validators/EndpointValidationRules.cs new file mode 100644 index 0000000..c81c2c2 --- /dev/null +++ b/src/WebhookEngine.API/Validators/EndpointValidationRules.cs @@ -0,0 +1,90 @@ +using FluentValidation; +using WebhookEngine.Infrastructure.Services; + +namespace WebhookEngine.API.Validators; + +/// +/// 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. +/// +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_"; + + /// + /// 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. + /// + public static IRuleBuilderOptions EndpointUrlSyntax( + this IRuleBuilder rule, + EndpointUrlPolicy urlPolicy) + { + return rule + .Must(urlPolicy.IsValid) + .WithMessage(urlPolicy.ValidationMessage); + } + + public static IRuleBuilderOptions EndpointDescription( + this IRuleBuilder rule) + { + return rule.MaximumLength(MaxDescriptionLength); + } + + public static IRuleBuilderOptions EndpointTransformExpression( + this IRuleBuilder rule) + { + return rule + .MaximumLength(MaxTransformExpressionLength) + .WithMessage($"TransformExpression must not exceed {MaxTransformExpressionLength} characters."); + } + + public static IRuleBuilderOptions?> EndpointCustomHeaders( + this IRuleBuilder?> rule) + { + return rule + .Must(headers => CustomHeaderPolicy.Validate(headers) is null) + .WithMessage(_ => CustomHeaderPolicy.Validate(default(IDictionary?)) ?? "Invalid custom headers."); + } + + public static IRuleBuilderOptions?> EndpointAllowedIpsCidrs( + this IRuleBuilder?> 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\")."); + } + + /// + /// 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. + /// + public static IRuleBuilderOptions EndpointSecretOverride( + this IRuleBuilder 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."); + } +} diff --git a/src/WebhookEngine.API/Validators/PortalRequestValidators.cs b/src/WebhookEngine.API/Validators/PortalRequestValidators.cs index 25d2a51..77d54b1 100644 --- a/src/WebhookEngine.API/Validators/PortalRequestValidators.cs +++ b/src/WebhookEngine.API/Validators/PortalRequestValidators.cs @@ -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) => @@ -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); } } @@ -44,9 +39,8 @@ public class PortalUpdateEndpointRequestValidator : AbstractValidator 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) => @@ -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) diff --git a/src/WebhookEngine.API/Validators/RequestValidators.cs b/src/WebhookEngine.API/Validators/RequestValidators.cs index 24fa396..5c8a4e8 100644 --- a/src/WebhookEngine.API/Validators/RequestValidators.cs +++ b/src/WebhookEngine.API/Validators/RequestValidators.cs @@ -107,8 +107,7 @@ public CreateEndpointRequestValidator(EndpointUrlPolicy urlPolicy) { RuleFor(x => x.Url) .NotEmpty() - .Must(urlPolicy.IsValid) - .WithMessage(urlPolicy.ValidationMessage) + .EndpointUrlSyntax(urlPolicy) .DependentRules(() => { // Resolve the host eagerly so an unreachable webhook target fails the @@ -122,22 +121,19 @@ public CreateEndpointRequestValidator(EndpointUrlPolicy urlPolicy) }); RuleFor(x => x.Description) - .MaximumLength(500) + .EndpointDescription() .When(x => x.Description is not null); RuleFor(x => x.TransformExpression) - .MaximumLength(4096) - .When(x => x.TransformExpression is not null) - .WithMessage("TransformExpression must not exceed 4096 characters."); + .EndpointTransformExpression() + .When(x => x.TransformExpression 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.AllowedIps) - .Must(list => list!.All(cidr => IpAllowlistMatcher.TryParseCidr(cidr, out _))) - .WithMessage("AllowedIps must contain valid CIDR notations (e.g. \"203.0.113.0/24\").") + .EndpointAllowedIpsCidrs() .When(x => x.AllowedIps is { Count: > 0 }); } } @@ -147,9 +143,8 @@ public class UpdateEndpointRequestValidator : AbstractValidator 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) => @@ -160,22 +155,19 @@ public UpdateEndpointRequestValidator(EndpointUrlPolicy urlPolicy) }); RuleFor(x => x.Description) - .MaximumLength(500) + .EndpointDescription() .When(x => x.Description is not null); RuleFor(x => x.TransformExpression) - .MaximumLength(4096) - .When(x => x.TransformExpression is not null) - .WithMessage("TransformExpression must not exceed 4096 characters."); + .EndpointTransformExpression() + .When(x => x.TransformExpression 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.AllowedIps) - .Must(list => list!.All(cidr => IpAllowlistMatcher.TryParseCidr(cidr, out _))) - .WithMessage("AllowedIps must contain valid CIDR notations (e.g. \"203.0.113.0/24\").") + .EndpointAllowedIpsCidrs() .When(x => x.AllowedIps is { Count: > 0 }); RuleFor(x => x) @@ -200,8 +192,7 @@ public DashboardCreateEndpointRequestValidator(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) => @@ -212,22 +203,19 @@ public DashboardCreateEndpointRequestValidator(EndpointUrlPolicy urlPolicy) }); RuleFor(x => x.Description) - .MaximumLength(500) + .EndpointDescription() .When(x => x.Description is not null); RuleFor(x => x.TransformExpression) - .MaximumLength(4096) - .When(x => x.TransformExpression is not null) - .WithMessage("TransformExpression must not exceed 4096 characters."); + .EndpointTransformExpression() + .When(x => x.TransformExpression 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.AllowedIps) - .Must(list => list!.All(cidr => IpAllowlistMatcher.TryParseCidr(cidr, out _))) - .WithMessage("AllowedIps must contain valid CIDR notations (e.g. \"203.0.113.0/24\").") + .EndpointAllowedIpsCidrs() .When(x => x.AllowedIps is { Count: > 0 }); } } @@ -237,9 +225,8 @@ public class DashboardUpdateEndpointRequestValidator : AbstractValidator 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) => @@ -250,22 +237,19 @@ public DashboardUpdateEndpointRequestValidator(EndpointUrlPolicy urlPolicy) }); RuleFor(x => x.Description) - .MaximumLength(500) + .EndpointDescription() .When(x => x.Description is not null); RuleFor(x => x.TransformExpression) - .MaximumLength(4096) - .When(x => x.TransformExpression is not null) - .WithMessage("TransformExpression must not exceed 4096 characters."); + .EndpointTransformExpression() + .When(x => x.TransformExpression 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.AllowedIps) - .Must(list => list!.All(cidr => IpAllowlistMatcher.TryParseCidr(cidr, out _))) - .WithMessage("AllowedIps must contain valid CIDR notations (e.g. \"203.0.113.0/24\").") + .EndpointAllowedIpsCidrs() .When(x => x.AllowedIps is { Count: > 0 }); RuleFor(x => x) diff --git a/tests/WebhookEngine.API.Tests/Portal/DashboardPortalControllerTests.cs b/tests/WebhookEngine.API.Tests/Portal/DashboardPortalControllerTests.cs index 3152ff7..c8695b0 100644 --- a/tests/WebhookEngine.API.Tests/Portal/DashboardPortalControllerTests.cs +++ b/tests/WebhookEngine.API.Tests/Portal/DashboardPortalControllerTests.cs @@ -117,8 +117,11 @@ public async Task Rotate_Returns_New_Signing_Key_And_Updates_RotatedAt() } [Fact] - public async Task Disable_Clears_SigningKey_And_Origins() + public async Task Disable_Clears_SigningKey_But_Preserves_Origins() { + // Operator behaviour: disable revokes auth (signing key + rotated-at) + // but keeps the CORS allowlist so re-enable doesn't force the + // operator to re-curate origins. Explicit clear is via /portal/origins. await ResetDatabaseAsync(); var (appId, _) = await SeedAppAsync( portalEnabled: true, @@ -132,8 +135,8 @@ await ExecuteDbAsync(async db => { var app = await db.Applications.AsNoTracking().FirstAsync(a => a.Id == appId); app.PortalSigningKey.Should().BeNull(); - app.AllowedPortalOriginsJson.Should().BeNull(); app.PortalRotatedAt.Should().BeNull(); + app.AllowedPortalOriginsJson.Should().Be("[\"https://app.acme.com\"]"); }); } diff --git a/tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs b/tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs index 7a73abd..99bd01d 100644 --- a/tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs +++ b/tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs @@ -137,6 +137,36 @@ public async Task Real_Request_With_Valid_Token_And_Disallowed_Origin_Has_No_Cor response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse(); } + [Fact] + public async Task Preflight_Deny_Decision_Is_Cached_Within_Ttl() + { + // Regression for the deny-cache fix: previously every disallowed + // OPTIONS bypassed any cache and re-scanned the portal-enabled apps. + // Now both allow and deny outcomes are cached for LookupCacheTtlSeconds, + // so a first preflight that returns 403 must keep returning 403 even + // if the DB starts allowing the origin within the TTL window. Uses + // a unique origin so the assertion isn't polluted by other tests + // sharing the IClassFixture's MemoryCache. + const string uniqueOrigin = "https://deny-cache-fix.example"; + + await SeedAppAsync(allowedOriginsJson: "[\"https://other.example\"]"); + var first = await SendAsync(HttpMethod.Options, PingPath, uniqueOrigin); + first.StatusCode.Should().Be(HttpStatusCode.Forbidden); + + // Mutate DB to now allow the origin — without invalidating the CORS + // cache the cached deny decision must still win until TTL elapses. + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var app = await db.Applications.FirstAsync(); + app.AllowedPortalOriginsJson = $"[\"{uniqueOrigin}\"]"; + await db.SaveChangesAsync(); + } + + var second = await SendAsync(HttpMethod.Options, PingPath, uniqueOrigin); + second.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + private async Task SendAsync( HttpMethod method, string path, diff --git a/tests/WebhookEngine.API.Tests/Portal/PortalEndpointsControllerTests.cs b/tests/WebhookEngine.API.Tests/Portal/PortalEndpointsControllerTests.cs index e8c5b0d..408d1d4 100644 --- a/tests/WebhookEngine.API.Tests/Portal/PortalEndpointsControllerTests.cs +++ b/tests/WebhookEngine.API.Tests/Portal/PortalEndpointsControllerTests.cs @@ -123,10 +123,9 @@ public async Task Portal_Update_Endpoint_Returns_200_And_Updates_Fields() var token = MintFullToken(appId); using var client = CreateClient(token); - var response = await client.PutAsJsonAsync($"{PortalRoot}/endpoints/{endpointId}", new - { - description = "updated by portal" - }); + var response = await client.PatchAsync( + $"{PortalRoot}/endpoints/{endpointId}", + JsonContent.Create(new { description = "updated by portal" })); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -309,7 +308,9 @@ public async Task Portal_Cannot_Update_Other_Apps_Endpoint() using var client = CreateClient(tokenA); var body = new { url = "https://example.com/new" }; - var response = await client.PutAsJsonAsync($"{PortalRoot}/endpoints/{otherEndpointId}", body); + var response = await client.PatchAsync( + $"{PortalRoot}/endpoints/{otherEndpointId}", + JsonContent.Create(body)); response.StatusCode.Should().Be(HttpStatusCode.NotFound); (await ReadErrorCodeAsync(response)).Should().Be("PORTAL_NOT_FOUND");