Make every cookie host-only: __Host- names, Secure, Path=/ #11

Merged
heimdall merged 2 commits from heimdall/host-only-cookies into main 2026-11-09 11:48:28 +00:00
9 changed files with 181 additions and 23 deletions

View file

@ -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";

View file

@ -446,13 +446,13 @@ public sealed record HeimdallAccount(
/// <summary>
/// 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).
/// 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.
/// </summary>
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
};
/// <summary>
/// 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.
/// </summary>
public static CookieOptions DeleteOptions() => new() { Path = "/auth/heimdall" };
public static CookieOptions DeleteOptions() => new() { Path = BifrostCookies.Path, Secure = true };
}

View file

@ -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 = "/";
}

View file

@ -76,6 +76,13 @@ builder.Services
.AddOptions<ConceptVoteOptions>()
.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");

View file

@ -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<TestWebApplicationFactory>
{
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<TestWebApplicat
Assert.Contains("httponly", attributes);
Assert.Contains("secure", attributes);
Assert.Contains("samesite=lax", attributes);
Assert.Contains("path=/mill", attributes);
Assert.Contains("path=/", attributes);
Assert.Contains("max-age=31536000", attributes);
using (var page = await visitor.Get(collection, "alpha"))

View file

@ -1,3 +1,4 @@
using Bifrost.Web.Features.Shared;
using System.Net;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
@ -19,7 +20,7 @@ namespace Bifrost.Web.Tests;
public sealed class ConceptVoteRateLimitTests : IClassFixture<ConceptVoteRateLimitTests.LimitedFactory>
{
private const string Site = "https://localhost";
private const string CookieName = "bifrost_voter";
private const string CookieName = BifrostCookies.Voter;
private readonly LimitedFactory _factory;

View file

@ -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<CookieHardeningTests.OAuthConfiguredFactory>
{
// 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<IOptionsMonitor<CookieAuthenticationOptions>>()
.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<IOptionsMonitor<OAuthOptions>>().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<IOptions<AntiforgeryOptions>>().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);
}
}

View file

