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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageVersion Include="FluentAssertions" Version="8.3.0" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="Bogus" Version="35.6.1" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.3" />
Expand Down
17 changes: 17 additions & 0 deletions framework/SimpleModule.Core/Security/ISignedUrlGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Http;

namespace SimpleModule.Core.Security;

public interface ISignedUrlGenerator
{
string Sign(
string path,
IDictionary<string, string?>? query = null,
DateTimeOffset? expiresAt = null,
string? purpose = null
);

bool TryValidate(HttpRequest request, out SignedUrlClaims? claims);

bool TryValidate(HttpRequest request, string? expectedPurpose, out SignedUrlClaims? claims);
}
3 changes: 3 additions & 0 deletions framework/SimpleModule.Core/Security/SignedUrlClaims.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace SimpleModule.Core.Security;

public sealed record SignedUrlClaims(string Path, string? Purpose, DateTimeOffset? ExpiresAt);
Original file line number Diff line number Diff line change
@@ -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<TBuilder>(this TBuilder builder, string? purpose = null)
where TBuilder : IEndpointConventionBuilder
{
builder.AllowAnonymous();
builder.AddEndpointFilter(
async (context, next) =>
{
var generator =
context.HttpContext.RequestServices.GetRequiredService<ISignedUrlGenerator>();

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;
}
}
215 changes: 215 additions & 0 deletions framework/SimpleModule.Core/Security/SignedUrlGenerator.cs
Original file line number Diff line number Diff line change
@@ -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<string, string?>? 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<string, string?>(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<KeyValuePair<string, string?>>();
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<string, string?>(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<KeyValuePair<string, string?>> BuildPairs(
IDictionary<string, string?>? query,
DateTimeOffset? expiresAt,
string? purpose
)
{
var pairs = new List<KeyValuePair<string, string?>>();
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<string, string?>(key, value));
}
}
if (expiresAt is not null)
{
pairs.Add(
new KeyValuePair<string, string?>(
ExpiresQueryKey,
expiresAt.Value.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)
)
);
}
if (purpose is not null)
{
pairs.Add(new KeyValuePair<string, string?>(PurposeQueryKey, purpose));
}
return pairs;
}

private static string BuildCanonical(
string path,
IEnumerable<KeyValuePair<string, string?>> 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();
}
}
3 changes: 3 additions & 0 deletions framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure(

builder.Services.AddScoped<ICspNonce, CspNonce>();

builder.Services.TryAddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ISignedUrlGenerator, SignedUrlGenerator>();

if (options.EnableHealthChecks)
{
builder
Expand Down
Loading
Loading