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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 21:50:43 +02:00
co-authored by Claude Opus 4.8
parent a40de35a57
commit 45ce66a6ee
3 changed files with 28 additions and 20 deletions
@@ -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