Heimdall sign-in: attempt cookies live in a ring of two fixed slots, so the Cookie header is bounded by construction; pin the completion form's id

Start takes a free slot or overwrites the one expiring first; parallel starts carry the same request
cookies, choose the same slot and overwrite each other. Complete finds its attempt by scanning the
slots for the posted nonce; an overwritten attempt finds none and is refused like any other. The
per-request counting sweep, its byte budget and the nonce-named cookie are deleted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 22:25:24 +02:00
commit 15f96c408c
4 changed files with 122 additions and 105 deletions

View file

@ -58,6 +58,7 @@ public sealed class HeimdallAuthService(
string callbackBaseUrl,
LocalReturnPath returnPath,
bool requireMemberAccess,
IEnumerable<KeyValuePair<string, string>> requestCookies,
CancellationToken cancellationToken)
{
if (!AllowedProviders.Contains(provider))
@ -106,54 +107,53 @@ public sealed class HeimdallAuthService(
returnPath,
timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime);
return new HeimdallAuthStartResult(
new Uri(payload.AuthorizationUrl, UriKind.Absolute), HeimdallAttemptCookie.NameFor(nonce)!, Protect(attempt));
new Uri(payload.AuthorizationUrl, UriKind.Absolute), ChooseSlot(requestCookies), Protect(attempt));
}
/// <summary>
/// The attempt cookies Start must delete so the Cookie header the browser sends to /auth/heimdall stays under
/// the proxy's header limit: every request cookie that is no live attempt, then the live attempts that expire
/// first until the new attempt and the survivors fit <see cref="HeimdallAttemptCookie.MaxAggregateBytes"/>.
/// The new attempt is always kept.
/// The slot the new attempt's cookie takes: the first slot that holds no live attempt, else the one whose
/// attempt expires first. Parallel starts carry the same request cookies, so they choose the same slot and
/// overwrite each other; the Cookie header holds at most <see cref="HeimdallAttemptCookie.SlotNames"/> attempt
/// cookies however many starts a browser makes.
/// </summary>
public IReadOnlyList<string> SupersededAttemptCookies(
IEnumerable<KeyValuePair<string, string>> requestCookies,
HeimdallAuthStartResult start)
private string ChooseSlot(IEnumerable<KeyValuePair<string, string>> requestCookies)
{
var cookies = requestCookies.ToLookup(x => x.Key, x => x.Value, StringComparer.Ordinal);
string? oldest = null;
var oldestExpiry = DateTimeOffset.MaxValue;
foreach (var name in HeimdallAttemptCookie.SlotNames)
{
var live = Read(cookies[name].FirstOrDefault());
if (live is null)
{
return name;
}
if (live.ExpiresAt < oldestExpiry)
{
oldest = name;
oldestExpiry = live.ExpiresAt;
}
}
return oldest!;
}
/// <summary>
/// The slot cookie that holds the attempt for this nonce: its name and value, or null when the browser holds
/// none (never started here, expired, or overwritten by a later start).
/// </summary>
public (string Name, string Value)? FindAttemptCookie(IEnumerable<KeyValuePair<string, string>> requestCookies, string? nonce)
{
var used = HeimdallAttemptCookie.EntrySize(start.CookieName, start.CookieValue);
var superseded = new List<string>();
var live = new List<(string Name, int Size, DateTimeOffset ExpiresAt)>();
foreach (var (name, value) in requestCookies)
{
if (!name.StartsWith(HeimdallAttemptCookie.Prefix, StringComparison.Ordinal) ||
string.Equals(name, start.CookieName, StringComparison.Ordinal))
if (HeimdallAttemptCookie.SlotNames.Contains(name) && Unprotect(value, nonce) is not null)
{
continue;
}
var attempt = Unprotect(value, name[HeimdallAttemptCookie.Prefix.Length..]);
if (attempt is null)
{
superseded.Add(name);
}
else
{
live.Add((name, HeimdallAttemptCookie.EntrySize(name, value), attempt.ExpiresAt));
return (name, value);
}
}
foreach (var cookie in live.OrderByDescending(x => x.ExpiresAt))
{
if (used + cookie.Size <= HeimdallAttemptCookie.MaxAggregateBytes)
{
used += cookie.Size;
}
else
{
superseded.Add(cookie.Name);
}
}
return superseded;
return null;
}
public async Task<HeimdallCompletedSignIn> CompleteAsync(
@ -223,9 +223,13 @@ public sealed class HeimdallAuthService(
_protector.Protect(JsonSerializer.Serialize(new AttemptWire(
attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt)));
private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce)
private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) =>
string.IsNullOrEmpty(nonce) ? null : Read(cookieValue, nonce);
/// <summary>The live attempt a cookie value protects (for this nonce when given), else null.</summary>
private HeimdallAttempt? Read(string? cookieValue, string? nonce = null)
{
if (string.IsNullOrEmpty(cookieValue) || string.IsNullOrEmpty(nonce))
if (string.IsNullOrEmpty(cookieValue))
{
return null;
}
@ -234,7 +238,7 @@ public sealed class HeimdallAuthService(
{
var wire = JsonSerializer.Deserialize<AttemptWire>(_protector.Unprotect(cookieValue));
if (wire is null ||
!string.Equals(wire.Nonce, nonce, StringComparison.Ordinal) ||
(nonce is not null && !string.Equals(wire.Nonce, nonce, StringComparison.Ordinal)) ||
wire.ExpiresAt <= timeProvider.GetUtcNow())
{
return null;
@ -442,29 +446,20 @@ public sealed record HeimdallAccount(
[property: JsonPropertyName("id")] string Id);
/// <summary>
/// The per-attempt cookie: named for the attempt's nonce so two attempts in one browser both complete.
/// A name is only built from a well-formed nonce, so request input never becomes an arbitrary cookie name.
/// The attempt cookies: a ring of <see cref="SlotNames"/> fixed names, so the Cookie header a browser sends to
/// /auth/heimdall is bounded by construction (a worst-case attempt is about 2.4 KB; nginx's line limit is 8 KB).
/// A start takes a free slot or overwrites the one expiring first; an overwritten attempt finds no cookie and
/// is refused. The attempt's nonce lives inside the protected value, never in the cookie name.
/// </summary>
public static class HeimdallAttemptCookie
{
public const string Prefix = "bifrost_heimdall_";
public const int NonceLength = 43;
public const int SlotCount = 2;
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10);
/// <summary>
/// The most bytes of attempt cookies a browser may hold at once. One worst-case cookie is about 2.4 KB, and
/// the Cookie header must stay under nginx's 8 KB with room for the session and antiforgery cookies.
/// </summary>
public const int MaxAggregateBytes = 5120;
/// <summary>A cookie's bytes in a Cookie header: name, '=', value and the '; ' separator.</summary>
public static int EntrySize(string name, string value) => name.Length + 1 + value.Length + 2;
public static string? NameFor(string? nonce) =>
nonce is { Length: NonceLength } && nonce.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_')
? Prefix + nonce
: null;
public static readonly IReadOnlyList<string> SlotNames =
Enumerable.Range(0, SlotCount).Select(x => Prefix + x).ToArray();
public static CookieOptions Options() => new()
{

View file

@ -378,13 +378,9 @@ app.MapGet("/auth/heimdall/{provider}", async (
hostOptions.Value.PublicBaseUrl,
returnPath,
requireMemberAccess,
httpContext.Request.Cookies,
cancellationToken);
foreach (var superseded in heimdallAuthService.SupersededAttemptCookies(httpContext.Request.Cookies, start))
{
httpContext.Response.Cookies.Delete(superseded, HeimdallAttemptCookie.DeleteOptions());
}
httpContext.Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options());
return Results.Redirect(start.AuthorizationUrl.ToString());
}
@ -533,25 +529,25 @@ app.MapPost("/auth/heimdall/complete", async (
{
var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken);
var attempt = form?["attempt"].ToString();
var cookieName = HeimdallAttemptCookie.NameFor(attempt);
var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt);
try
{
var signIn = await heimdallAuthService.CompleteAsync(
hostOptions.Value.PublicBaseUrl,
attempt,
cookieName is null ? null : httpContext.Request.Cookies[cookieName],
held?.Value,
form?["code"].ToString(),
cancellationToken);
await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal);
httpContext.Response.Cookies.Delete(cookieName!, HeimdallAttemptCookie.DeleteOptions());
httpContext.Response.Cookies.Delete(held!.Value.Name, HeimdallAttemptCookie.DeleteOptions());
return Results.LocalRedirect(signIn.ReturnPath.Value);
}
catch (HeimdallAuthException error)
{
logger.LogWarning("Heimdall completion refused: {Reason}", error.Message);
if (cookieName is not null)
if (held is not null)
{
httpContext.Response.Cookies.Delete(cookieName, HeimdallAttemptCookie.DeleteOptions());
httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions());
}
return Results.Redirect(HeimdallAuthService.ErrorRedirect);

View file

@ -80,7 +80,7 @@ public sealed class HeimdallCompletionTests
Assert.Equal("Genuine Name", account!.DisplayName);
Assert.Equal(MembershipStatus.Authenticated, account.Membership!.Status);
Assert.Empty(account.Membership.RoleAssignments);
var cleared = HeimdallHost.SetCookieHeader(done.Response, HeimdallAttemptCookie.Prefix + attempt.Nonce);
var cleared = HeimdallHost.SetCookieHeader(done.Response, attempt.CookieName!);
Assert.NotNull(cleared);
Assert.Contains("1970", cleared);
@ -515,7 +515,7 @@ public sealed class HeimdallCompletionTests
Assert.Equal("https://heimdall.test/oauth/discord/authorize?state=fixture", first.Response.Headers.Location!.OriginalString);
Assert.Equal(43, first.Nonce.Length);
Assert.NotEqual(first.Nonce, second.Nonce);
var header = HeimdallHost.SetCookieHeader(first.Response, "bifrost_heimdall_" + first.Nonce);
var header = HeimdallHost.SetCookieHeader(first.Response, first.CookieName!);
Assert.NotNull(header);
var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray();
Assert.Contains("httponly", attributes);
@ -689,7 +689,7 @@ public sealed class HeimdallCompletionTests
"Heimdall completion has no attempt for this browser."),
("bad cookie", await host.CompleteAsync(attempt.Nonce, host.CodeFor(attempt, accountId), Corrupt(attempt.Cookie!)),
"Heimdall completion has no attempt for this browser."),
("no attempt field", await host.CompleteAsync(null, "code", attempt.Cookie, HeimdallAttemptCookie.Prefix + attempt.Nonce),
("no attempt field", await host.CompleteAsync(null, "code", attempt.Cookie),
"Heimdall completion has no attempt for this browser."),
("malformed attempt", await host.CompleteAsync("short", "code", attempt.Cookie, "bifrost_heimdall_short"),
"Heimdall completion has no attempt for this browser."),
@ -1174,9 +1174,8 @@ public sealed class HeimdallCompletionTests
Assert.DoesNotContain(canary, logs);
}
private static void AssertAttemptCookieExpired(HttpResponseMessage response, string nonce)
private static void AssertAttemptCookieExpired(HttpResponseMessage response, string name)
{
var name = HeimdallAttemptCookie.Prefix + nonce;
var header = HeimdallHost.SetCookieHeader(response, name);
Assert.NotNull(header);
Assert.StartsWith(name + "=;", header);
@ -1231,7 +1230,7 @@ public sealed class HeimdallCompletionTests
Assert.Equal("/App", overSignIn.Response.Headers.Location!.OriginalString);
var heaviest = await host.StartAsync(returnUrl: worstCase);
var header = HeimdallHost.SetCookieHeader(heaviest.Response, HeimdallAttemptCookie.Prefix + heaviest.Nonce)!;
var header = HeimdallHost.SetCookieHeader(heaviest.Response, heaviest.CookieName!)!;
Assert.InRange(header.Split(';')[0].Length, 1, 3072);
}
@ -1278,62 +1277,80 @@ public sealed class HeimdallCompletionTests
}
[Fact]
public async Task Attempts_opened_past_the_cookie_bound_keep_the_header_small_and_the_newest_completes()
public async Task Parallel_starts_cannot_push_the_Cookie_header_past_the_bound_and_the_last_start_completes()
{
using var host = new HeimdallHost();
var worstCase = "/" + new string('<', LocalReturnPath.MaxLength - 1);
var jar = new Dictionary<string, string>();
var started = new List<StartedAttempt>();
var deletions = 0;
var largest = 0;
for (var i = 0; i < 12; i++)
// Rounds of parallel starts: every start of a round carries the same Cookie header, as a browser's
// simultaneous requests do before any Set-Cookie lands.
for (var round = 0; round < 3; round++)
{
host.Clock.Advance(TimeSpan.FromSeconds(1));
var attempt = await host.StartAsync(returnUrl: worstCase, cookieHeader: jar.Count == 0 ? null : CookieHeader(jar));
Assert.NotNull(attempt.Cookie);
deletions += Deletions(attempt.Response);
ApplySetCookies(jar, attempt.Response);
started.Add(attempt);
Assert.True(CookieHeader(jar).Length <= 6144, $"Cookie header is {CookieHeader(jar).Length} bytes after start {i}");
Assert.Contains(HeimdallAttemptCookie.Prefix + attempt.Nonce, jar.Keys);
var header = jar.Count == 0 ? null : CookieHeader(jar);
var parallel = new List<StartedAttempt>();
for (var i = 0; i < 6; i++)
{
host.Clock.Advance(TimeSpan.FromSeconds(1));
parallel.Add(await host.StartAsync(returnUrl: worstCase, cookieHeader: header));
}
foreach (var attempt in parallel)
{
Assert.NotNull(attempt.Cookie);
Assert.Equal(0, Deletions(attempt.Response));
ApplySetCookies(jar, attempt.Response);
largest = Math.Max(largest, CookieHeader(jar).Length);
}
// The browser holds the last-started attempt and it completes; an overwritten one fails cleanly.
var last = parallel[^1];
var overwritten = parallel[0];
var lost = await host.CompleteWithHeaderAsync(
overwritten.Nonce, host.CodeFor(overwritten, HeimdallShapes.NewAccountId()), CookieHeader(jar));
Assert.False(lost.SignedIn);
Assert.Equal(HttpStatusCode.Redirect, lost.Response.StatusCode);
Assert.Equal(ErrorRedirect, lost.Response.Headers.Location!.OriginalString);
Assert.DoesNotContain(overwritten.Nonce, string.Join("\n", lost.Response.Headers.SelectMany(x => x.Value)));
var done = await host.CompleteWithHeaderAsync(
last.Nonce, host.CodeFor(last, HeimdallShapes.NewAccountId()), CookieHeader(jar));
Assert.True(done.SignedIn);
ApplySetCookies(jar, done.Response);
}
Assert.True(deletions > 0, "the bound was never reached");
Assert.True(jar.Count < started.Count);
// The oldest attempts were the ones given up: the newest completes, the first has no cookie left.
Assert.True((await Genuine(host, started[^1], HeimdallShapes.NewAccountId())).SignedIn);
Assert.DoesNotContain(HeimdallAttemptCookie.Prefix + started[0].Nonce, jar.Keys);
Assert.Contains(HeimdallAttemptCookie.Prefix + started[^2].Nonce, jar.Keys);
Assert.True(largest <= 6144, $"Cookie header reached {largest} bytes");
Assert.True(largest > 4096, "the probe never held two worst-case attempts");
}
[Fact]
public async Task Small_attempts_all_survive_under_the_cookie_bound_and_dead_cookies_are_swept()
public async Task A_third_sequential_attempt_takes_the_oldest_slot_and_the_others_still_complete()
{
using var host = new HeimdallHost();
var jar = new Dictionary<string, string>();
var started = new List<StartedAttempt>();
for (var i = 0; i < 5; i++)
for (var i = 0; i < 3; i++)
{
host.Clock.Advance(TimeSpan.FromSeconds(1));
var unrelated = ".AspNetCore.Cookies=session; x=1";
var attempt = await host.StartAsync(
returnUrl: "/App", cookieHeader: jar.Count == 0 ? unrelated : CookieHeader(jar) + "; " + unrelated);
Assert.NotNull(attempt.Cookie);
Assert.Equal(0, Deletions(attempt.Response));
returnUrl: "/App", cookieHeader: (jar.Count == 0 ? "" : CookieHeader(jar) + "; ") + ".AspNetCore.Cookies=session; x=1");
ApplySetCookies(jar, attempt.Response);
started.Add(attempt);
}
Assert.Equal(5, jar.Count);
foreach (var attempt in started)
{
Assert.True((await Genuine(host, attempt, HeimdallShapes.NewAccountId())).SignedIn);
}
Assert.Equal(HeimdallAttemptCookie.SlotCount, jar.Keys.Count(x => x.StartsWith(HeimdallAttemptCookie.Prefix, StringComparison.Ordinal)));
Assert.Equal(started[0].CookieName, started[2].CookieName);
var deadName = HeimdallAttemptCookie.Prefix + new string('A', HeimdallAttemptCookie.NonceLength);
var next = await host.StartAsync(cookieHeader: deadName + "=not-protected");
Assert.Contains("1970", HeimdallHost.SetCookieHeader(next.Response, deadName));
var oldest = await host.CompleteWithHeaderAsync(
started[0].Nonce, host.CodeFor(started[0], HeimdallShapes.NewAccountId()), CookieHeader(jar));
Assert.False(oldest.SignedIn);
Assert.Equal(ErrorRedirect, oldest.Response.Headers.Location!.OriginalString);
foreach (var attempt in started.Skip(1))
{
var done = await host.CompleteWithHeaderAsync(
attempt.Nonce, host.CodeFor(attempt, HeimdallShapes.NewAccountId()), CookieHeader(jar));
Assert.True(done.SignedIn);
}
}
[Fact]
@ -1346,6 +1363,9 @@ public sealed class HeimdallCompletionTests
var form = Regex.Match(page, @"<form\b([^>]*)>(.*?)</form>", RegexOptions.Singleline);
Assert.True(form.Success);
Assert.Matches(@"\bmethod=""post""", form.Groups[1].Value);
var formId = Regex.Match(form.Groups[1].Value, @"\bid=""([^""]*)""").Groups[1].Value;
Assert.NotEmpty(formId);
Assert.Contains($"getElementById(\"{formId}\")", page);
var action = Regex.Match(form.Groups[1].Value, @"\baction=""([^""]*)""").Groups[1].Value;
Assert.Equal("/auth/heimdall/complete", action);
var inputs = Regex.Matches(form.Groups[2].Value, @"<input\b([^>]*)>");
@ -1358,7 +1378,7 @@ public sealed class HeimdallCompletionTests
var code = host.CodeFor(attempt, HeimdallShapes.NewAccountId());
var fields = new Dictionary<string, string> { [names[0]] = attempt.Nonce, [names[1]] = code };
using var post = new HttpRequestMessage(HttpMethod.Post, action) { Content = new FormUrlEncodedContent(fields) };
post.Headers.Add("Cookie", $"{HeimdallAttemptCookie.Prefix + attempt.Nonce}={attempt.Cookie}");
post.Headers.Add("Cookie", $"{attempt.CookieName}={attempt.Cookie}");
using var response = await client.SendAsync(post);
Assert.False(string.IsNullOrEmpty(HeimdallHost.CookieValue(response, ".AspNetCore.Cookies")));
}

