feat(api): honour X-Forwarded-Proto so Secure cookies flip on behind Traefik

This commit is contained in:
Claude
2026-08-28 18:48:30 +00:00
parent 7854cb02ec
commit 3d9df50f20
2 changed files with 79 additions and 0 deletions

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using VolcanicCore.Api;
using VolcanicCore.Api.Auth;
using VolcanicCore.Api.Endpoints;
@@ -107,6 +108,17 @@ builder.Services
});
builder.Services.AddAuthorization();
// Trust Coolify's Traefik as the TLS-terminating proxy. Clearing the default
// KnownNetworks/KnownProxies is required because the container talks to Traefik
// over the compose network — that IP is not in the built-in localhost list.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
// Reflection scan — pick up INodeType / IConnectionProvider implementations
// registered as DI services by any loaded assembly. In Plan 2, node/provider
// assemblies will register their types via extension methods.
@@ -155,6 +167,9 @@ using (var scope = app.Services.CreateScope())
AdminBootstrap.Seed(repo, pw, settings.AdminPassword);
}
// Must run before UseAuthentication so cookie SecurePolicy.SameAsRequest sees https.
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();

View File

@@ -0,0 +1,64 @@
using System.Net;
using System.Net.Http.Json;
using Xunit;
namespace VolcanicCore.Tests;
public class ForwardedHeadersTests : IClassFixture<WebFactory>
{
private readonly WebFactory _f;
public ForwardedHeadersTests(WebFactory f) { _f = f; }
[Fact]
public async Task Login_with_XForwardedProto_https_returns_Secure_cookie()
{
// AllowAutoRedirect false so we can inspect the raw Set-Cookie header.
var client = _f.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
// Simulate Traefik terminating TLS: it forwards over plain HTTP with the
// scheme and client IP in headers.
client.DefaultRequestHeaders.Add("X-Forwarded-Proto", "https");
client.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.7");
var req = new HttpRequestMessage(HttpMethod.Post, "/api/session")
{
Content = JsonContent.Create(new { password = _f.AdminPassword }),
};
var res = await client.SendAsync(req);
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
// Cookie should be marked Secure because the middleware saw https.
var setCookies = res.Headers.GetValues("Set-Cookie").ToList();
var sid = setCookies.FirstOrDefault(c => c.StartsWith("volcaniccore.sid=", StringComparison.Ordinal));
Assert.NotNull(sid);
Assert.Contains("secure", sid!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Login_without_XForwardedProto_returns_cookie_without_Secure()
{
// Same request WITHOUT the forwarded header — Request.Scheme stays http,
// so CookieSecurePolicy.SameAsRequest omits the Secure flag. This proves
// the middleware is doing the work; without it the "https" test above
// would still pass because both requests would look like http.
var client = _f.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
var req = new HttpRequestMessage(HttpMethod.Post, "/api/session")
{
Content = JsonContent.Create(new { password = _f.AdminPassword }),
};
var res = await client.SendAsync(req);
Assert.Equal(HttpStatusCode.NoContent, res.StatusCode);
var setCookies = res.Headers.GetValues("Set-Cookie").ToList();
var sid = setCookies.FirstOrDefault(c => c.StartsWith("volcaniccore.sid=", StringComparison.Ordinal));
Assert.NotNull(sid);
Assert.DoesNotContain("secure", sid!, StringComparison.OrdinalIgnoreCase);
}
}