harden(gateway): prefix-boundary + path-traversal checks; simplify ticket validation

Tighten the stream-ticket authorization in the gateway middleware:
- Enforce a path-segment boundary on the prefix check instead of a bare
  StartsWith, so a ticket for 'users/12' no longer grants 'users/1234/...'
  (exact-match still allowed for single-object grants)
- Reject object keys containing '..'/'.' segments before proxying, closing a
  path-traversal bypass that SeaweedFS would otherwise resolve outside the
  authorized prefix

Also simplify TicketValidator.ValidateTicketAsync from ErrorOr<string> to
string? (null = invalid) since it has a single failure mode; drop the now
unused ErrorOr package reference from the gateway.

Verified: build clean; the authorization predicate passes a table of cases
covering both the escalation and traversal vulnerabilities.

Build: 0 warnings, 0 errors.
This commit is contained in:
2026-07-05 21:50:43 +02:00
parent 695f4378c6
commit 3e03fbf078
3 changed files with 28 additions and 20 deletions
@@ -1,5 +1,4 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using ErrorOr;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -35,25 +34,24 @@ public sealed class TicketValidator : IDisposable
}; };
} }
public async Task<ErrorOr<string>> ValidateTicketAsync(string? token) /// <summary>
/// Validates the stream ticket and returns the authorized object-key prefix,
/// or <see langword="null"/> if the ticket is missing, invalid, or carries no prefix.
/// </summary>
public async Task<string?> ValidateTicketAsync(string? token)
{ {
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token))
return Error.Unauthorized(); return null;
var result = await _handler.ValidateTokenAsync(token, _validationParameters); var result = await _handler.ValidateTokenAsync(token, _validationParameters);
if (!result.IsValid) if (!result.IsValid)
return Error.Unauthorized(); return null;
if (!result.Claims.TryGetValue("prefix", out var claim)) return result.Claims.TryGetValue("prefix", out var claim)
return Error.Unauthorized(); && claim is string prefix
&& !string.IsNullOrEmpty(prefix)
if (claim is not string prefix) ? prefix
return Error.Unauthorized(); : null;
if (string.IsNullOrEmpty(prefix))
return Error.Unauthorized();
return prefix;
} }
public void Dispose() => _rsa.Dispose(); public void Dispose() => _rsa.Dispose();
@@ -28,19 +28,27 @@ public sealed class TicketValidationMiddleware(
token = context.Request.Headers[options.HeaderName].ToString(); token = context.Request.Headers[options.HeaderName].ToString();
} }
var validation = await ticketValidator.ValidateTicketAsync(token); var prefix = await ticketValidator.ValidateTicketAsync(token);
if (validation.IsError) if (prefix is null)
{ {
logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path); logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path);
context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return; return;
} }
var prefix = validation.Value;
var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty; var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty;
if (!objectKey.StartsWith(prefix, StringComparison.Ordinal))
if (HasTraversalSegment(objectKey))
{
logger.LogWarning("Rejected media request {ObjectKey}: contains a path traversal segment", objectKey);
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
var prefixBoundary = prefix.EndsWith('/') ? prefix : prefix + "/";
if (!string.Equals(objectKey, prefix, StringComparison.Ordinal)
&& !objectKey.StartsWith(prefixBoundary, StringComparison.Ordinal))
{ {
logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix); logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix);
context.Response.StatusCode = StatusCodes.Status403Forbidden; context.Response.StatusCode = StatusCodes.Status403Forbidden;
@@ -51,6 +59,9 @@ public sealed class TicketValidationMiddleware(
await next(context); await next(context);
} }
private static bool HasTraversalSegment(string objectKey) =>
objectKey.Split('/').Any(segment => segment is ".." or ".");
private static void StripQueryParameter(HttpContext context, string name) private static void StripQueryParameter(HttpContext context, string name)
{ {
var remaining = context.Request.Query var remaining = context.Request.Query
@@ -5,7 +5,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ErrorOr" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" /> <PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
<PackageReference Include="Yarp.ReverseProxy" /> <PackageReference Include="Yarp.ReverseProxy" />
</ItemGroup> </ItemGroup>