@ -49,6 +49,29 @@ public sealed class HeimdallCompletionTests
private static void AssertTokenRefused(HeimdallHost host, string rule) =>
AssertLogged(host, $"Heimdall token refused: {rule}.");
/// <summary>A __Host- cookie is only deleted by a Set-Cookie that is itself Secure at Path=/; a browser ignores any other.</summary>
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("<noscript>", page);
Assert.Contains("/?auth=heimdall-error", page);
Assert.DoesNotContain("CANARY", page);
Assert.Null(HeimdallHost.CookieValue(response, ".AspNetCore.Cookies"));
Assert.Null(HeimdallHost.CookieValue(response, BifrostCookies.Session));
}
[Theory]
@ -696,7 +718,7 @@ public sealed class HeimdallCompletionTests
"Heimdall completion has no attempt for this browser."),
("no attempt field", await host.CompleteAsync(null, host.CodeFor(attempt, accountId), attempt.Cookie),
"Heimdall completion has no attempt for this browser."),
("malformed attempt", await host.CompleteAsync("short", "code", attempt.Cookie, "bifrost_heimdall_short"),
("malformed attempt", await host.CompleteAsync("short", "code", attempt.Cookie, BifrostCookies.HeimdallAttemptPrefix + "short"),
"Heimdall completion has no attempt for this browser."),
("no code", await host.CompleteAsync(attempt.Nonce, null, attempt.Cookie), "Heimdall completion carried no code."),
("blank code", await host.CompleteAsync(attempt.Nonce, " ", attempt.Cookie), "Heimdall completion carried no code."),
@ -730,7 +752,7 @@ public sealed class HeimdallCompletionTests
{
AssertRefusedRedirect(result);
Assert.Equal(string.Empty, await result.Response.Content.ReadAsStringAsync());
Assert.Null(HeimdallHost.CookieValue(result.Response, ".AspNetCore.Cookies"));
Assert.Null(HeimdallHost.CookieValue(result.Response, BifrostCookies.Session));
AssertLogged(host, reason);
Assert.True(result.Response.Headers.Location!.OriginalString == ErrorRedirect, name);
}
@ -1109,7 +1131,7 @@ public sealed class HeimdallCompletionTests
responses.Add((await host.CompleteAsync(issued, host.CodeFor(issued, accountCanary, wrongSubject))).Response);
responses.Add((await host.CompleteAsync(issued, host.CodeFor(issued, accountCanary, returnTo: returnToCanary))).Response);
responses.Add((await host.CompleteAsync(issued.Nonce, codeCanary, cookieCanary)).Response);
responses.Add((await host.CompleteAsync(attemptCanary, codeCanary, cookieCanary, "bifrost_heimdall_" + attemptCanary)).Response);
responses.Add((await host.CompleteAsync(attemptCanary, codeCanary, cookieCanary, BifrostCookies.HeimdallAttemptPrefix + attemptCanary)).Response);
responses.Add((await host.CompleteAsync(issued.Nonce, codeCanary, issued.Cookie)).Response);
using (var client = host.NewClient())
{
@ -1188,7 +1210,7 @@ public sealed class HeimdallCompletionTests
Assert.NotNull(header);
Assert.StartsWith(name + "=;", header);
var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray();
Assert.Contains("path=/auth/heimdall", attributes);
Assert.Contains("path=/", attributes);
Assert.Contains(attributes, x => x.StartsWith("expires=", StringComparison.Ordinal) && x.Contains("1970"));
Assert.DoesNotContain(attributes, x => x.StartsWith("max-age", StringComparison.Ordinal));
}
@ -1277,7 +1299,7 @@ public sealed class HeimdallCompletionTests
foreach (var header in deletions)
{
var attributes = header.Split(';').Skip(1).Select(x => x.Trim().ToLowerInvariant()).ToArray();
Assert.Contains("path=/auth/heimdall", attributes);
Assert.Contains("path=/", attributes);
Assert.DoesNotContain(attributes, x => x.StartsWith("max-age", StringComparison.Ordinal));
}
@ -1346,7 +1368,7 @@ public sealed class HeimdallCompletionTests
{
host.Clock.Advance(TimeSpan.FromSeconds(1));
var attempt = await host.StartAsync(
returnUrl: "/App", cookieHeader: (jar.Count == 0 ? "" : CookieHeader(jar) + "; ") + ".AspNetCore.Cookies=session; x=1");
returnUrl: "/App", cookieHeader: (jar.Count == 0 ? "" : CookieHeader(jar) + "; ") + BifrostCookies.Session + "=session; x=1");
ApplySetCookies(jar, attempt.Response);
started.Add(attempt);
}
@ -1393,7 +1415,7 @@ public sealed class HeimdallCompletionTests
using var post = new HttpRequestMessage(HttpMethod.Post, action) { Content = new FormUrlEncodedContent(fields) };
post.Headers.Add("Cookie", $"{attempt.CookieName}={attempt.Cookie}");
using var response = await client.SendAsync(post);
Assert.False(string.IsNullOrEmpty(HeimdallHost.CookieValue(response, ".AspNetCore.Cookies")));
Assert.False(string.IsNullOrEmpty(HeimdallHost.CookieValue(response, BifrostCookies.Session)));
}
[Fact]

View file

@ -1,3 +1,4 @@
using Bifrost.Web.Features.Shared;
using System.Net;
using System.Security.Cryptography;
using System.Text;
@ -482,7 +483,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory
}
var response = await client.SendAsync(request);
var session = CookieValue(response, ".AspNetCore.Cookies");
var session = CookieValue(response, BifrostCookies.Session);
return new CompletionResult(response, !string.IsNullOrEmpty(session));
}