From d866530df213ba78e2568abbc71d8ac313c70028 Mon Sep 17 00:00:00 2001 From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:19:18 -0400 Subject: [PATCH] security: tighten app-wide Referrer-Policy to same-origin (#383) Remote resource hosts were told the origin of every PoracleWeb instance a user browsed: uicons on raw.githubusercontent.com, Discord avatars on cdn.discordapp.com, and the Google Fonts stylesheets. #242 fixed this per-element for the gym picker; annotating every tag in the app does not scale. The security-headers middleware sent strict-origin-when-cross-origin -- the browser default, which sends the origin cross-origin. It now sends same-origin: full referrer within the site, nothing to third parties. One line, every case. no-referrer was rejected. AuthController reads the Referer header on DiscordLogin, the OIDC login path, and OIDC RP-initiated logout to recover which frontend origin the user came from, validate it against the configured CORS origins, and redirect back there after the provider callback. Blanking the same-origin referrer degrades all three to this host's own origin and bounces users to the wrong place after login. Header values moved out of the inline lambda in Program.cs into a SecurityHeaders class so they can be asserted without booting the app. The CSP is carried over byte-identical; a test pins it against the original literal. Tests cover the policy value, a guard that it never becomes no-referrer or any of the origin-leaking values, and the AuthController origin recovery it depends on -- which had no coverage at all before. Closes #383 --- .../Configuration/SecurityHeaders.cs | 47 ++++++ .../Pgan.PoracleWebNet.Api/Program.cs | 9 +- CHANGELOG.md | 1 + .../Configuration/SecurityHeadersTests.cs | 99 ++++++++++++ .../AuthControllerLoginOriginTests.cs | 149 ++++++++++++++++++ 5 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.Api/Configuration/SecurityHeaders.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Configuration/SecurityHeadersTests.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerLoginOriginTests.cs diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/SecurityHeaders.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/SecurityHeaders.cs new file mode 100644 index 00000000..490ddabf --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/SecurityHeaders.cs @@ -0,0 +1,47 @@ +namespace Pgan.PoracleWebNet.Api.Configuration; + +/// +/// Response security headers applied to every request by the middleware in Program.cs. +/// Extracted from an inline lambda so the policy values are assertable in unit tests +/// without booting the whole app (issue #383). +/// +public static class SecurityHeaders +{ + /// + /// Sends the full referrer on same-origin requests and nothing at all cross-origin. + /// + /// + /// Cross-origin suppression is the point: without it, every remote image host the SPA + /// touches (uicons on raw.githubusercontent.com, Discord avatars on cdn.discordapp.com, + /// Google Fonts) learns the origin of the PoracleWeb instance the user is browsing, + /// which for a private instance is the thing worth not disclosing. Issue #383. + /// + /// Do NOT tighten this to no-referrer. AuthController reads the Referer + /// header on the login and logout entry points (DiscordLogin, the OIDC login path, and + /// OIDC RP-initiated logout) to recover which frontend origin the user came from, + /// validate it against the configured CORS origins, and redirect back there after the + /// provider callback. Those reads are same-origin when the SPA is served by this host, + /// so same-origin keeps them working; no-referrer would blank them and + /// silently bounce users to this host's own origin after login instead. + /// + public const string ReferrerPolicy = "same-origin"; + + public const string ContentSecurityPolicy = + "default-src 'self'; script-src 'self' 'unsafe-hashes' 'sha256-MhtPZXr7+LpJUY5qtMutB+qWfQtMaPccfe7QXtCcEYc=' https://telegram.org; " + + "style-src 'self' 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; " + + "connect-src 'self' https://raw.githubusercontent.com; frame-src https://oauth.telegram.org"; + + /// + /// Stamps the security headers onto an outgoing response. + /// + public static void Apply(IHeaderDictionary headers) + { + ArgumentNullException.ThrowIfNull(headers); + + headers.XContentTypeOptions = "nosniff"; + headers.XFrameOptions = "DENY"; + headers.XXSSProtection = "0"; + headers["Referrer-Policy"] = ReferrerPolicy; + headers.ContentSecurityPolicy = ContentSecurityPolicy; + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Program.cs b/Applications/Pgan.PoracleWebNet.Api/Program.cs index f887c8fd..9d2f712c 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Program.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Program.cs @@ -370,17 +370,12 @@ await context.Response.WriteAsync( forwardedHeadersOptions.KnownProxies.Clear(); app.UseForwardedHeaders(forwardedHeadersOptions); -// Security headers +// Security headers -- values live in SecurityHeaders so they can be unit-tested app.Use(async (context, next) => { context.Response.OnStarting(() => { - var headers = context.Response.Headers; - headers.XContentTypeOptions = "nosniff"; - headers.XFrameOptions = "DENY"; - headers.XXSSProtection = "0"; - headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; - headers.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-hashes' 'sha256-MhtPZXr7+LpJUY5qtMutB+qWfQtMaPccfe7QXtCcEYc=' https://telegram.org; style-src 'self' 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://raw.githubusercontent.com; frame-src https://oauth.telegram.org"; + SecurityHeaders.Apply(context.Response.Headers); return Task.CompletedTask; }); await next(); diff --git a/CHANGELOG.md b/CHANGELOG.md index 7078897b..f9ad97f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - **Gym-picker images no longer send a `Referer` header to third-party hosts** ([#242](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/242)): the gym picker renders two kinds of remote image — the scanner DB's `gym.url` photo (a Niantic CDN URL in stock Golbat/RDM deployments, though an operator can rewrite the column to point at a self-hosted mirror) and the team-icon fallback from `raw.githubusercontent.com`. Neither carried a referrer policy, so every image request told the remote host which PoracleWeb instance the user was browsing. All four `` tags in `gym-picker.component.html` now set `referrerpolicy="no-referrer"`. Modern browsers already default to `strict-origin-when-cross-origin`, so the pre-existing leak was the origin rather than the full URL — this closes the remainder. Presentation-only: no API, model, or scanner-query change, and `GymSearchResult.Url` still carries the raw scanner URL as before. The photo-proxy endpoint floated in the original issue was **not** implemented: server-side fetching of a URL supplied by a database PoracleWeb does not own would turn a passive disclosure into an authenticated outbound-request primitive from a host that can reach Poracle, Koji, Golbat, and both MySQL servers. A host allowlist applied at projection remains the cheaper option if a deployment ever needs the mirror case handled. +- **App-wide `Referrer-Policy` tightened to `same-origin`, so no remote host learns the instance origin** ([#383](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/383)): the per-element fix above covered the gym picker, but the same leak existed everywhere else the SPA loads a remote resource — uicons from `raw.githubusercontent.com` (`icon.service.ts`, operator-overridable, so possibly a self-hosted mirror) across the Pokémon/raid/egg/lure/invasion/gym/quick-pick lists and dialogs, Discord avatars from `cdn.discordapp.com`, and the Google Fonts stylesheets in `index.html`. Each request disclosed the origin of the PoracleWeb instance being browsed, which for a private or invite-only deployment is the part worth withholding. The security-headers middleware previously sent `strict-origin-when-cross-origin` (the browser default, which sends the origin cross-origin); it now sends `same-origin` — full referrer within the site, nothing at all to third parties — fixing every case in one place rather than annotating tags individually. `no-referrer` was considered and rejected: `AuthController` reads the `Referer` header on `DiscordLogin`, the OIDC login path, and OIDC RP-initiated logout to recover which frontend origin the user came from, validate it against the configured CORS origins, and redirect back there after the provider callback — blanking the same-origin referrer would degrade all three to this host's own origin and bounce users to the wrong place. The header values moved out of the inline lambda in `Program.cs` into a `SecurityHeaders` class so they're assertable without booting the app; the CSP is carried over byte-identical (a test pins it against the original literal). Tests cover the policy value, a guard that it never becomes `no-referrer` or any of the origin-leaking values, and the previously untested `AuthController` origin recovery it depends on (allowed referer honored, disallowed and non-absolute referers rejected, absent referer falling back to self). The per-element `referrerpolicy` attributes from #242 are left in place as defence-in-depth. ## [2.11.1] - 2026-06-05 diff --git a/Tests/Pgan.PoracleWebNet.Tests/Configuration/SecurityHeadersTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Configuration/SecurityHeadersTests.cs new file mode 100644 index 00000000..ed2900e5 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Configuration/SecurityHeadersTests.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Http; +using Pgan.PoracleWebNet.Api.Configuration; + +namespace Pgan.PoracleWebNet.Tests.Configuration; + +/// +/// Tests for the response security headers (issue #383). +/// The Referrer-Policy assertions are the point of this file: the value has to stay +/// cross-origin-suppressing (so remote image hosts don't learn the instance origin) while +/// still sending a same-origin referrer, which AuthController's login/logout redirects +/// depend on. +/// +public class SecurityHeadersTests +{ + /// + /// The CSP exactly as it was written inline in Program.cs before being extracted into + /// . Guards the extraction against a typo in the + /// concatenated string. + /// + private const string OriginalCsp = + "default-src 'self'; script-src 'self' 'unsafe-hashes' 'sha256-MhtPZXr7+LpJUY5qtMutB+qWfQtMaPccfe7QXtCcEYc=' https://telegram.org; style-src 'self' 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://raw.githubusercontent.com; frame-src https://oauth.telegram.org"; + + [Fact] + public void Apply_SetsReferrerPolicy_ToSameOrigin() + { + IHeaderDictionary headers = new HeaderDictionary(); + + SecurityHeaders.Apply(headers); + + Assert.Equal("same-origin", headers["Referrer-Policy"]); + } + + /// + /// A referrer policy of no-referrer blanks the Referer header on same-origin requests + /// too, which breaks the origin recovery in AuthController's DiscordLogin, OIDC login, + /// and OIDC logout handlers -- they would silently fall back to this host's own origin + /// and redirect users to the wrong place after a provider callback. If this assertion + /// fails, read the remarks on SecurityHeaders.ReferrerPolicy before changing it. + /// + [Fact] + public void ReferrerPolicy_IsNotNoReferrer_SoAuthRedirectsKeepWorking() + { + Assert.NotEqual("no-referrer", SecurityHeaders.ReferrerPolicy); + } + + /// + /// The whole reason for #383: the policy must not send anything cross-origin. The two + /// values that leak the origin are the browser default and the explicit unsafe opt-outs. + /// + [Theory] + [InlineData("strict-origin-when-cross-origin")] + [InlineData("no-referrer-when-downgrade")] + [InlineData("origin")] + [InlineData("origin-when-cross-origin")] + [InlineData("unsafe-url")] + public void ReferrerPolicy_DoesNotLeakOriginCrossOrigin(string leakyPolicy) + { + Assert.NotEqual(leakyPolicy, SecurityHeaders.ReferrerPolicy); + } + + [Fact] + public void Apply_SetsContentSecurityPolicy_UnchangedFromTheInlineVersion() + { + IHeaderDictionary headers = new HeaderDictionary(); + + SecurityHeaders.Apply(headers); + + Assert.Equal(OriginalCsp, headers.ContentSecurityPolicy); + } + + [Fact] + public void Apply_SetsTheRemainingHardeningHeaders() + { + IHeaderDictionary headers = new HeaderDictionary(); + + SecurityHeaders.Apply(headers); + + Assert.Equal("nosniff", headers.XContentTypeOptions); + Assert.Equal("DENY", headers.XFrameOptions); + Assert.Equal("0", headers.XXSSProtection); + } + + [Fact] + public void Apply_OverwritesAnyValuePresetByAnUpstreamProxyOrHost() + { + IHeaderDictionary headers = new HeaderDictionary + { + ["Referrer-Policy"] = "unsafe-url" + }; + + SecurityHeaders.Apply(headers); + + Assert.Equal("same-origin", headers["Referrer-Policy"]); + } + + [Fact] + public void Apply_Throws_WhenHeadersAreNull() => + Assert.Throws(() => SecurityHeaders.Apply(null!)); +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerLoginOriginTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerLoginOriginTests.cs new file mode 100644 index 00000000..22f46319 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerLoginOriginTests.cs @@ -0,0 +1,149 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Api.Services.Oidc; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Tests.Controllers; + +/// +/// Tests for the frontend-origin recovery in AuthController.DiscordLogin — the Referer read +/// that decides which origin the user is sent back to after the provider callback, stashed in +/// the oauth_origin cookie. +/// +/// This behavior is why the app's Referrer-Policy is "same-origin" and not "no-referrer" +/// (issue #383): a policy that blanks the same-origin Referer would make every case below +/// fall back to the API's own origin. See SecurityHeaders.ReferrerPolicy. +/// +public class AuthControllerLoginOriginTests +{ + private const string SelfOrigin = "https://alerts.example.net"; + + [Fact] + public async Task DiscordLoginUsesRefererOriginWhenItIsAnAllowedCorsOrigin() + { + var controller = CreateController( + allowedOrigins: ["https://app.example.net", SelfOrigin], + referer: "https://app.example.net/auth/login"); + + await controller.DiscordLogin(); + + Assert.Equal("https://app.example.net", ReadOriginCookie(controller)); + } + + /// + /// The shared-host production topology: the SPA is served by this same host, so the + /// referrer is same-origin. "same-origin" policy sends the full URL here, so the header + /// is present and the recovered origin matches self. + /// + [Fact] + public async Task DiscordLoginUsesRefererOriginWhenItMatchesSelfAndNoCorsOriginsConfigured() + { + var controller = CreateController( + allowedOrigins: [], + referer: $"{SelfOrigin}/auth/login"); + + await controller.DiscordLogin(); + + Assert.Equal(SelfOrigin, ReadOriginCookie(controller)); + } + + /// + /// The no-referrer scenario, asserted explicitly: with no Referer to read, the origin + /// silently degrades to this host. Harmless when the SPA shares the host, wrong when it + /// doesn't — which is the regression a "no-referrer" policy would introduce everywhere. + /// + [Fact] + public async Task DiscordLoginFallsBackToSelfOriginWhenRefererIsAbsent() + { + var controller = CreateController(allowedOrigins: ["https://app.example.net"], referer: null); + + await controller.DiscordLogin(); + + Assert.Equal(SelfOrigin, ReadOriginCookie(controller)); + } + + [Fact] + public async Task DiscordLoginIgnoresRefererOriginThatIsNotAllowed() + { + var controller = CreateController( + allowedOrigins: ["https://app.example.net"], + referer: "https://evil.example.com/auth/login"); + + await controller.DiscordLogin(); + + Assert.Equal(SelfOrigin, ReadOriginCookie(controller)); + } + + [Fact] + public async Task DiscordLoginIgnoresRefererThatIsNotAnAbsoluteUri() + { + var controller = CreateController(allowedOrigins: [], referer: "/auth/login"); + + await controller.DiscordLogin(); + + Assert.Equal(SelfOrigin, ReadOriginCookie(controller)); + } + + private static AuthController CreateController(string[] allowedOrigins, string? referer) + { + var settings = new Dictionary(); + for (var i = 0; i < allowedOrigins.Length; i++) + { + settings[$"Cors:AllowedOrigins:{i}"] = allowedOrigins[i]; + } + + var controller = new AuthController( + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + Options.Create(new DiscordSettings { ClientId = "test-id", ClientSecret = "test-secret" }), + Options.Create(new TelegramSettings()), + Options.Create(new OidcSettings()), + Options.Create(new PoracleSettings()), + new ConfigurationBuilder().AddInMemoryCollection(settings).Build(), + new Mock>().Object); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("alerts.example.net"); + if (referer != null) + { + httpContext.Request.Headers.Referer = referer; + } + + controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + return controller; + } + + private static string? ReadOriginCookie(ControllerBase controller) + { + const string Name = "oauth_origin="; + + var cookie = controller.Response.Headers.SetCookie + .FirstOrDefault(c => c != null && c.StartsWith(Name, StringComparison.Ordinal)); + if (cookie == null) + { + return null; + } + + var value = cookie[Name.Length..]; + var end = value.IndexOf(';', StringComparison.Ordinal); + if (end >= 0) + { + value = value[..end]; + } + + return Uri.UnescapeDataString(value); + } +}