Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6febeb5
WIP: sliding-window rate limiter + security headers (checkpoint)
stef-k Mar 22, 2026
d18060a
WIP: authenticated user rate limiting + migration (checkpoint)
stef-k Mar 22, 2026
fdbd50f
WIP: outbound request budget for OSM protection (checkpoint)
stef-k Mar 22, 2026
6eb7144
Security: harden tile proxy with sliding-window rate limiter, outboun…
stef-k Mar 22, 2026
a0052bd
Fix all review findings: admin UI, OSM compliance, concurrency, usabi…
stef-k Mar 22, 2026
4efb56c
Fix checkbox unchecking in admin settings (hidden field fallback)
stef-k Mar 22, 2026
b55f290
Update CHANGELOG with checkbox fix entry
stef-k Mar 22, 2026
4f806dd
Fix review findings: eviction atomicity, per-cache cleanup, backgroun…
stef-k Mar 22, 2026
0dc8ea1
Fix review pass 2: lock contention, cold-cache latency, eviction race…
stef-k Mar 22, 2026
3f1040c
Fix review pass 3: lock contention, purge ordering, budget retries, t…
stef-k Mar 22, 2026
d732c4b
Fix review pass 4: eviction concurrency, purge ordering, orphan query…
stef-k Mar 22, 2026
e2e4e62
Fix review pass 5: PurgeBatchAsync cache size drift for zoom 0-8 tiles
stef-k Mar 22, 2026
04ca005
Fix review pass 6: purge retry safety, sliding-window accuracy, concu…
stef-k Mar 22, 2026
7580510
Fix review pass 7: index, purge perf, lock convoy, budget timeout, ra…
stef-k Mar 22, 2026
69de2c8
Fix review pass 8: per-IP outbound budget, IPv6 normalization, purge …
stef-k Mar 22, 2026
3e7b561
Fix review pass 9: purge robustness, lock chunking, DI safety, per-IP…
stef-k Mar 22, 2026
2ccaa7e
Fix review pass 10: concurrent insert race, purge memory, retry safety
stef-k Mar 22, 2026
71ee070
Fix review pass 11: add missing ProxyImageRateLimitEnabled to admin UI
stef-k Mar 22, 2026
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
15 changes: 15 additions & 0 deletions Areas/Admin/Controllers/SettingsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ public async Task<IActionResult> Update(ApplicationSettings updatedSettings)
"Minimum cache size is 256 MB (OSM requires at least 7 days of cached tiles). Use -1 to disable.");
}

// Authenticated users should always have at least the same rate limit as anonymous users.
if (updatedSettings.TileRateLimitAuthenticatedPerMinute < updatedSettings.TileRateLimitPerMinute)
{
ModelState.AddModelError(nameof(updatedSettings.TileRateLimitAuthenticatedPerMinute),
"Authenticated rate limit must be equal to or greater than the anonymous rate limit.");
}

if (currentSettings != null)
{
// Validate tile provider settings before model validation.
Expand Down Expand Up @@ -176,6 +183,10 @@ void Track<T>(string name, T oldVal, T newVal)
}
Track("TileRateLimitEnabled", currentSettings.TileRateLimitEnabled, updatedSettings.TileRateLimitEnabled);
Track("TileRateLimitPerMinute", currentSettings.TileRateLimitPerMinute, updatedSettings.TileRateLimitPerMinute);
Track("TileRateLimitAuthenticatedPerMinute", currentSettings.TileRateLimitAuthenticatedPerMinute, updatedSettings.TileRateLimitAuthenticatedPerMinute);
Track("TileOutboundBudgetPerIpPerMinute", currentSettings.TileOutboundBudgetPerIpPerMinute, updatedSettings.TileOutboundBudgetPerIpPerMinute);
Track("ProxyImageRateLimitEnabled", currentSettings.ProxyImageRateLimitEnabled, updatedSettings.ProxyImageRateLimitEnabled);
Track("ProxyImageRateLimitPerMinute", currentSettings.ProxyImageRateLimitPerMinute, updatedSettings.ProxyImageRateLimitPerMinute);

