From c757acf525da1d60406ba0386f22c36a9f75ba85 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 7 Nov 2026 18:42:43 +0000 Subject: [PATCH 1/2] Make every cookie host-only: __Host- names, Secure, Path=/, no Domain Session, OAuth correlation, antiforgery, voter and Heimdall attempt cookies all take the __Host- prefix. The prefix forbids a narrower Path, so the voter (was /mill) and attempt (was /auth/heimdall) cookies move to Path=/. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/ConceptVotes/VoterKeyResolver.cs | 5 +- .../Features/Heimdall/HeimdallAuthService.cs | 8 +- .../Features/Shared/BifrostCookies.cs | 17 +++ src/Bifrost.Web/Program.cs | 14 +++ .../Bifrost.Web.Tests/ConceptVotePageTests.cs | 5 +- .../ConceptVoteRateLimitTests.cs | 3 +- .../Bifrost.Web.Tests/CookieHardeningTests.cs | 101 ++++++++++++++++++ .../HeimdallCompletionTests.cs | 18 ++-- .../Support/HeimdallTestKit.cs | 3 +- 9 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 src/Bifrost.Web/Features/Shared/BifrostCookies.cs create mode 100644 tests/Bifrost.Web.Tests/CookieHardeningTests.cs diff --git a/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs b/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs index ce9fbec..2948700 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.DataProtection; namespace Bifrost.Web.Features.ConceptVotes; @@ -7,8 +8,8 @@ namespace Bifrost.Web.Features.ConceptVotes; // decides the voter key and class. The cookie holds a protected random id, never a count, class or account. public sealed class VoterKeyResolver(IDataProtectionProvider dataProtection) { - public const string CookieName = "bifrost_voter"; - public const string CookiePath = "/mill"; + public const string CookieName = BifrostCookies.Voter; + public const string CookiePath = BifrostCookies.Path; public static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(365); private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1"; diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 3e0b3a1..7749402 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -446,13 +446,13 @@ public sealed record HeimdallAccount( /// /// The attempt cookies: a ring of 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). +/// every path is bounded by construction (the __Host- prefix forces Path=/; 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. /// public static class HeimdallAttemptCookie { - public const string Prefix = "bifrost_heimdall_"; + public const string Prefix = BifrostCookies.HeimdallAttemptPrefix; public const int SlotCount = 2; public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10); @@ -465,7 +465,7 @@ public static class HeimdallAttemptCookie HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax, - Path = "/auth/heimdall", + Path = BifrostCookies.Path, MaxAge = Lifetime }; @@ -473,5 +473,5 @@ public static class HeimdallAttemptCookie /// The options a deletion carries: only the attempt cookie's Path, which a cookie is matched on. It carries no /// Max-Age, which would take precedence over the expiry Delete writes and leave the emptied cookie alive. /// - public static CookieOptions DeleteOptions() => new() { Path = "/auth/heimdall" }; + public static CookieOptions DeleteOptions() => new() { Path = BifrostCookies.Path }; } diff --git a/src/Bifrost.Web/Features/Shared/BifrostCookies.cs b/src/Bifrost.Web/Features/Shared/BifrostCookies.cs new file mode 100644 index 0000000..1c099e8 --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/BifrostCookies.cs @@ -0,0 +1,17 @@ +namespace Bifrost.Web.Features.Shared; + +// Every cookie Bifrost sets carries the __Host- prefix: the browser then accepts it only with Secure, Path=/ and no +// Domain, so no sibling subdomain of gamecult.org (the preview host is same-site) can plant or shadow one by setting +// a Domain=gamecult.org cookie of the same name. The prefix forbids a narrower Path, so these all live at Path=/. +public static class BifrostCookies +{ + public const string HostPrefix = "__Host-"; + + public const string Session = HostPrefix + "bifrost_session"; + public const string OAuthCorrelation = HostPrefix + "bifrost_oauth_correlation"; + public const string Antiforgery = HostPrefix + "bifrost_antiforgery"; + public const string Voter = HostPrefix + "bifrost_voter"; + public const string HeimdallAttemptPrefix = HostPrefix + "bifrost_heimdall_"; + + public const string Path = "/"; +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index d0656d3..c8a524a 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -76,6 +76,13 @@ builder.Services .AddOptions() .BindConfiguration(ConceptVoteOptions.SectionName); +builder.Services.AddAntiforgery(options => +{ + options.Cookie.Name = BifrostCookies.Antiforgery; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.Path = BifrostCookies.Path; +}); + builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); @@ -167,6 +174,10 @@ var authenticationBuilder = builder.Services .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { + options.Cookie.Name = BifrostCookies.Session; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.Path = BifrostCookies.Path; + options.Cookie.Domain = null; options.LoginPath = "/auth/sign-in"; options.AccessDeniedPath = "/Membership/Status"; options.SlidingExpiration = true; @@ -183,6 +194,9 @@ if (configuredGitHubOAuthOptions.IsConfigured) options.TokenEndpoint = "https://github.com/login/oauth/access_token"; options.UserInformationEndpoint = "https://api.github.com/user"; options.SaveTokens = true; + options.CorrelationCookie.Name = BifrostCookies.OAuthCorrelation; + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; + options.CorrelationCookie.Path = BifrostCookies.Path; options.Scope.Add("read:user"); options.Scope.Add("user:email"); options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id"); diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 5434e8d..d0675d7 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -1,3 +1,4 @@ +using Bifrost.Web.Features.Shared; using System.Net; using System.Text.RegularExpressions; using Bifrost.Web.Data; @@ -14,7 +15,7 @@ namespace Bifrost.Web.Tests; public sealed partial class ConceptVotePageTests : IClassFixture { - private const string CookieName = "bifrost_voter"; + private const string CookieName = BifrostCookies.Voter; private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1"; private readonly TestWebApplicationFactory _factory; @@ -40,7 +41,7 @@ public sealed partial class ConceptVotePageTests : IClassFixture { private const string Site = "https://localhost"; - private const string CookieName = "bifrost_voter"; + private const string CookieName = BifrostCookies.Voter; private readonly LimitedFactory _factory; diff --git a/tests/Bifrost.Web.Tests/CookieHardeningTests.cs b/tests/Bifrost.Web.Tests/CookieHardeningTests.cs new file mode 100644 index 0000000..3a43ebc --- /dev/null +++ b/tests/Bifrost.Web.Tests/CookieHardeningTests.cs @@ -0,0 +1,101 @@ +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +// Every cookie is host-only: __Host- name, Secure, Path=/, no Domain. These pin the literals on purpose, so removing +// the prefix, Secure or Path=/ from any of them fails here and not only in a test that reads the same constant. +public sealed class CookieHardeningTests(CookieHardeningTests.OAuthConfiguredFactory factory) + : IClassFixture +{ + // Program reads GitHubOAuth at startup, before the base factory's in-memory settings land, so the GitHub scheme + // is only registered when the settings arrive as host settings. + public sealed class OAuthConfiguredFactory : TestWebApplicationFactory + { + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + builder.UseSetting("GitHubOAuth:ClientId", "configured-for-tests"); + builder.UseSetting("GitHubOAuth:ClientSecret", "configured-for-tests"); + base.ConfigureWebHost(builder); + } + } + + [Fact] + public void Cookie_names_carry_the_host_prefix_and_live_at_the_root() + { + Assert.Equal("__Host-bifrost_session", BifrostCookies.Session); + Assert.Equal("__Host-bifrost_oauth_correlation", BifrostCookies.OAuthCorrelation); + Assert.Equal("__Host-bifrost_antiforgery", BifrostCookies.Antiforgery); + Assert.Equal("__Host-bifrost_voter", VoterKeyResolver.CookieName); + Assert.Equal("__Host-bifrost_heimdall_", HeimdallAttemptCookie.Prefix); + Assert.All(HeimdallAttemptCookie.SlotNames, x => Assert.StartsWith("__Host-", x, StringComparison.Ordinal)); + Assert.Equal("/", BifrostCookies.Path); + Assert.Equal("/", VoterKeyResolver.CookiePath); + } + + [Fact] + public void Session_cookie_is_host_only() + { + var cookie = factory.Services.GetRequiredService>() + .Get(CookieAuthenticationDefaults.AuthenticationScheme).Cookie; + AssertHostOnly(cookie.Name, cookie.SecurePolicy, cookie.Path, cookie.Domain); + Assert.True(cookie.HttpOnly); + } + + [Fact] + public void Oauth_correlation_cookie_is_host_only() + { + var cookie = factory.Services.GetRequiredService>().Get("GitHub").CorrelationCookie; + AssertHostOnly(cookie.Name, cookie.SecurePolicy, cookie.Path, cookie.Domain); + Assert.True(cookie.HttpOnly); + } + + [Fact] + public void Antiforgery_cookie_is_host_only() + { + var cookie = factory.Services.GetRequiredService>().Value.Cookie; + AssertHostOnly(cookie.Name, cookie.SecurePolicy, cookie.Path, cookie.Domain); + } + + [Fact] + public void Voter_and_attempt_cookies_set_secure_root_path_and_no_domain() + { + var context = new DefaultHttpContext(); + new VoterKeyResolver(new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider()).Mint(context); + context.Response.Cookies.Append("__Host-bifrost_heimdall_0", "v", HeimdallAttemptCookie.Options()); + var deletion = new DefaultHttpContext(); + deletion.Response.Cookies.Delete("__Host-bifrost_heimdall_0", HeimdallAttemptCookie.DeleteOptions()); + + var headers = context.Response.Headers.SetCookie.Concat(deletion.Response.Headers.SetCookie).ToArray(); + Assert.Equal(3, headers.Length); + foreach (var header in headers) + { + var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray(); + Assert.StartsWith("__host-", attributes[0], StringComparison.Ordinal); + Assert.Contains("path=/", attributes); + Assert.DoesNotContain(attributes, x => x.StartsWith("domain", StringComparison.Ordinal)); + } + + Assert.All(headers.Take(2), header => + { + Assert.Contains("secure", header!.Split(';').Select(x => x.Trim().ToLowerInvariant())); + Assert.Contains("httponly", header!.Split(';').Select(x => x.Trim().ToLowerInvariant())); + }); + } + + private static void AssertHostOnly(string? name, CookieSecurePolicy secure, string? path, string? domain) + { + Assert.StartsWith("__Host-", name, StringComparison.Ordinal); + Assert.Equal(CookieSecurePolicy.Always, secure); + Assert.Equal("/", path); + Assert.Null(domain); + } +} diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs index 3439b43..68f0a94 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -526,7 +526,7 @@ public sealed class HeimdallCompletionTests Assert.Contains("httponly", attributes); Assert.Contains("secure", attributes); Assert.Contains("samesite=lax", attributes); - Assert.Contains("path=/auth/heimdall", attributes); + Assert.Contains("path=/", attributes); Assert.Contains("max-age=600", attributes); Assert.NotEqual(first.Cookie, second.Cookie); Assert.DoesNotContain(first.Nonce, first.Cookie!); @@ -564,7 +564,7 @@ public sealed class HeimdallCompletionTests Assert.Contains("