Files
orca/src/shared/web-origin-security.ts
T
OrcaWinandm4air fee083fa47 feat(runtime): session-only browser keys and opt-in for pairing from plain-http pages (host) (#27540)
* feat(runtime): session-only browser keys and an opt-in for pairing from plain-http pages (host)

- pairing.registerDeviceKey accepts ephemeral: true (advertised as
  pairing.device-key-ephemeral.v1). Such keyed rows are dropped when the host
  restarts and after 12 hours with no live socket; a sweep removes them and cuts
  any socket still bound to them, so later key auth gets device_revoked.
- The WebSocket upgrade Origin is recorded per socket. A first pairing (a
  never-used pairing code) from an http:// page on another machine is refused
  with a sealed insecure_origin error unless the operator sets
  ORCA_ALLOW_INSECURE_WEB_PAIRING=1. Already-paired browsers, https and loopback
  pages, and native clients are unchanged.
- Registry file reading moved to device-registry-file-read.ts to keep the
  registry under the size cap.

* fix(runtime): gate first pairings only for the marked web client, never phones

React Native sends an http Origin on every LAN WebSocket, so the Origin alone
refused first-time QR pairing from released phones. The web bundle now marks
its upgrade with ?orca-client=web; only a marked socket from an http page on
another machine is gated. Released hosts ignore the query string.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 14:40:36 -07:00

62 lines
2.0 KiB
TypeScript

// Why: a plain-http page on another machine can be swapped by anyone on the LAN path, so a
// pairing code typed into it is exposed. Browsers treat loopback http as a secure context.
export function isInsecureRemoteWebOrigin(origin: string | undefined): boolean {
if (!origin) {
return false
}
let url: URL
try {
url = new URL(origin)
} catch {
return false
}
return url.protocol === 'http:' && !isLoopbackHostname(url.hostname)
}
function isLoopbackHostname(hostname: string): boolean {
const host = hostname.toLowerCase()
return (
host === 'localhost' ||
host.endsWith('.localhost') ||
host === '[::1]' ||
/^127(?:\.\d{1,3}){3}$/.test(host)
)
}
/** Sealed auth error for a first pairing from such a page without operator opt-in. */
export const INSECURE_WEB_ORIGIN_AUTH_CODE = 'insecure_origin' as const
// Operator opt-in, read by the host serving the page (desktop or `orca serve`).
export const ALLOW_INSECURE_WEB_PAIRING_ENV = 'ORCA_ALLOW_INSECURE_WEB_PAIRING' as const
// Why a marker: React Native phones also send an http Origin on LAN sockets, so the Origin alone
// cannot say "browser". Only the web bundle adds this query to its upgrade URL; hosts that predate
// it ignore the query string, so the marked upgrade still connects to them.
export const WEB_CLIENT_UPGRADE_PARAM = 'orca-client' as const
export const WEB_CLIENT_UPGRADE_VALUE = 'web' as const
export function withWebClientUpgradeMarker(endpoint: string): string {
try {
const url = new URL(endpoint)
url.searchParams.set(WEB_CLIENT_UPGRADE_PARAM, WEB_CLIENT_UPGRADE_VALUE)
return url.toString()
} catch {
return endpoint
}
}
/** True when an upgrade request path (`/path?query`) carries the web bundle's marker. */
export function hasWebClientUpgradeMarker(requestUrl: string | undefined): boolean {
if (!requestUrl) {
return false
}
try {
return (
new URL(requestUrl, 'http://upgrade.invalid').searchParams.get(WEB_CLIENT_UPGRADE_PARAM) ===
WEB_CLIENT_UPGRADE_VALUE
)
} catch {
return false
}
}