Files
SVSimServer/SVSim.EmulatedEntrypoint/Infrastructure/RequireAdminSecretAttribute.cs
gamer147 c8e4bda6af feat(admin): shared-secret auth for /admin/import_viewer + Swagger authorize
Anonymous /admin/import_viewer was safe locally but exposed viewer-import surface
whenever the server was reachable off-box. Gate it on a shared secret carried in
X-Admin-Secret, sourced from Admin:ImportSecret in appsettings (override via
ADMIN__IMPORTSECRET env var). Fails closed: an unconfigured deployment 401s every
request (with a logged warning) instead of leaving the endpoint open.

Implementation is a plain IAuthorizationFilter attribute (RequireAdminSecret) that
runs before model binding — short-circuits the ImportViewerRequest deserialize on
unauthorized calls. Compare uses CryptographicOperations.FixedTimeEquals to avoid
timing signal. A companion Swagger IOperationFilter registers an ApiKey security
definition and attaches the requirement ONLY to actions carrying the attribute, so
the Swagger UI shows an Authorize dialog for admin endpoints without decorating
ordinary game routes with a padlock.

Tests: existing 27 AdminController tests routed through a new
SVSimTestFactory.CreateAdminClient() helper that bakes the header from
appsettings.Testing.json; added two negative-path tests
(missing_secret_header, wrong_secret) — 29/29 passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 18:24:59 -04:00

58 lines
2.4 KiB
C#

using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SVSim.EmulatedEntrypoint.Configuration;
namespace SVSim.EmulatedEntrypoint.Infrastructure;
/// <summary>
/// Gates a controller or action on a shared secret carried in the <c>X-Admin-Secret</c> header,
/// compared against <see cref="AdminOptions.ImportSecret"/>. Runs as an authorization filter so
/// unauthorized requests short-circuit before model binding.
///
/// Fail-closed: if the configured secret is null/empty the endpoint is treated as disabled and
/// every request gets a 401 (with a warning logged once per request). This means a deployment
/// that forgets to set the secret leaves the endpoint locked, not open.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)]
public sealed class RequireAdminSecretAttribute : Attribute, IAuthorizationFilter
{
public const string HeaderName = "X-Admin-Secret";
public void OnAuthorization(AuthorizationFilterContext context)
{
var services = context.HttpContext.RequestServices;
var options = services.GetRequiredService<IOptions<AdminOptions>>().Value;
var logger = services.GetRequiredService<ILogger<RequireAdminSecretAttribute>>();
if (string.IsNullOrWhiteSpace(options.ImportSecret))
{
logger.LogWarning(
"Rejecting request to {Path}: Admin:ImportSecret is not configured. " +
"Set it in appsettings (or the NPGSQL_ADMIN__IMPORTSECRET env var) to enable the endpoint.",
context.HttpContext.Request.Path);
context.Result = new UnauthorizedResult();
return;
}
if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out var provided)
|| provided.Count == 0
|| string.IsNullOrEmpty(provided[0]))
{
context.Result = new UnauthorizedResult();
return;
}
var providedBytes = Encoding.UTF8.GetBytes(provided[0]!);
var expectedBytes = Encoding.UTF8.GetBytes(options.ImportSecret);
if (!CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes))
{
context.Result = new UnauthorizedResult();
}
}
}