Skip to content

Keep the setup pages out of caches and out of Referer headers - #7

Closed
tomj12k wants to merge 1 commit into
mainfrom
security/t98-no-store-registration
Closed

tomj12k wants to merge 1 commit into
mainfrom
security/t98-no-store-registration

Conversation

@tomj12k

@tomj12k tomj12k commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Threat: T98 (Morelitea/security, Boundary 8). Severity Medium. NIST IA-5, AU-9, SI-11.
Work item: WI-8.

Why

sendPage serves every HTML page in the setup and OAuth flow. Two are reached by a URL carrying a secret:

  • the registration form behind ?token=
  • 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, 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 page sendPage serves 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):

<form id="go" method="post" action="…/settings/apps/new?state=…">
  <input type="hidden" name="manifest" value="">
</form>
<script>document.getElementById("go").submit();</script>

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-referrer stops it.

An earlier version of this section blamed the result screen instead. That was wrong and it understated the problem: REGISTER_DONE_PATH reads only state and code (src/server.ts:388-404) — the setup token is not in that URL at all, and its outbound link would have leaked an already-spent code. 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.ts starts 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 --noEmit clean.

A passing assertion proves nothing about whether it can fail, so the header was deleted from src/server.ts and the new file re-run:

expect(response.headers.get("cache-control")).toBe("no-store")
Received: null      →  1 failed | 2 passed (3)

It genuinely fails without the header. Source restored.

The one flow a header change could affect

Every route reaching sendPage was 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 no Referer requirement, and its CSRF defence is the signed state in the query string — which no-referrer does not touch. The OAuth paths (CONNECT_PATH, CALLBACK_PATH, INSTALL_PATH) are 302s from redirect() 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-Control is not a gap: no Location value 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_URL and gives 155 failures without it — config.ts throws 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

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
@tomj12k

tomj12k commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Production-readiness review completed with no defect found. Secret-bearing setup/result responses consistently set Cache-Control: no-store and Referrer-Policy: no-referrer, including error paths covered by the route tests. Trade-off retained from scope: the setup token remains in the URL and therefore browser/proxy history; these headers prevent onward Referer leakage but do not remove local URL history.

@tomj12k tomj12k closed this Sep 10, 2026
@tomj12k
tomj12k deleted the security/t98-no-store-registration branch September 10, 2026 18:38
@tomj12k

tomj12k commented Sep 10, 2026

Copy link
Copy Markdown
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.

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