fix(cli): answer the Private Network Access preflight on the loopback callback - #348
Draft
DevQwinB wants to merge 1 commit into
Draft
fix(cli): answer the Private Network Access preflight on the loopback callback#348DevQwinB wants to merge 1 commit into
DevQwinB wants to merge 1 commit into
Conversation
… callback The approval page is served over HTTPS from the deployment; the callback target is http://127.0.0.1:<port>. Loopback is potentially trustworthy so mixed-content blocking does not apply, but Chrome sends a CORS preflight for public -> private requests and refuses the real request unless the private server opts in. Without that opt-in the callback fails with an opaque network error and the CLI waits out its timeout. This is the most likely way the feature breaks for a developer and the least likely way it breaks for whoever wrote it: localhost -> localhost is not a public -> private transition, so it works in local testing. The policy is pure functions over plain header maps, with no server or transport in sight, so it is testable on its own and portable if the CLI is re-hosted — the headers are protocol, not implementation. Access-Control-Allow-Private-Network is sent only when the browser asked for it, so Firefox and Safari get ordinary working CORS and nobody is handed a private-network grant they never requested. The allow-origin is the deployment origin, compared as a parsed origin and never a wildcard: this server is about to accept a pairing completion, and a wildcard means any tab the developer has open can post to it. Origin is re-checked on the real POST, since a preflight is a browser courtesy and anything that is not a browser skips it. A refused preflight answers 403 with no CORS headers rather than a bare 204, so there is something in the network tab and the log instead of a silent "CORS error". The browser side gets the other half. A blocked cross-origin fetch rejects with a bare TypeError carrying no status and no reason, which is why both components look hung and neither says why. postToCli always resolves with a typed reason — blocked, timeout, refused, invalid-response — each with a message naming the likely cause and the next step, and always under a deadline. It requests cors mode explicitly: no-cors would resolve opaquely and report success for a request the CLI never accepted.
|
@DevQwinB Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
@DevQwinB is attempting to deploy a commit to the blockchainmaxis-8449's projects Team on Vercel. A member of the Team first needs to authorize it. |
✅ Deploy Preview for stellar-signet ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This was referenced Sep 1, 2026
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.
closes #272
The bug
The approval page is served over HTTPS from the deployment; the callback target is
http://127.0.0.1:<port>. Loopback is a potentially-trustworthy origin so mixed-content blocking does not apply — but Chrome sends a CORS preflight for public → private requests and refuses the real request unless the private server opts in withAccess-Control-Allow-Private-Network: true.Without it the callback fails with an opaque network error, the page shows a spinner, and the CLI waits out its timeout. Two components that both look hung, neither saying why.
And it passes local testing, because
localhost → localhostis not a public → private transition. That is what makes it the most likely way this breaks for a developer and the least likely way it breaks for whoever wrote it.The server half
apps/cli/src/loopback-cors.ts— the policy, as pure functions over plain header maps with no server or transport in sight, so it is testable on its own and ports directly if the CLI is re-hosted (the Go module in #251, say). These headers are protocol, not implementation.Access-Control-Allow-Private-Network: trueis sent only when the browser asked for it. Firefox and Safari do not implement PNA and send an ordinary preflight; they get working CORS, and nobody is handed a private-network grant they never requested.Access-Control-Allow-Originis the deployment origin, never*— this server is about to accept a pairing completion, and a wildcard means any tab the developer has open can post to it. Compared as a parsed origin, sohttps://signet.dev:443matches andhttps://signet.dev.evil.testdoes not.Vary: Origin, Access-Control-Request-Private-Network, so a cache cannot serve one origin's answer — or a non-PNA answer — to a different request.Access-Control-Max-Age: 60, short on purpose: the server lives for one pairing, and a long cached preflight for127.0.0.1:<port>would outlive it and apply to whatever binds that port next.apps/cli/src/loopback.ts— the single-use server. Binds127.0.0.1explicitly (never0.0.0.0), re-checks the origin on the realPOSTbecause a preflight is a browser courtesy that anything non-browser simply skips, caps the body, sends CORS headers on the actual response too (without them the page cannot read the result — the same silent hang), accepts exactly one callback, then closes.The browser half
The issue also asks that the browser-side failure path surface a real message instead of hanging. A blocked cross-origin
fetchrejects with a bareTypeError: Failed to fetch— no status, no reason, no way to tell "Chrome refused the private-network request" from "the CLI already exited".apps/web/lib/cli-callback.ts—postToCli()never throws and never hangs. It always resolves with a typed reason:blockedsignet linktimeoutrefusedinvalid-responseIt sets
mode: "cors"explicitly:no-corswould resolve opaquely and report success for a request the CLI never accepted.Verification
Real runs.
apps/cli: 15 tests passing. Eight over the policy functions, and seven driving a real server over real HTTP — including a full Chrome-shaped exchange:OPTIONSwithAccess-Control-Request-Private-Network: true→204+ the opt-in, then thePOST→200with CORS headers and the payload delivered. Plus: loopback-only bind, foreign origin refused on the real POST with a logged reason, 405 withAllow, malformed body → 400, single-use (the second callback finds the port gone), and the timeout rejecting withLoopbackTimeoutError.apps/web: 7 tests passing for the callback helper, covering the bare-TypeErrorcase, the deadline, refusal vs block, and that it never throws whateverfetchdoes.pnpm --filter @signet/cli typecheckandpnpm --filter @signet/web typecheck→ both clean.eslintclean on both;prettier --checkclean.Where this fits
I could not fix this against the existing CLI because there is not one on
main. What is here is the piece that stays correct regardless of which layout wins: the header policy is decided by the Fetch and PNA specs, not by where the route lives.apps/climatches the layout #320 and #321 already use. If the Go direction in #251 wins instead,loopback-cors.tsis the file to port — it has no Node dependencies beyond the type of a header map, and its tests are the conformance suite for a port.Happy to rebase onto whichever pairing PR lands first and wire this into it.