fix: XSS, DPAPI scope, rate-limiter eviction (bug-bash batch 1)#249
fix: XSS, DPAPI scope, rate-limiter eviction (bug-bash batch 1)#249erikdarlingdata merged 1 commit intodevfrom
Conversation
- PlanShare dashboard: rebuild referrer table with DOM + textContent instead of innerHTML, so legacy rows containing hostile strings can't execute script. - PlanShare /api/event: drop the referrer entirely when Uri.TryCreate rejects it, instead of persisting the raw client-supplied string. - WindowsCredentialService: save credentials with Enterprise scope (current-user, DPAPI-encrypted) instead of LocalMachine (readable by every user on the box). - PlanShare RateLimiter: add Sweep() that prunes and TryRemoves empty keys, invoked from CleanupService's existing hourly tick so the dictionary no longer grows unbounded across unique IPs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
erikdarlingdata
left a comment
There was a problem hiding this comment.
Review summary — PR #249 (fix: XSS, DPAPI scope, rate-limiter eviction)
What the PR does
Three independent, localized fixes: (1) server drops non-URL referrers to null and dashboard rebuilds the referrer table via createElement/textContent (stored-XSS defense in depth); (2) WindowsCredentialService persistence flipped LocalMachine → Enterprise so DPAPI scope is per-user; (3) RateLimiter.Sweep() added and called from CleanupService's existing hourly tick to evict empty-list keys from the per-IP dictionary.
What's good
- Base branch is
dev. ✓ - XSS fix is layered (server-side normalization + DOM API rendering), not just one side.
RateLimitersrecord + DI registration is cleaner than reaching into captured locals from the background service.- PR body is detailed, lists the drop/false-positive findings, and describes the manual verification for each change.
- No Avalonia/XAML/PlanAnalyzer surface touched — the "keep PlanAnalyzer rules synced with PerformanceMonitor" concern does not apply here.
What needs attention
Program.cs:484— minor race betweenSweep()andIsAllowed()where a request timestamp can be dropped if the key is evicted mid-call. Bounded impact, worth knowing about. Inline comment with details.Program.cs:169-174— relative-URL referrers now drop tonullinstead of being stored. Almost certainly desired, but worth a sanity check against real client payloads.WindowsCredentialService.cs:19—Enterpriseis correct; minor nit on the PR description's "roams with the domain profile" wording (it's opt-in via AD credential roaming policy). ExistingLocalMachinecreds still decrypt, so silent migration is fine.dashboard.html:277—replaceChildren()is modern-only; fine for a maintainer dashboard, flag if this page ever gets linked from user-facing surfaces.- Tests:
src/PlanViewer.Core/Services/WindowsCredentialService.cschanged but nothing was added undertests/PlanViewer.Core.Tests/. The DPAPI change isn't easily unit-testable (Win32-only, touches real credential store) and the PR has a documented manual round-trip, so this is a reasonable skip. However,RateLimiter.Sweep()is pure in-process logic and is a good candidate for a unit test — the PR body describes exactly the 10k-key scenario you'd want captured as a regression test.
No changes requested — maintainer's call.
Generated by Claude Code
| } | ||
| } | ||
| return evicted; | ||
| } |
There was a problem hiding this comment.
Minor race between Sweep() and IsAllowed():
- Thread A calls
IsAllowed(key)—GetOrAddreturns existing listL(all entries expired, but not yet pruned). - Thread B (Sweep) acquires lock on
Lfirst, prunes to empty,TryRemoves the key, releases lock. - Thread A acquires lock on the now-detached
L, addsnow, returnstrue. - Thread A's next request:
GetOrAddcreates a fresh empty listL2, 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
| referrer = Uri.TryCreate(referrer, UriKind.Absolute, out var refUri) | ||
| ? refUri.Host | ||
| : null; | ||
| } |
There was a problem hiding this comment.
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
| secret: password, | ||
| comment: "planview credential", | ||
| persistence: CredentialPersistence.LocalMachine); | ||
| persistence: CredentialPersistence.Enterprise); |
There was a problem hiding this comment.
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
| tr.appendChild(tdRef); | ||
| tr.appendChild(tdCount); | ||
| tbody.appendChild(tr); | ||
| }); |
There was a problem hiding this comment.
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
Summary
Three security / reliability fixes surfaced by a max-effort bug-bash review. Each is small, localized, verified end-to-end, and independent.
1. Stored XSS on the PlanShare dashboard
server/PlanShare/dashboard.html:282renderedr.referrerviainnerHTMLstring-concat.server/PlanShare/Program.cs:165only sanitized referrers whenUri.TryCreatesucceeded — non-URL strings flowed through raw.Fix (defense in depth):
referrer = nullwhenTryCreaterejects the input, so raw attacker strings never reach storage.document.createElement+textContent, so even any legacy row containing a hostile string can't execute.2. DPAPI scope leaks SQL creds across Windows users
WindowsCredentialService.cs:19was saving withCredentialPersistence.LocalMachine, which makes the credential decryptable by any account on the same box (shared dev VMs, jump hosts, Citrix).Fix: one-word change to
CredentialPersistence.Enterprise(per-user, DPAPI-scoped, roams with the domain profile). Pre-existingLocalMachine-scoped credentials remain readable; next re-save migrates them.3. PlanShare rate limiter grows unbounded
server/PlanShare/Program.cs— theConcurrentDictionary<string, List<DateTime>>keyed by IP pruned per-IP timestamp lists but never evicted the keys. Any unique-IP traffic over time grew memory forever.Fix: added
RateLimiter.Sweep()which prunes each list andTryRemoves keys whose list is empty. Both limiters are DI-registered via aRateLimiterscontainer;CleanupServiceinvokesSweep()on its existing hourly tick and logs evictions.Test plan
dotnet buildonPlanViewer.CoreandPlanShare: 0 errors, no new warnings.<img src=x onerror=...>and"><script>...</script>plus a legit URL to/api/event, thenGET /api/stats. Onlygood.example.comlanded intop_referrers; both XSS payloads dropped as expected.SaveCredential→GetCredential→DeleteCredentialwithEnterprisescope and a password containing!,$,"— byte-for-byte match.Sweep()evicted 0 (expected). Waited 1.5s, staleSweep()evicted 10 000, 0 remaining. Post-sweep insert still works.Dropped (false positives)
Bug-bash agents flagged several that didn't survive verification; not addressing these:
BenefitScorer.cs:584"ms² unit bug" — dimensionallyms·ms/ms = ms, math is correct.BenefitScorer.cs:367-378Key-Lookup "double counts parent NL" —CostPercentis own-cost, not subtree (seeShowPlanParser.cs:1787).PlanAnalyzer.cs:863Rule 32 "fires on ActualRows=0" — that IS the scan-caused-by-bad-CE case the rule targets.QueryStoreGridControl:1231"duplicate Click handlers accumulate" — lines 1213-1219 explicitly-=before+=.🤖 Generated with Claude Code