Conversation
sendPage serves every HTML page in the setup and OAuth flow, and two of them are reached by a URL carrying a secret: the registration form behind ?token=, and the result page rendering the four one-time values GitHub will not show again. Rendering those values is deliberate and the code says why — GitHub shows them once and this app keeps no copy, so there is nowhere else for them to go. What was not deliberate is that the response carried no cache directive and no referrer policy. no-store because a secret-bearing response has no business in any cache, and applying it to every page here is cheaper and less error-prone than deciding per route which ones are sensitive. no-referrer because the setup token travels in the query string. Without it, following the link to the app's GitHub page from the result screen sends that URL — token included — to github.com in a Referer header. Not fixed: the token is still a query parameter. Moving it to a POST body or a header changes the operator's entry flow and wants its own decision; this closes the most direct way it escapes in the meantime. Adds test/page-headers.test.ts, which starts the server on an ephemeral port and asserts both headers on a real response, plus that the route still 404s without the token — so the test would fail if the fix were made by loosening the guard. Verified: 271 tests pass (16 files), tsc clean. Traced from T98 in the estate threat model, work item WI-8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D5xdKhPXJT4HMWwUyENiu1
Contributor
Author
|
Production-readiness review completed with no defect found. Secret-bearing setup/result responses consistently set |
Contributor
Author
|
Superseded by #9. Reopened with the finding details moved to the private security repository — this repository is public, and a detailed description of an open gap is itself an exposure. Tracked as T98. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Threat: T98 (
Morelitea/security, Boundary 8). Severity Medium. NIST IA-5, AU-9, SI-11.Work item: WI-8.
Why
sendPageserves every HTML page in the setup and OAuth flow. Two are reached by a URL carrying a secret:?token=Rendering those values is deliberate, and the code says why — GitHub shows them once, this app keeps no copy, so there is nowhere else for them to go. I am not changing that; it is the honest shape of a one-time secret.
What was not deliberate is that the response carried no cache directive and no referrer policy.
Change
Cache-Control: no-store— a secret-bearing response has no business in any cache. Applied to every pagesendPageserves rather than picked per route, which is cheaper and less error-prone than maintaining a list of which pages are sensitive.Referrer-Policy: no-referrer— the setup token is in the query string of/register?token=…, and that page contains an auto-submitting cross-origin POST to github.com (src/github/registration.ts:57-88):Without a referrer policy that POST sends
Referer: https://<app>/register?token=<SECRET>to github.com on every registration, automatically, with no click required.no-referrerstops it.An earlier version of this section blamed the result screen instead. That was wrong and it understated the problem:
REGISTER_DONE_PATHreads onlystateandcode(src/server.ts:388-404) — the setup token is not in that URL at all, and its outbound link would have leaked an already-spentcode. The leak is one page earlier, it is automatic rather than click-dependent, and what it leaks is the live setup token. Anyone who later moves the token out of the query string should be looking at/register, not at the result page.Not fixed
The token is still a query parameter. Moving it to a POST body or a header changes the operator's entry flow and wants its own decision. This closes the most direct way it escapes in the meantime, and I would rather say that than imply the query-string problem is solved.
Verification
test/page-headers.test.tsstarts the server on an ephemeral port and asserts both headers on a real response — plus that the route still returns 404 without the setup token, so the test would fail if the headers were made to pass by loosening the guard.271 tests pass across 16 files;
tsc --noEmitclean.A passing assertion proves nothing about whether it can fail, so the header was deleted from
src/server.tsand the new file re-run:It genuinely fails without the header. Source restored.
The one flow a header change could affect
Every route reaching
sendPagewas checked. The only one at risk is that same cross-origin POST. GitHub's App Manifest flow is designed to be initiated from arbitrary origins, documents noRefererrequirement, and its CSRF defence is the signedstatein the query string — whichno-referrerdoes not touch. The OAuth paths (CONNECT_PATH,CALLBACK_PATH,INSTALL_PATH) are 302s fromredirect()and never see either header.I expect no breakage, but nothing here proves it. One manual run of the register flow before merge would close it, and that is the only outstanding item on this PR.
302s lacking
Cache-Controlis not a gap: noLocationvalue carries the setup token, and a 302 without explicit freshness is not heuristically cacheable.Worth knowing if you run tests locally
The suite needs
DATABASE_URLand gives 155 failures without it —config.tsthrows at import. CI supplies it; a fresh clone does not, and the error does not obviously point at the fix. Not changed here, but a line in the README would save the next person the detour it cost me.🤖 Generated with Claude Code
https://claude.ai/code/session_01D5xdKhPXJT4HMWwUyENiu1