Skip to content

fix: XSS, DPAPI scope, rate-limiter eviction (bug-bash batch 1)#249

Merged
erikdarlingdata merged 1 commit intodevfrom
fix/bug-bash-xss-dpapi-ratelimit
Apr 21, 2026
Merged

fix: XSS, DPAPI scope, rate-limiter eviction (bug-bash batch 1)#249
erikdarlingdata merged 1 commit intodevfrom
fix/bug-bash-xss-dpapi-ratelimit

Conversation

@erikdarlingdata
Copy link
Copy Markdown
Owner

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:282 rendered r.referrer via innerHTML string-concat. server/PlanShare/Program.cs:165 only sanitized referrers when Uri.TryCreate succeeded — non-URL strings flowed through raw.

Fix (defense in depth):

  • Server now sets referrer = null when TryCreate rejects the input, so raw attacker strings never reach storage.
  • Dashboard rebuilds the referrer table with 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:19 was saving with CredentialPersistence.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-existing LocalMachine-scoped credentials remain readable; next re-save migrates them.

3. PlanShare rate limiter grows unbounded

server/PlanShare/Program.cs — the ConcurrentDictionary<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 and TryRemoves keys whose list is empty. Both limiters are DI-registered via a RateLimiters container; CleanupService invokes Sweep() on its existing hourly tick and logs evictions.

Test plan

  • dotnet build on PlanViewer.Core and PlanShare: 0 errors, no new warnings.
  • XSS end-to-end: ran PlanShare locally, POSTed <img src=x onerror=...> and "><script>...</script> plus a legit URL to /api/event, then GET /api/stats. Only good.example.com landed in top_referrers; both XSS payloads dropped as expected.
  • DPAPI round-trip: SaveCredentialGetCredentialDeleteCredential with Enterprise scope and a password containing !, $, " — byte-for-byte match.
  • Rate limiter: 10 000 unique keys, window=1s. Fresh Sweep() evicted 0 (expected). Waited 1.5s, stale Sweep() 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" — dimensionally ms·ms/ms = ms, math is correct.
  • BenefitScorer.cs:367-378 Key-Lookup "double counts parent NL" — CostPercent is own-cost, not subtree (see ShowPlanParser.cs:1787).
  • PlanAnalyzer.cs:863 Rule 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 +=.
  • A handful of others (PlanShare DELETE "token leak", HKCU registry "injection", installer symlink, etc.) — detailed in the review thread.

🤖 Generated with Claude Code

- 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 erikdarlingdata merged commit 7fbdf3e into dev Apr 21, 2026
2 checks passed
@erikdarlingdata erikdarlingdata deleted the fix/bug-bash-xss-dpapi-ratelimit branch April 21, 2026 21:09
Copy link
Copy Markdown
Owner Author

@erikdarlingdata erikdarlingdata left a comment

Choose a reason for hiding this comment

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

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 LocalMachineEnterprise 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.
  • RateLimiters record + 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 between Sweep() and IsAllowed() 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 to null instead of being stored. Almost certainly desired, but worth a sanity check against real client payloads.
  • WindowsCredentialService.cs:19Enterprise is correct; minor nit on the PR description's "roams with the domain profile" wording (it's opt-in via AD credential roaming policy). Existing LocalMachine creds still decrypt, so silent migration is fine.
  • dashboard.html:277replaceChildren() 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.cs changed but nothing was added under tests/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;
}
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

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

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant