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]

### Added
- **Portal stack test coverage — 23 new tests closing the v0.2.0 audit gaps.** New `PortalCorsMiddlewareTests` (7 facts) covers preflight allow / reject / case-insensitive match / subdomain-spoofing reject / missing-Origin pass-through and the real-request CORS-header echo path — previously zero coverage. New `PortalLookupCacheTests` (5 facts) pins TTL hit, invalidation forces DB reload, portal-disabled returns null, and concurrent-Invalidate doesn't double-dispose (regression for the audit fix). New `PortalOriginsAllowlistE2ETests` (7 facts, Testcontainers) runs `ApplicationRepository.AnyAllowsPortalOriginAsync` against real PostgreSQL JSONB. `PortalEndpointsControllerTests` gains four cross-tenant guards (`DELETE`, `/enable`, `/disable`, `/test`, `/attempts` against another tenant's endpoint id all return `404 PORTAL_NOT_FOUND`) and a defense-in-depth test for empty-capabilities tokens (the absence of any `capabilities` claim must surface as `403 PORTAL_INSUFFICIENT_CAPABILITY`, not silent full access).

### 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.

Expand Down
226 changes: 226 additions & 0 deletions tests/WebhookEngine.API.Tests/Portal/PortalCorsMiddlewareTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
using System.Net;
using System.Net.Http.Headers;
using FluentAssertions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using WebhookEngine.API.Middleware;
using WebhookEngine.Core.Entities;
using WebhookEngine.Infrastructure.Data;

namespace WebhookEngine.API.Tests.Portal;

/// <summary>
/// Contract tests for <c>PortalCorsMiddleware</c>. Stand up the real production
/// middleware in a minimal pipeline (PortalTokenAuth → PortalCors → terminal
/// pong) so the ordering invariant the production code documents is exercised
/// here too. Allowed-origin matching, preflight rejection of subdomain spoofing,
/// and the "no Origin → no CORS interference" branch are the security-load-bearing
/// behaviours that previously had zero coverage.
/// </summary>
public class PortalCorsMiddlewareTests : IClassFixture<PortalCorsMiddlewareTests.CorsMiddlewareFactory>
{
private const string PingPath = "/api/v1/portal/_ping";
private const string AllowedOrigin = "https://app.example.com";
private const string DisallowedOrigin = "https://attacker.example";

private readonly CorsMiddlewareFactory _factory;

public PortalCorsMiddlewareTests(CorsMiddlewareFactory factory)
{
_factory = factory;
}

[Fact]
public async Task Preflight_With_Allowed_Origin_Returns_204_And_Cors_Headers()
{
await SeedAppAsync(allowedOriginsJson: $"[\"{AllowedOrigin}\"]");

var response = await SendAsync(HttpMethod.Options, PingPath, AllowedOrigin);

response.StatusCode.Should().Be(HttpStatusCode.NoContent);
response.Headers.GetValues("Access-Control-Allow-Origin").Should().ContainSingle().Which.Should().Be(AllowedOrigin);
response.Headers.GetValues("Access-Control-Allow-Methods").Should().ContainSingle().Which.Should().Contain("POST");
response.Headers.GetValues("Access-Control-Allow-Headers").Should().ContainSingle().Which.Should().Contain("Authorization");
response.Headers.GetValues("Access-Control-Max-Age").Should().ContainSingle().Which.Should().Be("600");
response.Headers.Vary.Should().Contain("Origin");
}

[Fact]
public async Task Preflight_With_Disallowed_Origin_Returns_403_Without_Cors_Headers()
{
await SeedAppAsync(allowedOriginsJson: $"[\"{AllowedOrigin}\"]");

var response = await SendAsync(HttpMethod.Options, PingPath, DisallowedOrigin);

response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
// CORS headers MUST NOT leak on a rejected preflight — a browser that
// saw Allow-Origin: <attacker> would treat the response as authorised.
response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse();
response.Headers.Contains("Access-Control-Allow-Methods").Should().BeFalse();
}

[Fact]
public async Task Preflight_With_Subdomain_Spoofed_Origin_Is_Rejected()
{
// Classic CORS bypass: allowlist contains "https://acme.com", attacker
// sends Origin "https://acme.com.attacker.com". A naive
// origin.StartsWith(allowed) check would accept it. Strict equality
// (or, here, exact-string match) refuses it.
await SeedAppAsync(allowedOriginsJson: "[\"https://acme.com\"]");

var response = await SendAsync(HttpMethod.Options, PingPath, "https://acme.com.attacker.com");

response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse();
}

[Fact]
public async Task Preflight_Origin_Match_Is_Case_Insensitive_Per_Rfc6454()
{
// RFC 6454 §4: scheme + host comparison is case-insensitive. Allowlist
// stored with uppercase host; browser sends lowercased Origin.
await SeedAppAsync(allowedOriginsJson: "[\"https://APP.EXAMPLE.com\"]");

var response = await SendAsync(HttpMethod.Options, PingPath, "https://app.example.com");

response.StatusCode.Should().Be(HttpStatusCode.NoContent);
response.Headers.GetValues("Access-Control-Allow-Origin")
.Should().ContainSingle().Which.Should().Be("https://app.example.com");
}

[Fact]
public async Task Request_Without_Origin_Header_Falls_Through_To_Next()
{
// Same-origin or non-browser caller: no Origin header. The middleware
// must not interfere — the terminal handler should respond normally.
await SeedAppAsync(allowedOriginsJson: $"[\"{AllowedOrigin}\"]");

var response = await SendAsync(HttpMethod.Options, PingPath, origin: null);

// Terminal pong handler returns 200 when reached.
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse();
}

[Fact]
public async Task Real_Request_With_Valid_Token_And_Allowed_Origin_Echoes_Cors_Headers()
{
var appId = await SeedAppAsync(allowedOriginsJson: $"[\"{AllowedOrigin}\"]");
var token = PortalJwtFactory.Mint(appId, capabilities: ["endpoints:read"]);

var response = await SendAsync(HttpMethod.Get, PingPath, AllowedOrigin, bearerToken: token);

response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Headers.GetValues("Access-Control-Allow-Origin")
.Should().ContainSingle().Which.Should().Be(AllowedOrigin);
response.Headers.Vary.Should().Contain("Origin");
}

[Fact]
public async Task Real_Request_With_Valid_Token_And_Disallowed_Origin_Has_No_Cors_Headers()
{
// Token is valid (request reaches the handler) but Origin is not on
// the allowlist — browser will see the 200 without a matching
// Allow-Origin and surface a CORS error, which is the correct UX.
var appId = await SeedAppAsync(allowedOriginsJson: $"[\"{AllowedOrigin}\"]");
var token = PortalJwtFactory.Mint(appId, capabilities: ["endpoints:read"]);

var response = await SendAsync(HttpMethod.Get, PingPath, DisallowedOrigin, bearerToken: token);

response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse();
}

private async Task<HttpResponseMessage> SendAsync(
HttpMethod method,
string path,
string? origin,
string? bearerToken = null)
{
var client = _factory.CreateClient();
var request = new HttpRequestMessage(method, path);
if (origin is not null)
{
request.Headers.Add("Origin", origin);
}
if (bearerToken is not null)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
}
return await client.SendAsync(request);
}

