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();