diff --git a/Directory.Packages.props b/Directory.Packages.props index 4a16ef67..846804d5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -44,6 +44,7 @@ + diff --git a/framework/SimpleModule.Core/Security/ISignedUrlGenerator.cs b/framework/SimpleModule.Core/Security/ISignedUrlGenerator.cs new file mode 100644 index 00000000..ee99fcc6 --- /dev/null +++ b/framework/SimpleModule.Core/Security/ISignedUrlGenerator.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http; + +namespace SimpleModule.Core.Security; + +public interface ISignedUrlGenerator +{ + string Sign( + string path, + IDictionary? query = null, + DateTimeOffset? expiresAt = null, + string? purpose = null + ); + + bool TryValidate(HttpRequest request, out SignedUrlClaims? claims); + + bool TryValidate(HttpRequest request, string? expectedPurpose, out SignedUrlClaims? claims); +} diff --git a/framework/SimpleModule.Core/Security/SignedUrlClaims.cs b/framework/SimpleModule.Core/Security/SignedUrlClaims.cs new file mode 100644 index 00000000..a3470244 --- /dev/null +++ b/framework/SimpleModule.Core/Security/SignedUrlClaims.cs @@ -0,0 +1,3 @@ +namespace SimpleModule.Core.Security; + +public sealed record SignedUrlClaims(string Path, string? Purpose, DateTimeOffset? ExpiresAt); diff --git a/framework/SimpleModule.Core/Security/SignedUrlEndpointExtensions.cs b/framework/SimpleModule.Core/Security/SignedUrlEndpointExtensions.cs new file mode 100644 index 00000000..92d97094 --- /dev/null +++ b/framework/SimpleModule.Core/Security/SignedUrlEndpointExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace SimpleModule.Core.Security; + +public static class SignedUrlEndpointExtensions +{ + private const string SignedUrlClaimsItemKey = "SimpleModule.SignedUrl.Claims"; + + public static TBuilder RequireSignedUrl(this TBuilder builder, string? purpose = null) + where TBuilder : IEndpointConventionBuilder + { + builder.AllowAnonymous(); + builder.AddEndpointFilter( + async (context, next) => + { + var generator = + context.HttpContext.RequestServices.GetRequiredService(); + + if (!generator.TryValidate(context.HttpContext.Request, purpose, out var claims)) + { + return Results.StatusCode(StatusCodes.Status403Forbidden); + } + + context.HttpContext.Items[SignedUrlClaimsItemKey] = claims; + return await next(context); + } + ); + return builder; + } + + public static SignedUrlClaims? GetSignedUrlClaims(this HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + return context.Items.TryGetValue(SignedUrlClaimsItemKey, out var value) + ? value as SignedUrlClaims + : null; + } +} diff --git a/framework/SimpleModule.Core/Security/SignedUrlGenerator.cs b/framework/SimpleModule.Core/Security/SignedUrlGenerator.cs new file mode 100644 index 00000000..be44b8c3 --- /dev/null +++ b/framework/SimpleModule.Core/Security/SignedUrlGenerator.cs @@ -0,0 +1,215 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; + +namespace SimpleModule.Core.Security; + +public sealed class SignedUrlGenerator : ISignedUrlGenerator +{ + private const string SignatureQueryKey = "signature"; + private const string ExpiresQueryKey = "expires"; + private const string PurposeQueryKey = "purpose"; + private const string ProtectorPurpose = "SimpleModule.SignedUrl.v1"; + + private readonly IDataProtector _protector; + private readonly TimeProvider _clock; + + public SignedUrlGenerator(IDataProtectionProvider provider, TimeProvider clock) + { + ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(clock); + _protector = provider.CreateProtector(ProtectorPurpose); + _clock = clock; + } + + public string Sign( + string path, + IDictionary? query = null, + DateTimeOffset? expiresAt = null, + string? purpose = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (path.Contains('?', StringComparison.Ordinal)) + { + throw new ArgumentException( + "Path must not contain a query string; pass query parameters via the 'query' argument.", + nameof(path) + ); + } + + var pairs = BuildPairs(query, expiresAt, purpose); + var canonical = BuildCanonical(path, pairs); + var signature = WebEncoders.Base64UrlEncode( + _protector.Protect(Encoding.UTF8.GetBytes(canonical)) + ); + + pairs.Add(new KeyValuePair(SignatureQueryKey, signature)); + return QueryHelpers.AddQueryString(path, pairs); + } + + public bool TryValidate(HttpRequest request, out SignedUrlClaims? claims) => + TryValidate(request, expectedPurpose: null, out claims); + + public bool TryValidate( + HttpRequest request, + string? expectedPurpose, + out SignedUrlClaims? claims + ) + { + claims = null; + ArgumentNullException.ThrowIfNull(request); + + if (!request.Query.TryGetValue(SignatureQueryKey, out var providedSignature)) + { + return false; + } + + var pairs = new List>(); + string? purpose = null; + DateTimeOffset? expiresAt = null; + foreach (var (key, values) in request.Query) + { + if (string.Equals(key, SignatureQueryKey, StringComparison.Ordinal)) + { + continue; + } + + var value = (string?)values; + pairs.Add(new KeyValuePair(key, value)); + + if (string.Equals(key, PurposeQueryKey, StringComparison.Ordinal)) + { + purpose = value; + } + else if ( + string.Equals(key, ExpiresQueryKey, StringComparison.Ordinal) + && long.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var unix + ) + ) + { + expiresAt = DateTimeOffset.FromUnixTimeSeconds(unix); + } + } + + // A URL signed with a purpose must be validated against that exact purpose; + // otherwise a token issued for one flow could be replayed against another. + if (!string.Equals(expectedPurpose, purpose, StringComparison.Ordinal)) + { + return false; + } + + if (expiresAt is not null && _clock.GetUtcNow() > expiresAt.Value) + { + return false; + } + + var path = request.Path.Value ?? string.Empty; + var canonical = BuildCanonical(path, pairs); + var canonicalBytes = Encoding.UTF8.GetBytes(canonical); + + byte[] decrypted; + try + { + var sigBytes = WebEncoders.Base64UrlDecode((string)providedSignature!); + decrypted = _protector.Unprotect(sigBytes); + } + catch (CryptographicException) + { + return false; + } + catch (FormatException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + + if (!CryptographicOperations.FixedTimeEquals(decrypted, canonicalBytes)) + { + return false; + } + + claims = new SignedUrlClaims(path, purpose, expiresAt); + return true; + } + + private static List> BuildPairs( + IDictionary? query, + DateTimeOffset? expiresAt, + string? purpose + ) + { + var pairs = new List>(); + if (query is not null) + { + foreach (var (key, value) in query) + { + if ( + string.Equals(key, SignatureQueryKey, StringComparison.Ordinal) + || string.Equals(key, ExpiresQueryKey, StringComparison.Ordinal) + || string.Equals(key, PurposeQueryKey, StringComparison.Ordinal) + ) + { + throw new ArgumentException( + $"Query parameter '{key}' is reserved for signed URL metadata.", + nameof(query) + ); + } + pairs.Add(new KeyValuePair(key, value)); + } + } + if (expiresAt is not null) + { + pairs.Add( + new KeyValuePair( + ExpiresQueryKey, + expiresAt.Value.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) + ) + ); + } + if (purpose is not null) + { + pairs.Add(new KeyValuePair(PurposeQueryKey, purpose)); + } + return pairs; + } + + private static string BuildCanonical( + string path, + IEnumerable> pairs + ) + { + var sorted = pairs + .OrderBy(p => p.Key, StringComparer.Ordinal) + .ThenBy(p => p.Value, StringComparer.Ordinal) + .ToArray(); + + var builder = new StringBuilder(path); + if (sorted.Length == 0) + { + return builder.ToString(); + } + builder.Append('?'); + for (var i = 0; i < sorted.Length; i++) + { + if (i > 0) + { + builder.Append('&'); + } + builder.Append(Uri.EscapeDataString(sorted[i].Key)); + builder.Append('='); + builder.Append(Uri.EscapeDataString(sorted[i].Value ?? string.Empty)); + } + return builder.ToString(); + } +} diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 00a4782e..4111872f 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -130,6 +130,9 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( builder.Services.AddScoped(); + builder.Services.TryAddSingleton(TimeProvider.System); + builder.Services.AddSingleton(); + if (options.EnableHealthChecks) { builder diff --git a/tests/SimpleModule.Core.Tests/Security/SignedUrlGeneratorTests.cs b/tests/SimpleModule.Core.Tests/Security/SignedUrlGeneratorTests.cs new file mode 100644 index 00000000..56f5062e --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Security/SignedUrlGeneratorTests.cs @@ -0,0 +1,254 @@ +using FluentAssertions; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Primitives; +using Microsoft.Extensions.Time.Testing; +using SimpleModule.Core.Security; + +namespace SimpleModule.Core.Tests.Security; + +public class SignedUrlGeneratorTests +{ + private static SignedUrlGenerator CreateGenerator(FakeTimeProvider? clock = null) => + new(new EphemeralDataProtectionProvider(), clock ?? new FakeTimeProvider()); + + private static HttpRequest BuildRequest(string signedUrl) + { + var queryStart = signedUrl.IndexOf('?', StringComparison.Ordinal); + var path = queryStart < 0 ? signedUrl : signedUrl[..queryStart]; + var queryString = queryStart < 0 ? string.Empty : signedUrl[queryStart..]; + + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.Request.QueryString = new QueryString(queryString); + return context.Request; + } + + [Fact] + public void Sign_AppendsSignatureToQuery() + { + var generator = CreateGenerator(); + + var url = generator.Sign("/files/123"); + + url.Should().StartWith("/files/123?"); + url.Should().Contain("signature="); + } + + [Fact] + public void Sign_AndValidate_RoundTripsClaims() + { + var generator = CreateGenerator(); + var expires = DateTimeOffset.UtcNow.AddMinutes(10); + + var url = generator.Sign( + "/files/abc", + new Dictionary { ["userId"] = "42" }, + expires, + purpose: "download" + ); + + var request = BuildRequest(url); + var ok = generator.TryValidate(request, expectedPurpose: "download", out var claims); + + ok.Should().BeTrue(); + claims.Should().NotBeNull(); + claims!.Path.Should().Be("/files/abc"); + claims.Purpose.Should().Be("download"); + claims.ExpiresAt!.Value.ToUnixTimeSeconds().Should().Be(expires.ToUnixTimeSeconds()); + } + + [Fact] + public void Validate_TamperedQuery_Fails() + { + var generator = CreateGenerator(); + var url = generator.Sign( + "/files/abc", + new Dictionary { ["userId"] = "42" } + ); + var tampered = url.Replace("userId=42", "userId=43", StringComparison.Ordinal); + + var ok = generator.TryValidate(BuildRequest(tampered), out var claims); + + ok.Should().BeFalse(); + claims.Should().BeNull(); + } + + [Fact] + public void Validate_TamperedSignature_Fails() + { + var generator = CreateGenerator(); + var url = generator.Sign("/files/abc"); + + var sigIndex = url.IndexOf("signature=", StringComparison.Ordinal); + var head = url[..(sigIndex + "signature=".Length)]; + var tampered = head + WebEncoders.Base64UrlEncode([0xAA, 0xBB, 0xCC, 0xDD]); + + var ok = generator.TryValidate(BuildRequest(tampered), out _); + + ok.Should().BeFalse(); + } + + [Fact] + public void Validate_MissingSignature_Fails() + { + var generator = CreateGenerator(); + + var ok = generator.TryValidate(BuildRequest("/files/abc?userId=42"), out _); + + ok.Should().BeFalse(); + } + + [Fact] + public void Validate_ExpiredUrl_Fails() + { + var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + var generator = CreateGenerator(clock); + var url = generator.Sign("/files/abc", expiresAt: clock.GetUtcNow().AddMinutes(5)); + + clock.Advance(TimeSpan.FromMinutes(6)); + + var ok = generator.TryValidate(BuildRequest(url), out _); + + ok.Should().BeFalse(); + } + + [Fact] + public void Validate_NoExpiry_AcceptedIndefinitely() + { + var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + var generator = CreateGenerator(clock); + var url = generator.Sign("/files/abc"); + + clock.Advance(TimeSpan.FromDays(365)); + + var ok = generator.TryValidate(BuildRequest(url), out _); + + ok.Should().BeTrue(); + } + + [Fact] + public void Validate_PurposeMismatch_Fails() + { + var generator = CreateGenerator(); + var url = generator.Sign("/files/abc", purpose: "download"); + + var ok = generator.TryValidate(BuildRequest(url), expectedPurpose: "unsubscribe", out _); + + ok.Should().BeFalse(); + } + + [Fact] + public void Validate_PurposeMatch_Succeeds() + { + var generator = CreateGenerator(); + var url = generator.Sign("/files/abc", purpose: "download"); + + var ok = generator.TryValidate( + BuildRequest(url), + expectedPurpose: "download", + out var claims + ); + + ok.Should().BeTrue(); + claims!.Purpose.Should().Be("download"); + } + + [Fact] + public void Validate_PurposeReuseAcrossEndpoints_Isolated() + { + var generator = CreateGenerator(); + var url = generator.Sign("/unsubscribe/123", purpose: "unsubscribe"); + + var tamperedRequest = BuildRequest( + url.Replace("/unsubscribe/123", "/delete-account/123", StringComparison.Ordinal) + ); + + var ok = generator.TryValidate(tamperedRequest, expectedPurpose: "unsubscribe", out _); + + ok.Should().BeFalse(); + } + + [Fact] + public void Validate_QueryParameterOrder_DoesNotMatter() + { + var generator = CreateGenerator(); + var url = generator.Sign( + "/files/abc", + new Dictionary + { + ["a"] = "1", + ["b"] = "2", + ["c"] = "3", + } + ); + + var path = url[..url.IndexOf('?', StringComparison.Ordinal)]; + var queryDict = QueryHelpers.ParseQuery(url[url.IndexOf('?', StringComparison.Ordinal)..]); + var reordered = QueryHelpers.AddQueryString( + path, + queryDict.Reverse().ToDictionary(kv => kv.Key, kv => (string?)kv.Value.ToString()) + ); + + var ok = generator.TryValidate(BuildRequest(reordered), out _); + + ok.Should().BeTrue(); + } + + [Fact] + public void Sign_ReservedQueryKey_Throws() + { + var generator = CreateGenerator(); + + var act = () => + generator.Sign("/files/abc", new Dictionary { ["signature"] = "x" }); + + act.Should().Throw().WithMessage("*reserved*"); + } + + [Fact] + public void Sign_PathContainingQueryString_Throws() + { + var generator = CreateGenerator(); + + var act = () => generator.Sign("/files/abc?already=here"); + + act.Should().Throw().WithMessage("*query string*"); + } + + [Fact] + public void Validate_PurposeBoundUrl_WithoutExpectedPurpose_Fails() + { + var generator = CreateGenerator(); + var url = generator.Sign("/files/abc", purpose: "download"); + + var ok = generator.TryValidate(BuildRequest(url), out _); + + ok.Should().BeFalse(); + } + + [Fact] + public void Validate_NoPurposeUrl_WithExpectedPurpose_Fails() + { + var generator = CreateGenerator(); + var url = generator.Sign("/files/abc"); + + var ok = generator.TryValidate(BuildRequest(url), expectedPurpose: "download", out _); + + ok.Should().BeFalse(); + } + + [Fact] + public void Sign_DifferentKeyRings_ProduceDifferentSignatures() + { + var a = CreateGenerator(); + var b = CreateGenerator(); + + var urlA = a.Sign("/files/abc"); + + var ok = b.TryValidate(BuildRequest(urlA), out _); + + ok.Should().BeFalse(); + } +} diff --git a/tests/SimpleModule.Core.Tests/SimpleModule.Core.Tests.csproj b/tests/SimpleModule.Core.Tests/SimpleModule.Core.Tests.csproj index c836cf7d..0f79ac08 100644 --- a/tests/SimpleModule.Core.Tests/SimpleModule.Core.Tests.csproj +++ b/tests/SimpleModule.Core.Tests/SimpleModule.Core.Tests.csproj @@ -12,6 +12,7 @@ +