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
57 changes: 49 additions & 8 deletions server/PlanShare/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,14 @@ created_at TEXT NOT NULL
cmd.ExecuteNonQuery();
}

// --- Rate limiters (in-memory) ---
// Created before Build() so they can be DI-registered and swept by CleanupService.
var rateLimiter = new RateLimiter(maxRequests: 10, windowSeconds: 60);
var analyticsRateLimiter = new RateLimiter(maxRequests: 30, windowSeconds: 60);

// Register the cleanup background service
builder.Services.AddSingleton(new PlanDbConfig(connectionString));
builder.Services.AddSingleton(new RateLimiters(rateLimiter, analyticsRateLimiter));
builder.Services.AddHostedService<CleanupService>();

// Request size limit (10 MB)
Expand All @@ -54,10 +60,6 @@ created_at TEXT NOT NULL
var app = builder.Build();
app.UseCors();

// --- Rate limiters (in-memory) ---
var rateLimiter = new RateLimiter(maxRequests: 10, windowSeconds: 60);
var analyticsRateLimiter = new RateLimiter(maxRequests: 30, windowSeconds: 60);

const int MaxTtlDays = 365;

// --- Endpoints ---
Expand Down Expand Up @@ -161,9 +163,15 @@ created_at TEXT NOT NULL
return Results.BadRequest("Invalid JSON");
}

// Strip referrer to domain only (no full URLs with query params)
if (!string.IsNullOrEmpty(referrer) && Uri.TryCreate(referrer, UriKind.Absolute, out var refUri))
referrer = refUri.Host;
// Strip referrer to domain only (no full URLs with query params).
// If it doesn't parse as an absolute URL, drop it — never persist raw
// client-supplied strings, since the dashboard renders referrers in HTML.
if (!string.IsNullOrEmpty(referrer))
{
referrer = Uri.TryCreate(referrer, UriKind.Absolute, out var refUri)
? refUri.Host
: null;
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defense-in-depth fix looks right. One thing to double-check: a legitimate referrer coming in as a relative URL (e.g. /some-path) now gets dropped entirely instead of stored. Given the dashboard only shows hostnames, that's probably desired, but worth confirming against whatever clients actually POST here.


Generated by Claude Code


// Visitor hash: SHA256(IP + User-Agent + date) — unique per day, no PII stored
var ua = ctx.Request.Headers.UserAgent.FirstOrDefault() ?? "";
Expand Down Expand Up @@ -352,14 +360,18 @@ static string GenerateDeleteToken()

record PlanDbConfig(string ConnectionString);

record RateLimiters(RateLimiter Share, RateLimiter Analytics);

sealed class CleanupService : BackgroundService
{
private readonly PlanDbConfig _config;
private readonly RateLimiters _rateLimiters;
private readonly ILogger<CleanupService> _logger;

public CleanupService(PlanDbConfig config, ILogger<CleanupService> logger)
public CleanupService(PlanDbConfig config, RateLimiters rateLimiters, ILogger<CleanupService> logger)
{
_config = config;
_rateLimiters = rateLimiters;
_logger = logger;
}

Expand Down Expand Up @@ -401,6 +413,14 @@ private void Cleanup()
if (deleted > 0)
_logger.LogInformation("Cleaned up {Count} old page views", deleted);
}

// Evict stale rate-limiter keys so the dictionary doesn't grow forever.
var shareEvicted = _rateLimiters.Share.Sweep();
var analyticsEvicted = _rateLimiters.Analytics.Sweep();
if (shareEvicted + analyticsEvicted > 0)
_logger.LogInformation(
"Evicted {Share} share + {Analytics} analytics rate-limit keys",
shareEvicted, analyticsEvicted);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -441,4 +461,25 @@ public bool IsAllowed(string key)
return true;
}
}