// Trip Place Auto-Visited settings
Track("VisitedRequiredHits", currentSettings.VisitedRequiredHits, updatedSettings.VisitedRequiredHits);
Expand Down Expand Up @@ -219,6 +230,10 @@ void Track<T>(string name, T oldVal, T newVal)
currentSettings.TileProviderApiKey = updatedSettings.TileProviderApiKey;
currentSettings.TileRateLimitEnabled = updatedSettings.TileRateLimitEnabled;
currentSettings.TileRateLimitPerMinute = updatedSettings.TileRateLimitPerMinute;
currentSettings.TileRateLimitAuthenticatedPerMinute = updatedSettings.TileRateLimitAuthenticatedPerMinute;
currentSettings.TileOutboundBudgetPerIpPerMinute = updatedSettings.TileOutboundBudgetPerIpPerMinute;
currentSettings.ProxyImageRateLimitEnabled = updatedSettings.ProxyImageRateLimitEnabled;
currentSettings.ProxyImageRateLimitPerMinute = updatedSettings.ProxyImageRateLimitPerMinute;

// Trip Place Auto-Visited settings
currentSettings.VisitedRequiredHits = updatedSettings.VisitedRequiredHits;
Expand Down
91 changes: 79 additions & 12 deletions Areas/Admin/Views/Settings/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,8 @@
<input type="checkbox" class="form-check-input form-check-input-lg"
id="IsRegistrationOpen" name="IsRegistrationOpen"
value="true" @(Model.IsRegistrationOpen ? "checked" : "")/>
@* Hidden fallback ensures "false" is posted when checkbox is unchecked *@
<input type="hidden" name="IsRegistrationOpen" value="false"/>
<label class="form-check-label" for="IsRegistrationOpen">Enable new user
registrations</label>
</div>
Expand Down Expand Up @@ -578,28 +580,30 @@
<div class="row">
<h3 class="text-center fs-4">Tile Request Rate Limiting</h3>
<p class="text-muted">
Protect the tile proxy from abuse by rate limiting anonymous requests.
Authenticated users (logged-in) are never rate limited.
Protect the tile proxy from abuse by rate limiting tile requests.
Anonymous users are limited by IP address; authenticated users by user ID at a higher threshold.
</p>

<div class="col-md-4 mb-3">
<div class="col-md-3 mb-3">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input"
id="TileRateLimitEnabled" name="TileRateLimitEnabled"
value="true" @(Model.TileRateLimitEnabled ? "checked" : "")/>
@* Hidden fallback ensures "false" is posted when checkbox is unchecked *@
<input type="hidden" name="TileRateLimitEnabled" value="false"/>
<label class="form-check-label" for="TileRateLimitEnabled">
Enable rate limiting for anonymous requests
Enable tile rate limiting
<i class="bi bi-question-circle text-muted ms-1"
data-tippy-content="Protects your tile proxy from abuse. Logged-in users are never limited. Affects embeds, public timeline views, and anonymous visitors."></i>
data-tippy-content="Protects your tile proxy from abuse. Applies separate limits to anonymous and authenticated users."></i>
</label>
</div>
<small class="form-text text-muted">
When enabled, anonymous tile requests are limited per IP address.
When enabled, tile requests are limited for all users.
</small>
</div>

<div class="col-md-4 mb-3">
<label asp-for="TileRateLimitPerMinute" class="form-label">Requests per Minute (per IP)</label>
<div class="col-md-3 mb-3">
<label asp-for="TileRateLimitPerMinute" class="form-label">Anonymous (per IP)</label>
<div class="input-group">
<input asp-for="TileRateLimitPerMinute" class="form-control" type="number" min="50" max="10000" />
<span class="input-group-text">req/min</span>
Expand All @@ -610,14 +614,39 @@
<span asp-validation-for="TileRateLimitPerMinute" class="text-danger"></span>
</div>

<div class="col-md-4 mb-3">
<div class="col-md-3 mb-3">
<label asp-for="TileRateLimitAuthenticatedPerMinute" class="form-label">Authenticated (per user)</label>
<div class="input-group">
<input asp-for="TileRateLimitAuthenticatedPerMinute" class="form-control" type="number" min="100" max="50000" />
<span class="input-group-text">req/min</span>
</div>
<small class="form-text text-muted">
Default: @ApplicationSettings.DefaultTileRateLimitAuthenticatedPerMinute. Higher limit for trusted users.
</small>
<span asp-validation-for="TileRateLimitAuthenticatedPerMinute" class="text-danger"></span>
</div>

