Skip to content

Commit 53ec974

Browse files
vbilopavclaude
andcommitted
test: regression for claim-mapped param in cache key
Verifies that when a parameter is auto-populated from a claim (via ParameterNameClaimsMapping + user_parameters annotation), the cache key uses the resolved post-claim value, not the request-time null. Two different users get separate cache entries; same user hits cache. Catches a hypothetical security regression where the cache could leak responses across identities if the key were built before claim resolution. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 55dc86b commit 53ec974

2 files changed

Lines changed: 199 additions & 0 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using NpgsqlRestTests.Setup;
2+
3+
namespace NpgsqlRestTests;
4+
5+
public static partial class Database
6+
{
7+
/// <summary>
8+
/// Setup for CacheKeyClaimMappedParamTest.
9+
///
10+
/// Creates a function that takes `_user_id text` and returns "<user_id>:<random uuid>".
11+
/// The endpoint uses:
12+
/// - `user_parameters` → enables auto-population of `_user_id` from the `name_identifier` claim
13+
/// (per fixture's `ParameterNameClaimsMapping` mapping `_user_id` → `name_identifier`).
14+
/// - `cached _user_id` → caches the response, keyed by the resolved `_user_id` value.
15+
/// - `authorize` → requires authenticated user.
16+
///
17+
/// `gen_random_uuid()` makes each fresh execution distinct. So:
18+
/// - cache hit → both calls return the same response (same UUID).
19+
/// - cache miss → second call returns a different UUID.
20+
/// We use this to detect both whether the cache works AND whether two different users get
21+
/// separate cache entries (which only happens if the resolved claim value is part of the key).
22+
/// </summary>
23+
public static void CacheKeyClaimMappedParamTest()
24+
{
25+
script.Append(@"
26+
create function cct_get_cached_per_user(_user_id text)
27+
returns text language plpgsql as $$
28+
begin
29+
return _user_id || ':' || gen_random_uuid()::text;
30+
end;
31+
$$;
32+
comment on function cct_get_cached_per_user(text) is '
33+
HTTP GET
34+
authorize
35+
user_parameters
36+
cached _user_id
37+
';
38+
");
39+
}
40+
}
41+
42+
[Collection("CacheClaimTestFixture")]
43+
public class CacheKeyClaimMappedParamTest(CacheClaimTestFixture test)
44+
{
45+
/// <summary>
46+
/// Regression test: when a parameter is auto-populated from a claim (via
47+
/// `ParameterNameClaimsMapping` + `user_parameters` annotation), the cache key must use the
48+
/// resolved post-claim value — NOT the request-time null. Otherwise two different users
49+
/// would share a single cache entry, leaking responses across identities.
50+
///
51+
/// Verifies four things in one test:
52+
/// 1. user_a's first call populates the cache and returns a fresh response.
53+
/// 2. user_a's second call hits the cache (identical response — same UUID).
54+
/// 3. user_b's first call does NOT hit user_a's entry (different starts: "user_b:" vs "user_a:")
55+
/// and is itself cached.
56+
/// 4. user_a's third call still hits user_a's original entry (separate from user_b's).
57+
///
58+
/// If step 3 returns user_a's value, the cache key was built before claim resolution and the
59+
/// cache is leaking across identities — a security bug. Test catches this.
60+
/// </summary>
61+
[Fact]
62+
public async Task Cache_key_uses_claim_resolved_user_id_so_different_users_get_separate_entries()
63+
{
64+
using var clientA = test.CreateClient();
65+
using var clientB = test.CreateClient();
66+
67+
// user_a logs in
68+
using var loginA = await clientA.GetAsync("/cct-login-a");
69+
loginA.StatusCode.Should().Be(HttpStatusCode.OK);
70+
71+
// user_b logs in (separate cookie container — independent identity)
72+
using var loginB = await clientB.GetAsync("/cct-login-b");
73+
loginB.StatusCode.Should().Be(HttpStatusCode.OK);
74+
75+
// user_a — first call, populates cache
76+
using var responseA1 = await clientA.GetAsync("/api/cct-get-cached-per-user");
77+
responseA1.StatusCode.Should().Be(HttpStatusCode.OK);
78+
var bodyA1 = await responseA1.Content.ReadAsStringAsync();
79+
bodyA1.Should().StartWith("user_a:");
80+
81+
// user_a — second call, must hit cache (same exact response, including UUID)
82+
using var responseA2 = await clientA.GetAsync("/api/cct-get-cached-per-user");
83+
responseA2.StatusCode.Should().Be(HttpStatusCode.OK);
84+
var bodyA2 = await responseA2.Content.ReadAsStringAsync();
85+
bodyA2.Should().Be(bodyA1);
86+
87+
// user_b — first call. If the cache key was built without the claim value (i.e. with null),
88+
// user_b would hit user_a's cached entry and we'd see "user_a:..." here. We assert otherwise.
89+
using var responseB1 = await clientB.GetAsync("/api/cct-get-cached-per-user");
90+
responseB1.StatusCode.Should().Be(HttpStatusCode.OK);
91+
var bodyB1 = await responseB1.Content.ReadAsStringAsync();
92+
bodyB1.Should().StartWith("user_b:");
93+
bodyB1.Should().NotBe(bodyA1);
94+
95+
// user_b — second call hits user_b's own cache entry
96+
using var responseB2 = await clientB.GetAsync("/api/cct-get-cached-per-user");
97+
responseB2.StatusCode.Should().Be(HttpStatusCode.OK);
98+
var bodyB2 = await responseB2.Content.ReadAsStringAsync();
99+
bodyB2.Should().Be(bodyB1);
100+
101+
// user_a — third call still returns the original cached value, separate from user_b's
102+
using var responseA3 = await clientA.GetAsync("/api/cct-get-cached-per-user");
103+
responseA3.StatusCode.Should().Be(HttpStatusCode.OK);
104+
var bodyA3 = await responseA3.Content.ReadAsStringAsync();
105+
bodyA3.Should().Be(bodyA1);
106+
}
107+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System.Security.Claims;
2+
using Microsoft.AspNetCore.Authentication.Cookies;
3+
using Microsoft.AspNetCore.Builder;
4+
using Microsoft.AspNetCore.Hosting;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using NpgsqlRest.Auth;
8+
9+
namespace NpgsqlRestTests.Setup;
10+
11+
[CollectionDefinition("CacheClaimTestFixture")]
12+
public class CacheClaimTestFixtureCollection : ICollectionFixture<CacheClaimTestFixture> { }
13+
14+
/// <summary>
15+
/// Fixture for verifying that cache keys correctly include claim-resolved parameter values.
16+
/// Provides two distinct login endpoints (`/cct-login-a` → user_a, `/cct-login-b` → user_b)
17+
/// so a single test can switch between identities and observe whether the cache distinguishes them.
18+
///
19+
/// Cache backend: in-memory `RoutineCache` (default). User-parameters mapping is configured so
20+
/// the `_user_id` parameter is auto-populated from the `name_identifier` claim when an endpoint
21+
/// has the `user_parameters` annotation.
22+
/// </summary>
23+
public class CacheClaimTestFixture : IDisposable
24+
{
25+
private readonly WebApplication _app;
26+
27+
public string ServerAddress { get; }
28+
29+
public CacheClaimTestFixture()
30+
{
31+
var connectionString = Database.Create();
32+
33+
var builder = WebApplication.CreateBuilder();
34+
builder.WebHost.UseUrls("http://127.0.0.1:0");
35+
36+
builder.Services.AddAuthentication().AddCookie();
37+
38+
_app = builder.Build();
39+
40+
// Two distinct identities. Each /login route signs in as one user and sets the auth cookie.
41+
_app.MapGet("/cct-login-a", () => Results.SignIn(new ClaimsPrincipal(new ClaimsIdentity(
42+
claims: new[] { new Claim("name_identifier", "user_a") },
43+
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme))));
44+
45+
_app.MapGet("/cct-login-b", () => Results.SignIn(new ClaimsPrincipal(new ClaimsIdentity(
46+
claims: new[] { new Claim("name_identifier", "user_b") },
47+
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme))));
48+
49+
_app.UseNpgsqlRest(new(connectionString)
50+
{
51+
IncludeSchemas = ["public"],
52+
NameSimilarTo = "cct_%",
53+
CommentsMode = CommentsMode.ParseAll,
54+
RequiresAuthorization = false,
55+
AuthenticationOptions = new()
56+
{
57+
DefaultUserIdClaimType = "name_identifier",
58+
ParameterNameClaimsMapping = new()
59+
{
60+
{ "_user_id", "name_identifier" }
61+
},
62+
},
63+
CacheOptions = new()
64+
{
65+
DefaultRoutineCache = new RoutineCache(),
66+
MemoryCachePruneIntervalSeconds = 3600
67+
}
68+
});
69+
70+
_app.StartAsync().GetAwaiter().GetResult();
71+
72+
ServerAddress = _app.Urls.First();
73+
}
74+
75+
/// <summary>
76+
/// Creates a fresh HttpClient bound to this fixture. Each client has its own cookie container,
77+
/// so two clients can hold independent auth cookies (one signed in as user_a, the other as user_b).
78+
/// </summary>
79+
public HttpClient CreateClient()
80+
{
81+
var handler = new HttpClientHandler { UseCookies = true, CookieContainer = new System.Net.CookieContainer() };
82+
return new HttpClient(handler) { BaseAddress = new Uri(ServerAddress), Timeout = TimeSpan.FromMinutes(5) };
83+
}
84+
85+
#pragma warning disable CA1816
86+
public void Dispose()
87+
#pragma warning restore CA1816
88+
{
89+
_app.StopAsync().GetAwaiter().GetResult();
90+
_app.DisposeAsync().GetAwaiter().GetResult();
91+
}
92+
}

0 commit comments

Comments
 (0)