Skip to content

Harden PlanShare: storage limit, daily upload budget, one client key - #603

Merged
erikdarlingdata merged 4 commits into
devfrom
fix/planshare-limits
Sep 28, 2026
Merged

erikdarlingdata merged 4 commits into
devfrom
fix/planshare-limits

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

This PR hardens the PlanShare server (server/PlanShare) and the web app that uses it. It has no issue number.

The server now stops taking shares when the database is full. It also limits how much plan data one client can store per day. All per-client limits use one client key. Bad input gets a 400 instead of a 500. The delete token moves from the URL to a header. The web app shows the server's reason when a share fails.

Storage limit

  • Before the server stores a share, it measures the database as (PRAGMA page_count - PRAGMA freelist_count) x PRAGMA page_size.
  • At 10 GB (10 x 1024^3 bytes) or more, /api/share answers 507 with {"error": "Plan sharing is full right now. Please try again later."}.
  • /api/event makes the same check. When the store is full, it does not store the event and still answers 200, so analytics never show an error.

Daily upload budget

  • Each client key can store 100 MB (100 x 1024 x 1024 bytes) of plan data per UTC day. The server counts the UTF-8 size of the request body.
  • Over the budget, /api/share answers 429 with {"error": "Daily sharing limit reached for your network. Please try again tomorrow."}. A refused share adds nothing to the count.
  • The budget is in memory like the rate limiters. CleanupService prunes it every hour, the same way it prunes the limiters. A restart resets it.

Client key

  • The share, analytics, read and budget limits now use one client key.
  • An IPv4 address is its own key. An IPv4-mapped IPv6 address gets its IPv4 address as the key. Any other IPv6 address gets its /64 prefix.
  • The address is resolved as before, through the forwarded headers middleware that trusts only loopback.
  • The analytics visitor hash still uses the full address, so unique visitor counts do not change.

Order of checks in /api/share

  1. The per-minute rate limit (429).
  2. The body: empty, not JSON, root not an object, or ttl_days not a number (400).
  3. The storage limit (507).
  4. The daily budget (429).
  5. The insert.

A budget charge is not returned if the insert then fails. That is rare, and it costs a client at most one upload of its daily allowance.

Bad input

  • /api/share and /api/event answer 400 instead of 500 when the JSON root is not an object or a field has the wrong type. The fields are ttl_days for a share, and path and referrer for an event. A JSON null counts as not sent.
  • /api/event answers 400 for a path longer than 512 characters.
  • Every refusal body is {"error": "..."}. That includes the existing 400 answers and the per-minute 429 on /api/share, so the web app can show the text. The 429 on the other endpoints still has no body.

Delete token

  • DELETE /api/plans/{id} accepts the token in the X-Delete-Token header. It still accepts ?token= for older clients. If both are present, the header wins.
  • The web app now sends the header. Before, it put the token in the URL, and the proxy writes URLs to its access log.
  • CORS is unchanged. A test checks that a preflight for the new header passes.

Web app

  • When a share fails, the web app shows the error text from the server (507, 429 and 400). Before, it showed "Share failed: server returned N". That message is still the fallback when the reply has no error text, for example an HTML page from the proxy.
  • The share dialog now lists what is uploaded. The list has the plan file name, the query text, the operator details and warnings, and the compiled and runtime parameter values. It also has the missing index suggestions with database, schema and table names, and the full text report. I checked each item against AnalysisResult and the text report. The payload is unchanged.

Dashboard

  • server/PlanShare/dashboard.html has a local array named history, which hides window.history. Then history.replaceState(...) threw after the token was saved, and #token= stayed in the address bar. The call is now window.history.replaceState(...). I reproduced the shadowing with a short Node script.