<div class="col-md-3 mb-3">
<label asp-for="TileOutboundBudgetPerIpPerMinute" class="form-label">Cache miss budget (per IP)</label>
<div class="input-group">
<input asp-for="TileOutboundBudgetPerIpPerMinute" class="form-control" type="number" min="0" max="1000" />
<span class="input-group-text">miss/min</span>
</div>
<small class="form-text text-muted">
Default: @ApplicationSettings.DefaultTileOutboundBudgetPerIpPerMinute. Max upstream fetches per IP per minute. 0 = disabled.
</small>
<span asp-validation-for="TileOutboundBudgetPerIpPerMinute" class="text-danger"></span>
</div>
</div>
<div class="row">
<div class="col-md-3 mb-3">
<div class="alert alert-info mb-0">
<strong><i class="bi bi-shield-check me-1"></i>Who is affected:</strong>
<ul class="mb-0 mt-1">
<li><strong>Not limited:</strong> Logged-in users</li>
<li><strong>Limited:</strong> Embeds, public pages, anonymous visitors</li>
<li><strong>Logged-in:</strong> Limited per user ID</li>
<li><strong>Anonymous:</strong> Limited per IP address</li>
</ul>
<small class="text-muted">500/min is generous for normal viewing.</small>
<small class="text-muted">Both limits use sliding-window counting.</small>
</div>
</div>
</div>
Expand Down Expand Up @@ -798,6 +827,44 @@
</div>
</div>

<div class="row mb-3">
<h3 class="text-center fs-4">Image Proxy Rate Limiting</h3>
<p class="text-muted">
Protect upstream image origins from abuse by rate limiting anonymous proxy image requests.
Authenticated users are never rate limited.
</p>

<div class="col-md-6 mb-3">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input"
id="ProxyImageRateLimitEnabled" name="ProxyImageRateLimitEnabled"
value="true" @(Model.ProxyImageRateLimitEnabled ? "checked" : "")/>
@* Hidden fallback ensures "false" is posted when checkbox is unchecked *@
<input type="hidden" name="ProxyImageRateLimitEnabled" value="false"/>
<label class="form-check-label" for="ProxyImageRateLimitEnabled">
Enable image proxy rate limiting
<i class="bi bi-question-circle text-muted ms-1"
data-tippy-content="Rate limits anonymous proxy image requests by IP address to prevent abuse and origin flooding."></i>
</label>
</div>
<small class="form-text text-muted">
When enabled, anonymous image proxy requests are limited per IP.
</small>
</div>

<div class="col-md-6 mb-3">
<label asp-for="ProxyImageRateLimitPerMinute" class="form-label">Anonymous (per IP)</label>
<div class="input-group">
<input asp-for="ProxyImageRateLimitPerMinute" class="form-control" type="number" min="10" max="5000" />
<span class="input-group-text">req/min</span>
</div>
<small class="form-text text-muted">
Default: @ApplicationSettings.DefaultProxyImageRateLimitPerMinute. Max proxy image requests per minute per IP.
</small>
<span asp-validation-for="ProxyImageRateLimitPerMinute" class="text-danger"></span>
</div>
</div>

<hr/>

<div class="row">
Expand Down
70 changes: 61 additions & 9 deletions Areas/Public/Controllers/TilesController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using Wayfarer.Parsers;
using Wayfarer.Services;
Expand Down Expand Up @@ -29,8 +30,26 @@ public class TilesController : Controller
/// <summary>
/// Thread-safe dictionary for rate limiting anonymous tile requests by IP address.
/// Uses atomic operations via <see cref="RateLimitHelper"/> to prevent race conditions.
/// Exposed internally for periodic background cleanup by <see cref="Wayfarer.Jobs.RateLimitCleanupJob"/>.
/// </summary>
private static readonly ConcurrentDictionary<string, RateLimitHelper.RateLimitEntry> RateLimitCache = new();
internal static readonly ConcurrentDictionary<string, RateLimitHelper.RateLimitEntry> RateLimitCache = new();

/// <summary>
/// Thread-safe dictionary for rate limiting authenticated tile requests by user ID.
/// Separate from anonymous rate limiting to apply different (higher) limits for trusted users
/// while still preventing abuse from compromised accounts.
/// Exposed internally for periodic background cleanup by <see cref="Wayfarer.Jobs.RateLimitCleanupJob"/>.
/// </summary>
internal static readonly ConcurrentDictionary<string, RateLimitHelper.RateLimitEntry> AuthRateLimitCache = new();

/// <summary>
/// Thread-safe dictionary for tracking per-IP outbound budget consumption (cache miss rate).
/// Prevents a single IP from monopolizing the global outbound token budget by limiting how
/// many upstream tile fetches a single client can trigger per minute.
/// Uses the same sliding-window pattern as request rate limiting.
/// Exposed internally for periodic background cleanup by <see cref="Wayfarer.Jobs.RateLimitCleanupJob"/>.
/// </summary>
internal static readonly ConcurrentDictionary<string, RateLimitHelper.RateLimitEntry> OutboundBudgetCache = new();