private async Task<Guid> SeedAppAsync(string? allowedOriginsJson)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<WebhookDbContext>();
await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();

var appId = Guid.NewGuid();
db.Applications.Add(new Application
{
Id = appId,
Name = $"cors-{appId:N}",
ApiKeyPrefix = $"whe_{appId:N}".Substring(0, 12) + "_",
ApiKeyHash = "deadbeef",
SigningSecret = "whsec_test",
PortalSigningKey = PortalJwtFactory.ValidSigningKey,
AllowedPortalOriginsJson = allowedOriginsJson,
IsActive = true
});
await db.SaveChangesAsync();
return appId;
}

/// <summary>
/// Production DI from <see cref="Program"/> with the application pipeline
/// reduced to the two portal middlewares plus a terminal pong handler.
/// Mirrors the production ordering: PortalTokenAuth → PortalCors → next.
/// </summary>
public sealed class CorsMiddlewareFactory : WebApplicationFactory<Program>
{
private readonly string _dbName = $"PortalCorsTestDb_{Guid.NewGuid()}";

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");

builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<WebhookDbContext>>();
services.RemoveAll<DbContextOptions>();
services.RemoveAll<WebhookDbContext>();
services.RemoveAll(typeof(IDbContextOptionsConfiguration<WebhookDbContext>));
services.RemoveAll<Microsoft.Extensions.Hosting.IHostedService>();

services.AddDbContext<WebhookDbContext>(options =>
options.UseInMemoryDatabase(_dbName));
});