Tests and CI

  • tests/PlanViewer.Core.Tests now references server/PlanShare/PlanShare.csproj, so the solution build and the test run cover the server. The server has InternalsVisibleTo for the test project.
  • The code filter in .github/workflows/ci.yml now includes server/PlanShare/**. Without it, a PR that changes only the server skips the build and the tests.
  • New optional settings: PlanShare:DataDir, PlanShare:MaxDatabaseBytes and PlanShare:DailyUploadBytes. Without them the server uses data/ next to the binary, the 10 GB limit and the 100 MB budget. The endpoint tests use them to run a server with a temp database and small limits.
  • The solution does not list the server, so a solution build compiles it in Debug. The tests are not affected.
  • .github/workflows/deploy-planshare.yml is not changed.

Which component(s) does this affect?

  • Desktop App (PlanViewer.App)
  • Core Library (PlanViewer.Core)
  • CLI Tool (PlanViewer.Cli)
  • SSMS Extension (PlanViewer.Ssms)
  • Tests
  • Documentation

The template has no box for the PlanShare server or the web app. This PR changes both.

How was this tested?

Platform: Windows. This change has no plan files.

  • New unit tests (20) cover the client key, the upload budget and the storage check. The budget tests inject the clock. The storage tests use a real temp SQLite file with small limits, including free pages after a delete.
  • New endpoint tests (44 cases) run the real server pipeline through WebApplicationFactory with a temp database. They check the 400 cases, the 507 answer, and both 429 answers. They check that /api/event answers 200 when the store is full, and that a path of 512 characters passes and one of 513 fails. They also check delete by header, delete by ?token=, and the CORS preflight for X-Delete-Token. The budget tests check one key for an IPv4 address and its mapped form, and one key for two addresses in one /64.
  • New web service tests (12) run PlanShareService against a stub HTTP handler. They check the error text for 507, 429 and 400, and the fallback message. They also check that a delete request has the header and no token in the URL. Nothing is sent to a real server.
  • I also started the built server on loopback ports 5187 and 5188 with its database in a temp folder and called it with curl. Every case returned the expected status and error text. Both servers are stopped. Nothing was sent to stats.erikdarling.com.
  • I ran the publish command from the deploy workflow (dotnet publish server/PlanShare/PlanShare.csproj -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true) on this branch and on b34b1fb. Both outputs have the same files, including PlanShare and libe_sqlite3.so. The binary is about 5 KB larger.
  • dotnet build PlanViewer.sln -c Release --no-incremental gives 0 warnings and 0 errors. A Debug build gives the same.
  • The full dotnet test run (Release) had 1136 tests: 1134 passed, 2 skipped, 0 failed.

Not done

All eight items are done. Three things I did not check:

  • I did not run the tests on Linux. The CI run on Ubuntu is the first Linux run. The new tests use no Windows-only calls.
  • The nginx config is not in this repo, so I did not check that it passes the X-Delete-Token header.
  • The server and the web app deploy from separate workflows. If the web app goes live first, a delete from a new page fails with "Failed to delete shared plan." until the server deploy finishes.

Checklist

  • I have read the contributing guide
  • My code builds with zero warnings (dotnet build -c Debug)
  • All tests pass (dotnet test)
  • I have not introduced any hardcoded credentials or server names

🤖 Generated with Claude Code

https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza

erikdarlingdata and others added 4 commits September 28, 2026 17:00
Sharing is refused with 507 once the database uses 10 GB, counted as
(page_count - freelist_count) * page_size. /api/event skips the insert
in that state and still answers 200, so analytics never show an error.

Each client key can store 100 MB of plan data per UTC day. The budget
is in memory like the rate limiters and CleanupService sweeps it.

Every per-client limit (share, analytics, read, budget) now uses one
key: an IPv4 address, an IPv4-mapped IPv6 address as its IPv4 address,
and any other IPv6 address as its /64. The visitor hash still uses the
full address.

/api/share and /api/event answer 400 instead of 500 for a JSON root that
is not an object and for fields of the wrong type. /api/event refuses a
path over 512 characters. DELETE /api/plans/{id} takes the token in the
X-Delete-Token header and still accepts ?token=. Refusals carry an
"error" text in a JSON body.

The new classes are internal with InternalsVisibleTo, and the test
project now references server/PlanShare so CI builds and tests it. The
ci.yml path filter includes server/PlanShare.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
The web client reads the "error" text from a failed share (507, 429,
400) and shows it. A reply without that text, such as an HTML page from
the proxy, keeps the generic message with the status code.

DeleteAsync sends the token in X-Delete-Token instead of ?token=, which
the proxy writes to its access log.

The share dialog lists what the upload holds: the plan file name, the
query text, operator details and warnings, compiled and runtime
parameter values, missing index suggestions with database, schema and
table names, and the full text report. The payload is unchanged.

dashboard.html called history.replaceState, but a local array named
history shadows window.history, so the call threw and left #token= in
the address bar. It now calls window.history.replaceState.

Tests: the endpoints run through WebApplicationFactory with the database
in a temp folder and the limits passed as PlanShare:* settings, and the
web share service runs against a stub HTTP handler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 28, 2026 21:20
@claude

claude Bot commented Sep 28, 2026

Copy link
Copy Markdown

Reviewed the diff. I found no blocking issues.

  • Input handling: the ValueKind checks on /api/share and /api/event close the 500s. The tests cover the wrong-type, non-object and lone-surrogate cases.
  • SQL: the PRAGMA {name} in StorageCheck and the SELECT COUNT(*) FROM {table} in the test helper interpolate only hard-coded names. No untrusted input reaches SQL.
  • Repo conventions: I saw no new NoWarn. No version bump is involved. The linked PlanShareService.cs is compiled into the tests, and that is fine.

Two low-severity notes:

  1. DELETE /api/plans/{id}?token= is still accepted. Clients built before this change will keep leaking tokens into the nginx access log until the query form is dropped. Consider a removal date.
  2. The storage check runs before the insert and is not atomic with it. Concurrent uploads can overshoot the 10 GB cap by a few requests' worth. That is fine at a 40 GB disk, but the cap is soft.

Test coverage for the changed behavior looks thorough.

@erikdarlingdata
erikdarlingdata merged commit f9e5688 into dev Sep 28, 2026
4 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/planshare-limits branch September 28, 2026 21:38
@erikdarlingdata erikdarlingdata mentioned this pull request Sep 29, 2026
2 of 8 tasks
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