From b8bd92e3dc2d3dc7c6f6e05739d43926b4ac99fc Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 5 May 2026 01:55:59 +0200 Subject: [PATCH] Fix proxied live updates --- README.md | 49 +++++++++++++++++++++++++++++ RpgRoller.Tests/Api/AuthApiTests.cs | 26 ++++++++++++++- RpgRoller/Program.cs | 8 +++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 87fd2f2..1780ed7 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,55 @@ bash ./scripts/deploy.sh The script publishes the app locally, uploads a release to `/root/docker/rpgroller/releases/`, updates `/root/docker/rpgroller/current`, rebuilds the `rpgroller` image, and recreates the `rpgroller` container. The SQLite database is preserved because the container keeps using the existing bind mount at `/root/docker/rpgroller/data`. +Reverse proxy requirements for production: + +- Use `rpgroller.franktovar.de` as the only canonical host. +- Forward `X-Forwarded-For` and `X-Forwarded-Proto` so ASP.NET Core can mark the session cookie as secure behind TLS termination. +- Proxy `/_blazor` with WebSocket upgrade headers. +- Proxy `/api/events/state` as Server-Sent Events with buffering disabled, for example: + +```nginx +server { + server_name rpgroller.franktovar.de; + + location /_blazor { + proxy_pass http://127.0.0.1:8082; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 300; + } + + location /api/events/state { + proxy_pass http://127.0.0.1:8082; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_cache off; + gzip off; + proxy_read_timeout 3600; + add_header X-Accel-Buffering no; + } + + location / { + proxy_pass http://127.0.0.1:8082; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300; + } +} +``` + Environment overrides: - Set `ConnectionStrings__RpgRoller` to point at a custom SQLite database. diff --git a/RpgRoller.Tests/Api/AuthApiTests.cs b/RpgRoller.Tests/Api/AuthApiTests.cs index d4afb91..c0ace95 100644 --- a/RpgRoller.Tests/Api/AuthApiTests.cs +++ b/RpgRoller.Tests/Api/AuthApiTests.cs @@ -12,7 +12,8 @@ public sealed class AuthApiTests(WebApplicationFactory factory) : ApiTe Assert.Equal("alice", registerResult.Username); Assert.Contains(registerResult.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); - var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2")); + var duplicate = await client.PostAsJsonAsync("/api/auth/register", + new RegisterRequest("alice", "Password123", "Alice 2")); Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode); var loginResult = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "Password123")); @@ -44,4 +45,27 @@ public sealed class AuthApiTests(WebApplicationFactory factory) : ApiTe var usernames = await GetAsync>(client, "/api/users/usernames"); Assert.Equal(["amy", "bob", "zoe"], usernames); } + + [Fact] + public async Task LoginCookie_IsMarkedSecure_WhenForwardedProtoIsHttps() + { + using var factory = CreateFactory(); + using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); + + await RegisterAsync(client, "proxy-user", "Password123", "Proxy User"); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + { + Content = JsonContent.Create(new LoginRequest("proxy-user", "Password123")) + }; + request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https"); + + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Headers.SingleOrDefault(header => header.Key == "Set-Cookie").Value); + + var setCookie = Assert.Single(response.Headers.GetValues("Set-Cookie")); + Assert.Contains("rpgroller_session=", setCookie); + Assert.Contains("secure", setCookie, StringComparison.OrdinalIgnoreCase); + } } \ No newline at end of file diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs index 445ae97..b954e93 100644 --- a/RpgRoller/Program.cs +++ b/RpgRoller/Program.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.AspNetCore.HttpOverrides; using RpgRoller.Api; using RpgRoller.Components; using RpgRoller.Contracts; @@ -7,6 +8,12 @@ using RpgRoller.Hosting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); +}); builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; @@ -18,6 +25,7 @@ builder.Services.AddScoped(); var app = builder.Build(); app.InitializeRpgRollerState(); +app.UseForwardedHeaders(); var configuredPathBase = builder.Configuration["PathBase"]; if (!string.IsNullOrWhiteSpace(configuredPathBase))