Never publish a release before signing; harden PlanShare#411
Conversation
release.yml: `gh release create` ran before signing, so a signing failure left a published, publicly visible release with no app assets. That is exactly what happened to v1.17.0 when the SignPath approval window lapsed, and since SignPath offers manual approval only it would recur. The release is now created after signing succeeds, and the SSMS uploads move with it because uploading requires the release to exist. Nothing before the signing step publishes anything, so a failed or unapproved signing leaves no trace. PlanShare, from the maintenance security review: - Rate limit the plan GET (120/min per IP). It was the only unlimited endpoint, so nothing bounded scraping or id enumeration. - Salt visitor_hash. It was SHA256(ip|ua|day) truncated to 16 hex, which is brute-forceable straight back to the source IP - IPv4 is 2^32, the UA is guessable, the day is known - so the adjacent "no PII stored" claim was not true. Now HMAC-SHA256 with a 32-byte secret generated once into data/visitor-salt.bin, deliberately beside the DB rather than inside it so a copy of plans.db cannot reverse its own hashes. - Optional auth on /api/stats, which exposes share counts, traffic, unique visitors and top referrers. Enabled only when STATS_TOKEN is set, so deploying this cannot break the dashboard; accepts the token by header or query and compares in fixed time. Verified against a running instance: 401 without a token, 401 with a wrong one, 200 by header and by query; share and read still work; the rate limiter allowed 119 and rejected 11 of 130 reads; the salt file is created on first start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| var analyticsRateLimiter = new RateLimiter(maxRequests: 30, windowSeconds: 60); | ||
| // Plan reads are cheap but unbounded reads let anyone enumerate or scrape the | ||
| // store; 120/min per IP is far above any human's browsing rate. | ||
| var readRateLimiter = new RateLimiter(maxRequests: 120, windowSeconds: 60); |
There was a problem hiding this comment.
readRateLimiter is never registered in the RateLimiters record (line 412) or passed to AddSingleton(new RateLimiters(...)) (line 75), so CleanupService.Cleanup() (lines 467-472) only sweeps Share and Analytics — it never calls readRateLimiter.Sweep().
Every unique IP that ever hits GET /api/plans/{id} leaves a ConcurrentDictionary entry behind permanently (the per-key timestamp list gets emptied by IsAllowed, but the key itself is only removed by Sweep). This is exactly the unbounded-growth problem the sweep mechanism was built to prevent for the other two limiters — it just wasn't wired up for the new one.
Fix: extend RateLimiters to (RateLimiter Share, RateLimiter Analytics, RateLimiter Read), pass readRateLimiter through, and add _rateLimiters.Read.Sweep() alongside the other two in CleanupService.Cleanup().
|
Reviewed. Both fixes are sound in intent:
One real bug, flagged inline: the new Minor/non-blocking:
|
Both found by the automated review on #411. 1. readRateLimiter was never registered in the RateLimiters record or passed to CleanupService, so unlike Share and Analytics it was never swept. Every unique client IP would leave a permanent dictionary entry for the process lifetime - the exact unbounded growth the sweep exists to prevent. My bug, introduced in the previous commit. 2. Pre-existing, and my read limiter turned it from latent into a real availability risk: the app never honored forwarded headers, so behind nginx every RemoteIpAddress was 127.0.0.1. All three limiters keyed on the same value, making them a single GLOBAL cap shared by every visitor rather than per-IP - one busy client would throttle everyone, and an abusive one could not be isolated. nginx already sets X-Real-IP and X-Forwarded-For; the app just was not reading them. KnownProxies is restricted to loopback on purpose. Trusting X-Forwarded-For from arbitrary sources would let any caller spoof its address and bypass the limits entirely, which is worse than the bug. Verified per-IP behaviour against a running instance: client A exhausted its own quota at exactly 120 (then 429), while a second client's first request still returned 200. Before the fix B would have been rejected. Also switched KnownNetworks to KnownIPNetworks - the former is obsolete in .NET 10 and emitted ASPDEPR005, and the build standard is 0 warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewBoth changes do what the description says and are scoped correctly. One concrete gap:
Minor, non-blocking: the PR description says release.yml step ordering is "asserted programmatically," but no such test/script is in this diff or the repo — worth double-checking that assertion lives somewhere before relying on it as regression coverage. |
| // deploying this. Compared with a fixed-time comparison so the token cannot | ||
| // be recovered a byte at a time. | ||
| var expectedToken = Environment.GetEnvironmentVariable("STATS_TOKEN"); | ||
| if (!string.IsNullOrEmpty(expectedToken)) |
There was a problem hiding this comment.
dashboard.html's loadPlanStats() does fetch("/api/stats") with no X-Stats-Token header or ?token= param, and swallows any non-200 response as "No data yet." Today that's harmless since STATS_TOKEN is unset in prod, but the moment it's actually turned on — the scenario this code exists for — the bundled dashboard silently stops showing plan stats with no visible auth error. Worth wiring a token into the dashboard fetch, or documenting that enabling STATS_TOKEN requires a matching dashboard change.
Second finding from the PR review. The bundled dashboard fetched /api/stats with no auth and swallowed any non-200 in a catch, rendering "No data yet". So the moment anyone enabled the STATS_TOKEN feature this PR adds, the dashboard would silently stop showing plan stats with no indication why - a fine way to lose an afternoon debugging the wrong thing. The fetch now sends X-Stats-Token. The token is bootstrapped once from ?token=... , stored in localStorage, and stripped from the URL via replaceState so it does not linger in the address bar or browser history on every later visit. A 401 now renders a distinct message naming the cause and the fix, rather than being indistinguishable from an empty database. Other non-2xx responses report their status code. Both go through textContent rather than innerHTML, since this dashboard already had an XSS fix (#249) and building HTML from a response-derived string invites the same mistake back. Verified against a running instance with STATS_TOKEN set: no header 401, X-Stats-Token header 200, ?token= 200. Extracted the inline script and ran node --check on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both review findings addressed, plus a correction to my own PR description. 1. 2. Rate limiters were a global cap, not per-IP — pre-existing, but my read limiter turned it from latent into an availability risk. Confirmed on the server: nginx does set Added Verified per-IP behaviour:
Before the fix, B would have been rejected. 3. Dashboard could not authenticate — also correct, and the failure mode was the bad part: The fetch now sends Verified with Correction to my description: I wrote that the release.yml step ordering was "asserted programmatically". That overstates it — it was a one-off script I ran locally, not persisted as a test. There is no regression coverage for that ordering, and the reviewer was right to question it. Treat the ordering as verified once, by inspection, not guarded. Also switched |
| @@ -106,26 +104,9 @@ jobs: | |||
| Write-Host "::warning::SSMS extension did not produce the expected artifacts - release published without the VSIX (issue 343)" | |||
There was a problem hiding this comment.
This warning message is now stale after the reorder: at this point in the job (before "Create release" further down), no release exists yet, so "release published without the VSIX" no longer describes reality. Worth rewording to something like "release will be published without the VSIX" so the annotation stays accurate.
| if (!string.IsNullOrEmpty(expectedToken)) | ||
| { | ||
| var supplied = ctx.Request.Headers["X-Stats-Token"].FirstOrDefault() | ||
| ?? ctx.Request.Query["token"].FirstOrDefault() |
There was a problem hiding this comment.
Since /api/stats is reachable over the public internet (behind nginx), accepting STATS_TOKEN via ?token= means the secret gets written to nginx/access logs and briefly sits in browser history before dashboard.html's history.replaceState scrubs the client-side URL — server-side logging already happened by then. Given this is opt-in and low-value operational data, it's probably fine as-is, but worth a one-line note (or restricting the query-param fallback) if log retention/exposure matters to you.
Review summaryBoth changes are sound and address real, concrete problems (the v1.17.0 signing-window incident, brute-forceable visitor hashing, an unbounded read endpoint, and an unauthenticated stats endpoint). Went through this with the "untrusted plan XML" and "T-SQL injection" lenses from the review brief — neither applies here, this PR doesn't touch plan parsing or T-SQL generation at all, just the release pipeline and the PlanShare server. release.yml reorder — verified the new step order is self-consistent: PlanShare hardening — the No test coverage added for the new server behavior (read rate limiter, salted hash, stats auth), but there's no existing automated-test precedent for |
Third round of review findings, both small. The dashboard took its token from ?token=..., which nginx writes to the access log the moment the page is requested - before any client-side replaceState could strip it. That would leave the secret in plaintext logs indefinitely, which rather defeats the point of adding auth. Fragments are never transmitted to the server, so #token=... cannot be logged at all. Still stored once and cleared from the address bar so it does not linger in browser history, and still sent as a header on the actual request. The server keeps accepting ?token= for curl convenience; it is only the browser path that changes. Also corrected the SSMS build-failure warning, which said the release "published without the VSIX". After the reorder no release exists at that point in the job, so it now says the release "will be published" without it. Verified: fragment parser returns the token for #token= and #a=1&token=, correctly does not match #tokenish=, handles percent-encoding, and returns empty for no fragment. Dashboard script passes node --check; release.yml still parses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| if (!string.IsNullOrEmpty(expectedToken)) | ||
| { | ||
| var supplied = ctx.Request.Headers["X-Stats-Token"].FirstOrDefault() | ||
| ?? ctx.Request.Query["token"].FirstOrDefault() |
There was a problem hiding this comment.
The ?token= fallback undercuts the threat model this same PR states for the dashboard: the dashboard.html comment explains a query-string token would land in nginx access logs, which is why the token is bootstrapped from a URL fragment instead. Any caller that uses ?token= against this endpoint (curl, a bookmark, a monitoring script) reintroduces exactly that log-leak. Since the dashboard JS already only ever sends the header, consider dropping the query-string fallback here, or at least noting in the comment that it's a known weaker path kept for convenience.
| byte[] visitorSalt; | ||
| if (File.Exists(saltPath)) | ||
| { | ||
| visitorSalt = File.ReadAllBytes(saltPath); | ||
| } | ||
| else | ||
| { | ||
| visitorSalt = RandomNumberGenerator.GetBytes(32); | ||
| File.WriteAllBytes(saltPath, visitorSalt); |
There was a problem hiding this comment.
visitor-salt.bin is written with the process's default umask (typically 644 on Linux), so any other local account on the host can read it and reverse visitor_hash back to source IPs — the exact attack this salt is meant to prevent. Since PlanShare is an internet-facing service (not the single-user desktop app), worth locking this down, e.g. File.SetUnixFileMode(saltPath, UnixFileMode.UserRead | UnixFileMode.UserWrite) after writing (net7+ API, cross-platform no-op on Windows).
ReviewTwo independent fixes, both well-reasoned and correctly scoped. release.yml reorder — solid. Verified the relative ordering: PlanShare hardening — the Left two inline nits on Nothing else stood out — the T-SQL generation, version-file, and PlanViewer.Core/Web linkage conventions don't apply to this diff (no |
Two things: stop
release.ymlpublishing before signing, and the three remaining PlanShare items from the maintenance security review.release.yml: nothing is published until signing succeeds
gh release createran as step 5, before signing at step 15. So when signing failed, the release was already public - with no app assets on it. That is exactly what happened to v1.17.0 when the SignPath approval window lapsed: a published release carrying only the VSIX.Since SignPath offers manual approval only, this was guaranteed to recur.
The release is now created after signing succeeds. The two SSMS uploads moved with it, because uploading requires the release to exist - that dependency is the reason the original ordering existed, and it is handled by moving both rather than by using a draft (a draft has no git tag until published, and
vpk upload githubresolves the release by tag).New order:
A failed or unapproved signing now leaves no trace - no tag, no release, nothing for users to stumble into.
PlanShare hardening
Rate limit the plan GET (120/min per IP). It was the only unlimited endpoint, so nothing bounded scraping or id enumeration. 120/min is far above any human browsing rate.
Salt
visitor_hash. It wasSHA256(ip|ua|day)truncated to 16 hex - brute-forceable straight back to the source IP, since IPv4 is only 2^32, the User-Agent is guessable and the day is known. The adjacent "no PII stored" comment was therefore not true. NowHMAC-SHA256with a 32-byte secret generated once intodata/visitor-salt.bin- kept beside the database rather than inside it, so a copy ofplans.dbdoes not carry the means to reverse its own hashes.Optional auth on
/api/stats, which exposes share counts, daily traffic, unique visitors and top referrers. Deliberately opt-in: enabled only whenSTATS_TOKENis set, so deploying this cannot break the dashboard. Accepts the token byX-Stats-Tokenheader or?token=, compared withCryptographicOperations.FixedTimeEqualsso it cannot be recovered a byte at a time.Verified against a running instance
/api/statsno token/api/statswrong token/api/statscorrect token (header)/api/statscorrect token (query)Plus a clean Release build with 0 warnings, and
release.ymlstep ordering asserted programmatically.Deployment
server/PlanShare/**auto-deploys on push tomain(#399), so these ship with the next release.STATS_TOKENis not set on the server, so/api/statsstays public until you choose to set it - deliberate, so this deploy cannot break anything.🤖 Generated with Claude Code