/// <summary>
/// Evicts keys whose timestamp lists have gone empty. Call periodically
/// so the dictionary doesn't grow forever across unique IPs.
/// Returns the number of keys evicted.
/// </summary>
public int Sweep()
{
var cutoff = DateTime.UtcNow.AddSeconds(-_windowSeconds);
var evicted = 0;
foreach (var kvp in _requests)
{
lock (kvp.Value)
{
kvp.Value.RemoveAll(t => t < cutoff);
if (kvp.Value.Count == 0 && _requests.TryRemove(kvp))
evicted++;
}
}
return evicted;
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor race between Sweep() and IsAllowed():

  1. Thread A calls IsAllowed(key)GetOrAdd returns existing list L (all entries expired, but not yet pruned).
  2. Thread B (Sweep) acquires lock on L first, prunes to empty, TryRemoves the key, releases lock.
  3. Thread A acquires lock on the now-detached L, adds now, returns true.
  4. Thread A's next request: GetOrAdd creates a fresh empty list L2, so the earlier timestamp is lost.

Impact is bounded (hourly sweep, 60s window) — a client could get at most one "free" request per race — but if you want it airtight, re-check _requests.ContainsKey(kvp.Key) inside IsAllowed after locking, or switch to AddOrUpdate semantics. Not a blocker.


Generated by Claude Code

}
23 changes: 19 additions & 4 deletions server/PlanShare/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -275,13 +275,28 @@ <h3>Top Referrers (30 days)</h3>
}
function updateReferrers() {
var tbody = document.querySelector("#referrersTable tbody");
tbody.replaceChildren();
if (!planStats || !planStats.traffic.top_referrers.length) {
tbody.innerHTML = "<tr><td colspan='2' style='color:#484f58'>No referrer data yet</td></tr>";
var tr = document.createElement("tr");
var td = document.createElement("td");
td.setAttribute("colspan", "2");
td.style.color = "#484f58";
td.textContent = "No referrer data yet";
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
tbody.innerHTML = planStats.traffic.top_referrers.map(function(r) {
return "<tr><td>" + r.referrer + "</td><td class='num'>" + fmt(r.count) + "</td></tr>";
}).join("");
planStats.traffic.top_referrers.forEach(function(r) {
var tr = document.createElement("tr");
var tdRef = document.createElement("td");
tdRef.textContent = r.referrer;
var tdCount = document.createElement("td");
tdCount.className = "num";
tdCount.textContent = fmt(r.count);
tr.appendChild(tdRef);
tr.appendChild(tdCount);
tbody.appendChild(tr);
});
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbody.replaceChildren() + createElement + textContent is the right shape. replaceChildren() is 2020+ evergreen-browser only; fine for a maintainer-facing dashboard but if this page is ever linked to from somewhere user-facing, be aware older Edge/Safari won't render the table. (Not changing; just flagging.)


Generated by Claude Code

}
function updateCharts() {
updateChart("starsChart", "line", {
Expand Down
2 changes: 1 addition & 1 deletion src/PlanViewer.Core/Services/WindowsCredentialService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
{
try
{
CredentialManager.WriteCredential(

Check warning on line 14 in src/PlanViewer.Core/Services/WindowsCredentialService.cs

View workflow job for this annotation

GitHub Actions / build-and-test

This call site is reachable on all platforms. 'CredentialManager.WriteCredential(string, string, string, string?, CredentialPersistence)' is only supported on: 'windows' 5.1.2600 and later. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)
applicationName: Prefix + serverId,
userName: username,
secret: password,
comment: "planview credential",
persistence: CredentialPersistence.LocalMachine);
persistence: CredentialPersistence.Enterprise);
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enterprise is the correct scope for per-user DPAPI isolation. One call-out for the PR description: "roams with the domain profile" is slightly overstated — Enterprise means "roams via domain creds mgmt (credential roaming policy)" which is opt-in at the AD level. On non-domain / domain-without-roaming machines it's effectively identical to LocalComputer in terms of where the blob lives, but still DPAPI-scoped per user. End result for the user is correct either way; just noting in case someone asks.

Also: pre-existing LocalMachine creds will still decrypt (DPAPI machine key still present), so silent migration on next save is fine. No action needed.


Generated by Claude Code

return true;
}
catch { return false; }
Expand All @@ -24,7 +24,7 @@

public (string Username, string Password)? GetCredential(string serverId)
{
var cred = CredentialManager.ReadCredential(Prefix + serverId);

Check warning on line 27 in src/PlanViewer.Core/Services/WindowsCredentialService.cs

View workflow job for this annotation

GitHub Actions / build-and-test

This call site is reachable on all platforms. 'CredentialManager.ReadCredential(string)' is only supported on: 'windows' 5.1.2600 and later. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)
if (cred == null) return null;
return (cred.UserName ?? "", cred.Password ?? "");
}
Expand All @@ -33,7 +33,7 @@
{
try
{
CredentialManager.DeleteCredential(Prefix + serverId);

Check warning on line 36 in src/PlanViewer.Core/Services/WindowsCredentialService.cs

View workflow job for this annotation

GitHub Actions / build-and-test

This call site is reachable on all platforms. 'CredentialManager.DeleteCredential(string)' is only supported on: 'windows' 5.1.2600 and later. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)

Check warning on line 36 in src/PlanViewer.Core/Services/WindowsCredentialService.cs

View workflow job for this annotation

GitHub Actions / build-and-test

This call site is reachable on all platforms. 'CredentialManager.DeleteCredential(string)' is only supported on: 'windows' 5.1.2600 and later. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)
return true;
}
catch { return false; }
Expand All @@ -41,7 +41,7 @@

public bool CredentialExists(string serverId)
{
return CredentialManager.ReadCredential(Prefix + serverId) != null;

Check warning on line 44 in src/PlanViewer.Core/Services/WindowsCredentialService.cs

View workflow job for this annotation

GitHub Actions / build-and-test

This call site is reachable on all platforms. 'CredentialManager.ReadCredential(string)' is only supported on: 'windows' 5.1.2600 and later. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)
}

public bool UpdateCredential(string serverId, string username, string password)
Expand All @@ -54,7 +54,7 @@
/// </summary>
public IReadOnlyList<(string ServerName, string Username)> ListAll()
{
return CredentialManager.EnumerateCredentials()

Check warning on line 57 in src/PlanViewer.Core/Services/WindowsCredentialService.cs

View workflow job for this annotation

GitHub Actions / build-and-test

This call site is reachable on all platforms. 'CredentialManager.EnumerateCredentials()' is only supported on: 'windows' 5.1.2600 and later. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)
.Where(c => c.ApplicationName.StartsWith(Prefix, StringComparison.Ordinal))
.Select(c => (c.ApplicationName[Prefix.Length..], c.UserName ?? ""))
.ToList();
Expand Down
Loading