Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Pgan.PoracleWebNet.Api.Configuration;

/// <summary>
/// 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).
/// </summary>
public static class SecurityHeaders
{
/// <summary>
/// Sends the full referrer on same-origin requests and nothing at all cross-origin.
/// </summary>
/// <remarks>
/// 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 <c>no-referrer</c>. <c>AuthController</c> 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 <c>same-origin</c> keeps them working; <c>no-referrer</c> would blank them and
/// silently bounce users to this host's own origin after login instead.
/// </remarks>
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";

/// <summary>
/// Stamps the security headers onto an outgoing response.
/// </summary>
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;
}
}
9 changes: 2 additions & 7 deletions Applications/Pgan.PoracleWebNet.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` 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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Http;
using Pgan.PoracleWebNet.Api.Configuration;

namespace Pgan.PoracleWebNet.Tests.Configuration;

/// <summary>
/// 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.
/// </summary>
public class SecurityHeadersTests
{
/// <summary>
/// The CSP exactly as it was written inline in Program.cs before being extracted into
/// <see cref="SecurityHeaders"/>. Guards the extraction against a typo in the
/// concatenated string.
/// </summary>
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"]);
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public void ReferrerPolicy_IsNotNoReferrer_SoAuthRedirectsKeepWorking()
{
Assert.NotEqual("no-referrer", SecurityHeaders.ReferrerPolicy);
}

/// <summary>
/// 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.
/// </summary>
[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<ArgumentNullException>(() => SecurityHeaders.Apply(null!));
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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));
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task DiscordLoginUsesRefererOriginWhenItMatchesSelfAndNoCorsOriginsConfigured()
{
var controller = CreateController(
allowedOrigins: [],
referer: $"{SelfOrigin}/auth/login");

await controller.DiscordLogin();

Assert.Equal(SelfOrigin, ReadOriginCookie(controller));
}

/// <summary>
/// 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.
/// </summary>
[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<string, string?>();
for (var i = 0; i < allowedOrigins.Length; i++)
{
settings[$"Cors:AllowedOrigins:{i}"] = allowedOrigins[i];
}

var controller = new AuthController(
new Mock<IHumanService>().Object,
new Mock<IPoracleApiProxy>().Object,
new Mock<IPoracleHumanProxy>().Object,
new Mock<ISiteSettingService>().Object,
new Mock<IWebhookDelegateService>().Object,
new Mock<IJwtService>().Object,
new Mock<IOidcClient>().Object,
new Mock<IOidcSessionService>().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<ILogger<AuthController>>().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);
}
}
Loading