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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ GET https://moogleapi.com/api/monsters?gameId=7
GET https://moogleapi.com/api/games
```

No API key required. Pass `X-Api-Key: your-key` to get 10× the rate limit.
No API key required. Pass an issued `X-Api-Key: your-key` to get 10× the rate limit.

---

Expand Down Expand Up @@ -153,10 +153,14 @@ Stages can be run individually with `--only=`: `games`, `characters`, `playable`
| Tier | Limit | How |
|------|-------|-----|
| Anonymous | 60 req / min | Per IP, no setup needed |
| Premium | 600 req / min | Pass `X-Api-Key: your-key` header |
| Premium | 600 req / min | Pass an issued `X-Api-Key: your-key` header |

Responses over the limit return `429 Too Many Requests`.

Premium keys have to be issued — an unrecognized key isn't rejected, it just falls back to the
anonymous limit, so the API stays usable if you send a stale one. Self-hosting? Set the
allowlist with `ApiKeys__Keys__0`, `ApiKeys__Keys__1`, … With none set, everything is anonymous.

---

## 📜 Disclaimer
Expand Down
10 changes: 9 additions & 1 deletion src/MoogleAPI.Web/Features/Dashboard/Browse/MonstersEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,16 @@ public override async Task HandleAsync(BrowseRequest req, CancellationToken ct)
if (req.GameId.HasValue)
query = query.Where(m => m.GameId == req.GameId.Value);

// Name *or* description, matching what /api/monsters/search already does. Curation
// needs the description: what marks a row as not-a-monster — "#REDIRECT ... enemy
// abilities", "may refer to", "is a genus of" — is only ever in the prose the scrape
// kept, never in the name. Searching names alone left those rows unreachable here.
if (!string.IsNullOrWhiteSpace(req.Search))
query = query.Where(m => EF.Functions.ILike(m.Name, $"%{req.Search.Trim()}%"));
{
var search = req.Search.Trim();
query = query.Where(m => EF.Functions.ILike(m.Name, $"%{search}%") ||
(m.Description != null && EF.Functions.ILike(m.Description, $"%{search}%")));
}

var total = await query.CountAsync(ct);
var items = await query
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
using System.Text;
using MoogleAPI.Web.Infrastructure.Data;
using MoogleAPI.Web.Infrastructure.Models;
using MoogleAPI.Web.Infrastructure.RateLimiting;

namespace MoogleAPI.Web.Infrastructure.Middleware;

public class RequestLoggingMiddleware(RequestDelegate next, IServiceScopeFactory scopeFactory)
public class RequestLoggingMiddleware(
RequestDelegate next,
IServiceScopeFactory scopeFactory,
ApiKeyValidator apiKeys)
{
public async Task InvokeAsync(HttpContext context)
{
Expand All @@ -32,11 +36,15 @@ public async Task InvokeAsync(HttpContext context)
await next(context);
var durationMs = (int)(Environment.TickCount64 - start);

// Read off the request before handing to the background write — by the time that runs
// the response is done and the context is no longer ours to read.
var isPremium = apiKeys.ResolveKey(context.Request) is not null;

// Fire-and-forget: never slow down the response for logging
_ = WriteLogAsync(context, path, durationMs);
_ = WriteLogAsync(context, path, durationMs, isPremium);
}

private async Task WriteLogAsync(HttpContext context, string path, int durationMs)
private async Task WriteLogAsync(HttpContext context, string path, int durationMs, bool isPremium)
{
try
{
Expand All @@ -52,7 +60,9 @@ private async Task WriteLogAsync(HttpContext context, string path, int durationM
DurationMs = durationMs,
ResourceType = ExtractResourceType(path),
SearchTerm = context.Request.Query["query"].FirstOrDefault(),
IsPremium = context.Request.Headers.ContainsKey("X-Api-Key"),
// Recognized key, not merely a present header — otherwise the premium share in
// /api/stats counts anyone who sent the header, valid or not.
IsPremium = isPremium,
IpHash = HashIp(context.Connection.RemoteIpAddress?.ToString()),
});

Expand Down
35 changes: 35 additions & 0 deletions src/MoogleAPI.Web/Infrastructure/RateLimiting/ApiKeyValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Microsoft.Extensions.Options;

namespace MoogleAPI.Web.Infrastructure.RateLimiting;

/// <summary>
/// Decides whether an <c>X-Api-Key</c> header actually entitles a caller to the premium rate
/// limit. The header used to be taken at face value — any non-empty value bought 10× the
/// anonymous limit, so the limit was opt-out rather than enforced.
/// </summary>
public class ApiKeyValidator
{
public const string HeaderName = "X-Api-Key";

private readonly HashSet<string> _keys;

public ApiKeyValidator(IOptions<PremiumKeyOptions> options) =>
_keys = options.Value.Keys
.Where(k => !string.IsNullOrWhiteSpace(k))
.Select(k => k.Trim())
.ToHashSet(StringComparer.Ordinal);

public bool IsValid(string? apiKey) =>
!string.IsNullOrWhiteSpace(apiKey) && _keys.Contains(apiKey.Trim());

/// <summary>
/// The recognized key on this request, or <c>null</c> for anonymous. An unrecognized key
/// returns <c>null</c> rather than throwing: the API is public and documented as needing no
/// key at all, so a bad one degrades to the anonymous limit instead of failing the request.
/// </summary>
public string? ResolveKey(HttpRequest request)
{
var apiKey = request.Headers[HeaderName].ToString();
return IsValid(apiKey) ? apiKey.Trim() : null;
}
}
Original file line number Diff line number Diff line change
@@ -1,59 +1,46 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

namespace MoogleAPI.Web.Infrastructure.RateLimiting;

public static class ApiRateLimiterPolicy
{
public const string Anonymous = "anonymous";
public const string Premium = "premium";
public const int AnonymousPermitLimit = 60;
public const int PremiumPermitLimit = 600;

public static IServiceCollection AddApiRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

options.AddPolicy(Anonymous, context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
}));

// Premium users identified by X-Api-Key header get 10x the limit
options.AddPolicy(Premium, context =>
// One global limiter rather than named policies. There were two named policies here
// that no endpoint ever attached with RequireRateLimiting, so they enforced nothing
// while appearing to — and the premium one carried the same unchecked-key flaw as
// this limiter did. Anything that needs a per-endpoint limit should be added back
// deliberately, and wired up.
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var apiKey = context.Request.Headers["X-Api-Key"].ToString();
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: string.IsNullOrEmpty(apiKey) ? $"ip:{context.Connection.RemoteIpAddress}" : $"key:{apiKey}",
factory: _ => new FixedWindowRateLimiterOptions
// The key is validated against the configured allowlist, so the partition is
// only ever keyed on a credential we issued. Partitioning on the raw header
// would otherwise let a caller mint unlimited fresh windows just by varying it.
var validator = context.RequestServices.GetRequiredService<ApiKeyValidator>();
var apiKey = validator.ResolveKey(context.Request);

var partitionKey = apiKey is null
? $"ip:{context.Connection.RemoteIpAddress}"
: $"key:{apiKey}";

var permitLimit = apiKey is null ? AnonymousPermitLimit : PremiumPermitLimit;

return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ =>
new FixedWindowRateLimiterOptions
{
PermitLimit = string.IsNullOrEmpty(apiKey) ? 60 : 600,
PermitLimit = permitLimit,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});

options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var apiKey = context.Request.Headers["X-Api-Key"].ToString();
var limit = string.IsNullOrEmpty(apiKey) ? 60 : 600;
var key = string.IsNullOrEmpty(apiKey)
? $"ip:{context.Connection.RemoteIpAddress}"
: $"key:{apiKey}";

return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = limit,
Window = TimeSpan.FromMinutes(1)
});
});
});

return services;
Expand Down
22 changes: 22 additions & 0 deletions src/MoogleAPI.Web/Infrastructure/RateLimiting/PremiumKeyOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace MoogleAPI.Web.Infrastructure.RateLimiting;

/// <summary>
/// Named for what these keys buy rather than what they are: the API itself needs no key, and
/// <c>ApiKeyOptions</c> would collide with Scalar's type of that name in <c>Program.cs</c>.
/// </summary>
public class PremiumKeyOptions
{
public const string SectionName = "ApiKeys";

/// <summary>
/// The keys entitled to the premium rate limit. Anything not on this list is treated as
/// anonymous, so an empty list simply means nobody has premium — which is the correct
/// default, and the reason this isn't validated at startup the way
/// <see cref="Puzzles.DailyPuzzleOptions.Secret"/> is.
/// </summary>
/// <remarks>
/// Supply via <c>ApiKeys__Keys__0</c>, <c>ApiKeys__Keys__1</c>, … (env vars) or user-secrets
/// in development. These are credentials: keep them out of appsettings.json.
/// </remarks>
public List<string> Keys { get; set; } = [];
}
6 changes: 5 additions & 1 deletion src/MoogleAPI.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@
};
});

// Partitioned rate limiting: 60 req/min anonymous, 600 req/min with X-Api-Key
// Partitioned rate limiting: 60 req/min anonymous, 600 req/min with a recognized X-Api-Key.
// The allowlist is what makes the premium tier mean anything — without it the header was
// self-service. No startup validation: an empty list legitimately means nobody has premium.
builder.Services.Configure<PremiumKeyOptions>(builder.Configuration.GetSection(PremiumKeyOptions.SectionName));
builder.Services.AddSingleton<ApiKeyValidator>();
builder.Services.AddApiRateLimiting();

// Daily puzzle seeding. Validated at startup rather than on first request: an empty secret
Expand Down
102 changes: 102 additions & 0 deletions tests/MoogleAPI.Tests/ApiKeyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using MoogleAPI.Web.Infrastructure.RateLimiting;

namespace MoogleAPI.Tests;

public class ApiKeyTests
{
private static ApiKeyValidator Validator(params string[] keys) =>
new(Options.Create(new PremiumKeyOptions { Keys = [.. keys] }));

private static HttpRequest RequestWith(string? apiKey)
{
var context = new DefaultHttpContext();
if (apiKey is not null)
context.Request.Headers[ApiKeyValidator.HeaderName] = apiKey;
return context.Request;
}

[Fact]
public void RecognizedKeyIsValid()
{
Assert.True(Validator("sponsor-key").IsValid("sponsor-key"));
}

// The regression this whole class exists for: any non-empty header used to buy the
// premium limit, because the value was never compared against anything.
[Theory]
[InlineData("x")]
[InlineData("not-a-real-key")]
[InlineData("SPONSOR-KEY")] // keys are case-sensitive
[InlineData("sponsor-key-2")] // no prefix matching
public void UnrecognizedKeyIsNotValid(string apiKey)
{
Assert.False(Validator("sponsor-key").IsValid(apiKey));
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void MissingOrBlankKeyIsNotValid(string? apiKey)
{
Assert.False(Validator("sponsor-key").IsValid(apiKey));
}

[Fact]
public void NoConfiguredKeysMeansNobodyIsPremium()
{
var validator = Validator();

Assert.False(validator.IsValid("sponsor-key"));
Assert.False(validator.IsValid("anything"));
}

[Fact]
public void SurroundingWhitespaceIsToleratedOnBothSides()
{
// Config values and curl invocations both pick up stray whitespace; a key that is
// otherwise correct shouldn't be rejected for it.
Assert.True(Validator(" sponsor-key ").IsValid("sponsor-key"));
Assert.True(Validator("sponsor-key").IsValid(" sponsor-key "));
}

[Fact]
public void BlankConfiguredKeysAreDiscarded()
{
// An unset env var binds as an empty string. If that were kept as a key, sending an
// empty header would match it and premium would be self-service again.
var validator = Validator("", " ", "sponsor-key");

Assert.False(validator.IsValid(""));
Assert.False(validator.IsValid(" "));
Assert.True(validator.IsValid("sponsor-key"));
}

[Fact]
public void ResolveKeyReturnsTheKeyForARecognizedHeader()
{
Assert.Equal("sponsor-key", Validator("sponsor-key").ResolveKey(RequestWith("sponsor-key")));
}

[Fact]
public void ResolveKeyFallsBackToAnonymousRatherThanFailing()
{
var validator = Validator("sponsor-key");

Assert.Null(validator.ResolveKey(RequestWith("bogus")));
Assert.Null(validator.ResolveKey(RequestWith(null)));
}

[Fact]
public void ResolveKeyNormalizesSoOneKeyCannotHoldSeveralRateLimitWindows()
{
// The resolved value becomes the limiter's partition key. If padding survived here,
// " key" and "key " would be separate partitions and the limit would multiply.
var validator = Validator("sponsor-key");

Assert.Equal("sponsor-key", validator.ResolveKey(RequestWith(" sponsor-key")));
Assert.Equal("sponsor-key", validator.ResolveKey(RequestWith("sponsor-key ")));
}
}