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 ErrorOr;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
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))
return Error.Unauthorized();
return null;
var result = await _handler.ValidateTokenAsync(token, _validationParameters);
if (!result.IsValid)
return Error.Unauthorized();
return null;
if (!result.Claims.TryGetValue("prefix", out var claim))
return Error.Unauthorized();
if (claim is not string prefix)
return Error.Unauthorized();
if (string.IsNullOrEmpty(prefix))
return Error.Unauthorized();
return prefix;
return result.Claims.TryGetValue("prefix", out var claim)
&& claim is string prefix
&& !string.IsNullOrEmpty(prefix)
? prefix
: null;
}
public void Dispose() => _rsa.Dispose();
@@ -28,19 +28,27 @@ public sealed class TicketValidationMiddleware(
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);
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
var prefix = validation.Value;
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);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
@@ -51,6 +59,9 @@ public sealed class TicketValidationMiddleware(
await next(context);
}
private static bool HasTraversalSegment(string objectKey) =>
objectKey.Split('/').Any(segment => segment is ".." or ".");
private static void StripQueryParameter(HttpContext context, string name)
{
var remaining = context.Request.Query
@@ -5,7 +5,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ErrorOr" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
<PackageReference Include="Yarp.ReverseProxy" />
</ItemGroup>