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..0d39c2f 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,13 +465,14 @@ public static class HeimdallAttemptCookie
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
- Path = "/auth/heimdall",
+ Path = BifrostCookies.Path,
MaxAge = Lifetime
};
///
- /// 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.
+ /// The options a deletion carries: the attempt cookie's Path, which a cookie is matched on, and Secure, without
+ /// which a browser ignores a Set-Cookie for a __Host- name. 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, Secure = true };
}
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..287e8ac
--- /dev/null
+++ b/tests/Bifrost.Web.Tests/CookieHardeningTests.cs
@@ -0,0 +1,100 @@
+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));
+ }
+
+ // A browser ignores a __Host- Set-Cookie that lacks Secure, a deletion included.
+ Assert.All(headers, header => Assert.Contains("secure", header!.Split(';').Select(x => x.Trim().ToLowerInvariant())));
+ Assert.All(headers.Take(2), header =>
+ 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..532f669 100644
--- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs
+++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs
@@ -49,6 +49,29 @@ public sealed class HeimdallCompletionTests
private static void AssertTokenRefused(HeimdallHost host, string rule) =>
AssertLogged(host, $"Heimdall token refused: {rule}.");
+ /// A __Host- cookie is only deleted by a Set-Cookie that is itself Secure at Path=/; a browser ignores any other.
+ private static void AssertAttemptCookieCleared(string? header)
+ {
+ Assert.NotNull(header);
+ var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray();
+ Assert.Contains(attributes, x => x.StartsWith("expires=", StringComparison.Ordinal) && x.Contains("1970"));
+ Assert.Contains("secure", attributes);
+ Assert.Contains("path=/", attributes);
+ Assert.DoesNotContain(attributes, x => x.StartsWith("domain", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task A_refused_completion_clears_the_attempt_cookie_with_a_set_cookie_the_browser_honours()
+ {
+ using var host = new HeimdallHost();
+ var attempt = await host.StartAsync();
+
+ var refused = await host.CompleteAsync(attempt.Nonce, "unknown", attempt.Cookie);
+
+ AssertRefusedRedirect(refused);
+ AssertAttemptCookieCleared(HeimdallHost.SetCookieHeader(refused.Response, attempt.CookieName!));
+ }
+
private static void AssertRefusedRedirect(CompletionResult result)
{
Assert.Equal(HttpStatusCode.Redirect, result.Response.StatusCode);
@@ -81,8 +104,7 @@ public sealed class HeimdallCompletionTests
Assert.Equal(MembershipStatus.Authenticated, account.Membership!.Status);
Assert.Empty(account.Membership.RoleAssignments);
var cleared = HeimdallHost.SetCookieHeader(done.Response, attempt.CookieName!);
- Assert.NotNull(cleared);
- Assert.Contains("1970", cleared);
+ AssertAttemptCookieCleared(cleared);
var again = await host.CompleteAsync(attempt, code);
@@ -526,7 +548,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 +586,7 @@ public sealed class HeimdallCompletionTests
Assert.Contains("