builder.Configure(app =>
{
app.UseMiddleware<PortalTokenAuthMiddleware>();
app.UseMiddleware<PortalCorsMiddleware>();
app.Run(async context =>
{
if (context.Request.Path == PingPath)
{
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync("{\"message\":\"pong\"}");
return;
}

context.Response.StatusCode = StatusCodes.Status404NotFound;
});
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,137 @@ public async Task Portal_Create_With_Weak_Secret_Override_Returns_422()
response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
}

// ── Capability claim shape (defense-in-depth) ──────

[Fact]
public async Task Portal_Token_Without_Any_Capabilities_Cannot_Access_Endpoints()
{
// Silent regression guard: if a future refactor accidentally treats
// a missing `capabilities` claim as full access (e.g. by collapsing
// the HashSet check to "is null OR contains"), every read path would
// open up. This test pins the contract that absence == 403.
await ResetDatabaseAsync();
var (appId, _) = await SeedAppAsync();
var token = MintToken(appId); // zero capabilities
using var client = CreateClient(token);

var response = await client.GetAsync($"{PortalRoot}/endpoints");

response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
(await ReadErrorCodeAsync(response)).Should().Be("PORTAL_INSUFFICIENT_CAPABILITY");
}

// ── Cross-tenant: every mutating route is its own CAS surface ──

[Fact]
public async Task Portal_Cannot_Delete_Other_Apps_Endpoint()
{
// The 2-arg app-scoped GetByIdAsync inside Delete is the only check
// standing between this and a quiet cross-tenant wipe — without it
// EndpointRepository.DeleteAsync would happily 0-row the request and
// surface as a 204 No Content (silent leak).
await ResetDatabaseAsync();
var (appA, _) = await SeedAppAsync(name: "tenant-A");
var (_, otherEndpointId) = await SeedAppAndEndpointAsync(name: "tenant-B");

var tokenA = MintFullToken(appA);
using var client = CreateClient(tokenA);

var response = await client.DeleteAsync($"{PortalRoot}/endpoints/{otherEndpointId}");

response.StatusCode.Should().Be(HttpStatusCode.NotFound);
(await ReadErrorCodeAsync(response)).Should().Be("PORTAL_NOT_FOUND");

// Belt-and-braces: the row must still exist in tenant B's slice.
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<WebhookDbContext>();
(await db.Endpoints.AsNoTracking().AnyAsync(e => e.Id == otherEndpointId))
.Should().BeTrue();
}

[Fact]
public async Task Portal_Cannot_Enable_Other_Apps_Endpoint()
{
await ResetDatabaseAsync();
var (appA, _) = await SeedAppAsync(name: "tenant-A");
var (_, otherEndpointId) = await SeedAppAndEndpointAsync(name: "tenant-B");

var tokenA = MintFullToken(appA);
using var client = CreateClient(tokenA);

var response = await client.PostAsync(
$"{PortalRoot}/endpoints/{otherEndpointId}/enable",
content: null);

response.StatusCode.Should().Be(HttpStatusCode.NotFound);
(await ReadErrorCodeAsync(response)).Should().Be("PORTAL_NOT_FOUND");
}

[Fact]
public async Task Portal_Cannot_Disable_Other_Apps_Endpoint()
{
await ResetDatabaseAsync();
var (appA, _) = await SeedAppAsync(name: "tenant-A");
var (_, otherEndpointId) = await SeedAppAndEndpointAsync(name: "tenant-B");

var tokenA = MintFullToken(appA);
using var client = CreateClient(tokenA);

var response = await client.PostAsync(
$"{PortalRoot}/endpoints/{otherEndpointId}/disable",
content: null);

response.StatusCode.Should().Be(HttpStatusCode.NotFound);
(await ReadErrorCodeAsync(response)).Should().Be("PORTAL_NOT_FOUND");
}

[Fact]
public async Task Portal_Cannot_Test_Other_Apps_Endpoint()
{
// /test fires a real outbound HTTP POST (in production), so a
// cross-tenant probe here would let tenant A trigger arbitrary
// requests through tenant B's signing key. The 404 must precede any
// delivery dispatch — verified by asserting the EndpointTester fake
// never received a call.
await ResetDatabaseAsync();
// Shared NSubstitute fake survives across tests in the class fixture;
// clear any prior call history so DidNotReceive() reflects this test.
_factory.EndpointTester.ClearReceivedCalls();
var (appA, _) = await SeedAppAsync(name: "tenant-A");
var (_, otherEndpointId) = await SeedAppAndEndpointAsync(name: "tenant-B");

var tokenA = MintFullToken(appA);
using var client = CreateClient(tokenA);

var body = new { eventType = "test.event", payload = new { } };
var response = await client.PostAsJsonAsync(
$"{PortalRoot}/endpoints/{otherEndpointId}/test",
body);

response.StatusCode.Should().Be(HttpStatusCode.NotFound);
(await ReadErrorCodeAsync(response)).Should().Be("PORTAL_NOT_FOUND");
await _factory.EndpointTester.DidNotReceive()
.ExecuteAsync(Arg.Any<EndpointTestContext>(), Arg.Any<CancellationToken>());
}

[Fact]
public async Task Portal_Cannot_Read_Attempts_Of_Other_Apps_Endpoint()
{
await ResetDatabaseAsync();
var (appA, _) = await SeedAppAsync(name: "tenant-A");
var (appB, otherEndpointId) = await SeedAppAndEndpointAsync(name: "tenant-B");
await SeedAttemptsAsync(appB, otherEndpointId, count: 2);

var tokenA = MintFullToken(appA);
using var client = CreateClient(tokenA);

var response = await client.GetAsync(
$"{PortalRoot}/endpoints/{otherEndpointId}/attempts");

response.StatusCode.Should().Be(HttpStatusCode.NotFound);
(await ReadErrorCodeAsync(response)).Should().Be("PORTAL_NOT_FOUND");
}

// ── Plumbing ──────────────────────────────────────

private HttpClient CreateClient(string bearerToken)
Expand Down
Loading
Loading