private readonly ILogger<TilesController> _logger;
private readonly TileCacheService _tileCacheService;
Expand All @@ -51,6 +70,11 @@ public TilesController(ILogger<TilesController> logger, TileCacheService tileCac
public async Task<IActionResult> GetTile(int z, int x, int y)
{
// Validate the referer header to prevent third-party exploitation.
// Intentionally restrictive: rejects requests without a same-origin Referer.
// Acceptable because: mobile app fetches tiles directly from OSM (not this proxy),
// embedded maps (iframes) work because tile requests originate same-origin,
// and non-browser API clients are not expected to use this endpoint.
// The rate limiter is the primary abuse defense; this is an additional deterrent.
string? refererValue = Request.Headers["Referer"].ToString();
if (string.IsNullOrEmpty(refererValue) || !IsValidReferer(refererValue))
{
Expand All @@ -77,15 +101,41 @@ public async Task<IActionResult> GetTile(int z, int x, int y)
// Resolve the tile provider template from settings or presets.
var settings = _settingsService.GetSettings();

// Rate limit anonymous requests to prevent tile scraping abuse.
// Authenticated users (logged-in) are never rate limited.
if (User.Identity?.IsAuthenticated != true && settings.TileRateLimitEnabled)
// Rate limit strategy: anonymous by IP, authenticated by userId (mutually exclusive).
// An authenticated user is NOT also counted against the IP limit. This avoids
// unfairly penalizing users behind shared NATs (e.g., corporate networks).
// The outbound budget (OutboundBudget) provides system-wide protection regardless.
if (settings.TileRateLimitEnabled)
{
var clientIp = GetClientIpAddress();
if (RateLimitHelper.IsRateLimitExceeded(RateLimitCache, clientIp, settings.TileRateLimitPerMinute))
var userId = User.Identity?.IsAuthenticated == true
? User.FindFirstValue(ClaimTypes.NameIdentifier)
: null;

if (userId != null)
{
// Authenticated user with a valid user ID — rate limit by userId.
if (RateLimitHelper.IsRateLimitExceeded(AuthRateLimitCache, userId, settings.TileRateLimitAuthenticatedPerMinute))
{
_logger.LogWarning("Tile rate limit exceeded for authenticated user: {UserId}", userId);
return StatusCode(429, "Too many requests. Please try again later.");
}
}
else
{
_logger.LogWarning("Tile rate limit exceeded for IP: {ClientIp}", clientIp);
return StatusCode(429, "Too many requests. Please try again later.");
// Authenticated user without a NameIdentifier claim — unexpected, log for diagnostics.
// Falls back to the stricter anonymous (IP-based) rate limit as a safe-side default.
if (User.Identity?.IsAuthenticated == true)
{
_logger.LogWarning("Authenticated user without NameIdentifier claim — falling back to IP-based rate limiting");
}

// Anonymous user or authenticated user without a NameIdentifier claim — rate limit by IP.
var clientIp = GetClientIpAddress();
if (RateLimitHelper.IsRateLimitExceeded(RateLimitCache, clientIp, settings.TileRateLimitPerMinute))
{
_logger.LogWarning("Tile rate limit exceeded for IP: {ClientIp}", clientIp);
return StatusCode(429, "Too many requests. Please try again later.");
}
}
}
var preset = TileProviderCatalog.FindPreset(settings.TileProviderKey);
Expand All @@ -100,7 +150,7 @@ public async Task<IActionResult> GetTile(int z, int x, int y)

// Call the tile cache service to retrieve the tile.
// The service will either return the cached tile data or (if missing) download, cache, and then return it.
var tileData = await _tileCacheService.RetrieveTileAsync(z.ToString(), x.ToString(), y.ToString(), tileUrl);
var tileData = await _tileCacheService.RetrieveTileAsync(z.ToString(), x.ToString(), y.ToString(), tileUrl, HttpContext.RequestAborted);
if (tileData == null)
{
_logger.LogError("Tile data not found for {z}/{x}/{y}", z, x, y);
Expand All @@ -110,6 +160,8 @@ public async Task<IActionResult> GetTile(int z, int x, int y)
// Set browser cache headers. Tiles are stable and rarely change;
// 1-day browser caching eliminates redundant requests.
Response.Headers["Cache-Control"] = "public, max-age=86400";
// Prevent browsers from MIME-sniffing PNG responses as a different content type.
Response.Headers["X-Content-Type-Options"] = "nosniff";

// Return the tile data with the appropriate content type.
return File(tileData, "image/png");
Expand Down
Loading
Loading