View file

@ -337,7 +337,7 @@ public sealed class CapturedLogs : ILoggerProvider
}
/// <summary>An attempt Start issued: its nonce, the cookie it set, and the returnTo it sent Heimdall.</summary>
public sealed record StartedAttempt(string Nonce, string? Cookie, string ReturnTo, HttpResponseMessage Response);
public sealed record StartedAttempt(string Nonce, string? CookieName, string? Cookie, string ReturnTo, HttpResponseMessage Response);
public sealed record CompletionResult(HttpResponseMessage Response, bool SignedIn);
@ -437,12 +437,13 @@ public sealed class HeimdallHost : TestWebApplicationFactory
var response = await client.SendAsync(request);
if (Heimdall.StartBodies.Count == before)
{
return new StartedAttempt(string.Empty, null, string.Empty, response);
return new StartedAttempt(string.Empty, null, null, string.Empty, response);
}
var returnTo = JsonDocument.Parse(Heimdall.StartBodies[^1]).RootElement.GetProperty("returnTo").GetString()!;
var nonce = QueryHelpers.ParseQuery(new Uri(returnTo).Query)["attempt"].ToString();
return new StartedAttempt(nonce, CookieValue(response, HeimdallAttemptCookie.Prefix + nonce), returnTo, response);
var slot = HeimdallAttemptCookie.SlotNames.FirstOrDefault(x => CookieValue(response, x) is { Length: > 0 });
return new StartedAttempt(nonce, slot, slot is null ? null : CookieValue(response, slot), returnTo, response);
}
/// <summary>Registers a completion code as Heimdall would mint it after authenticating this attempt.</summary>
@ -455,7 +456,12 @@ public sealed class HeimdallHost : TestWebApplicationFactory
}
/// <summary>The browser's POST from the complete page: the attempt and code in a form, the attempt cookie by name.</summary>
public async Task<CompletionResult> CompleteAsync(string? nonce, string? code, string? cookie, string? cookieName = null)
public Task<CompletionResult> CompleteAsync(string? nonce, string? code, string? cookie, string? cookieName = null) =>
CompleteWithHeaderAsync(
nonce, code, cookie is null ? null : $"{cookieName ?? HeimdallAttemptCookie.SlotNames[0]}={cookie}");
/// <summary>The same POST carrying a browser's whole Cookie header (a jar), not one attempt cookie.</summary>
public async Task<CompletionResult> CompleteWithHeaderAsync(string? nonce, string? code, string? cookieHeader)
{
using var client = NewClient();
var fields = new Dictionary<string, string>();
@ -470,9 +476,9 @@ public sealed class HeimdallHost : TestWebApplicationFactory
}
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/heimdall/complete") { Content = new FormUrlEncodedContent(fields) };
if (cookie is not null)
if (cookieHeader is not null)
{
request.Headers.Add("Cookie", $"{cookieName ?? HeimdallAttemptCookie.Prefix + nonce}={cookie}");
request.Headers.Add("Cookie", cookieHeader);
}
var response = await client.SendAsync(request);
@ -481,7 +487,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory
}
public Task<CompletionResult> CompleteAsync(StartedAttempt attempt, string code) =>
CompleteAsync(attempt.Nonce, code, attempt.Cookie);
CompleteAsync(attempt.Nonce, code, attempt.Cookie, attempt.CookieName);
public static string? CookieValue(HttpResponseMessage response, string name)
{