mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(web): pair and stay paired from plain-http LAN and Tailscale pages again (#29984)
Partially reverts #27540 and the plain-http part of #27552, which broke web pairing on upgrade for 1.4.227 users who open Orca at a LAN or Tailscale address: - The host no longer refuses a first pairing from a plain-http page on another machine (insecure_origin, ORCA_ALLOW_INSECURE_WEB_PAIRING). - Such a page keeps its pairing code in local storage and signs in with it, as in 1.4.227, so reloads, new tabs and bookmarks stay paired; no notice. - Session-only browser keys (pairing.device-key-ephemeral.v1, the idle sweep) stay for browsers without Ed25519. - A web client whose credentials are refused shows the host's reason instead of an empty workspace and "Session restore failed".
This commit is contained in:
@@ -11,8 +11,7 @@ import {
|
||||
authenticateMobileE2EE,
|
||||
decodeMobileE2EEPublicKey,
|
||||
describeMobileE2EEAuthFailure,
|
||||
type DeviceKeyResolution,
|
||||
type DeviceTokenRefusal
|
||||
type DeviceKeyResolution
|
||||
} from './mobile-e2ee-auth-validation'
|
||||
import { isMobileE2EEOutboundItemWithinLimit } from './mobile-e2ee-outbound-admission'
|
||||
import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames'
|
||||
@@ -37,7 +36,7 @@ const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5
|
||||
|
||||
export type E2EEChannelOptions = {
|
||||
serverSecretKey: Uint8Array
|
||||
resolveAuthenticatedDevice: (token: string) => E2EEAuthenticatedDevice | DeviceTokenRefusal | null
|
||||
resolveAuthenticatedDevice: (token: string) => E2EEAuthenticatedDevice | null
|
||||
// Absent: key auth is refused as bad_auth, the same as before device keys existed.
|
||||
resolveAuthenticatedDeviceKey?: (deviceId: string) => DeviceKeyResolution<E2EEAuthenticatedDevice>
|
||||
onReady: (channel: E2EEChannel, device: E2EEAuthenticatedDevice) => void
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-sessio
|
||||
import { publicKeyFromBase64 } from './e2ee-crypto'
|
||||
import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames'
|
||||
import { isDeviceProofB64, verifyDeviceProof } from '../../../shared/device-key-proof'
|
||||
import { INSECURE_WEB_ORIGIN_AUTH_CODE } from '../../../shared/web-origin-security'
|
||||
|
||||
export type ClassifiedE2EEAuth =
|
||||
| { kind: 'token'; deviceToken: string; clientCapabilities?: unknown }
|
||||
@@ -81,10 +80,6 @@ export type MobileE2EEAuthFailureCode =
|
||||
| 'device_revoked'
|
||||
| 'registry_unavailable'
|
||||
| 'bad_proof'
|
||||
| typeof INSECURE_WEB_ORIGIN_AUTH_CODE
|
||||
|
||||
// The token matched, but this transport may not claim it (e.g. a first pairing from an insecure page).
|
||||
export type DeviceTokenRefusal = { refused: typeof INSECURE_WEB_ORIGIN_AUTH_CODE }
|
||||
|
||||
export type DeviceKeyResolution<TDevice> =
|
||||
| { kind: 'found'; device: TDevice; devicePublicKeyB64: string }
|
||||
@@ -97,7 +92,7 @@ export type DeviceKeyResolution<TDevice> =
|
||||
export function authenticateMobileE2EE<TDevice extends { deviceToken?: string }>(args: {
|
||||
plaintext: string
|
||||
v2Session: DesktopMobileE2EEV2Session | null
|
||||
resolveDevice: (token: string) => TDevice | DeviceTokenRefusal | null
|
||||
resolveDevice: (token: string) => TDevice | null
|
||||
// Absent on a host that does not accept device keys: key auth is then bad_auth, as before.
|
||||
resolveDeviceKey?: (deviceId: string) => DeviceKeyResolution<TDevice>
|
||||
}):
|
||||
@@ -106,9 +101,6 @@ export function authenticateMobileE2EE<TDevice extends { deviceToken?: string }>
|
||||
const auth = classifyE2EEAuth(args.plaintext, args.v2Session)
|
||||
if (auth.kind === 'token') {
|
||||
const device = args.resolveDevice(auth.deviceToken)
|
||||
if (device && 'refused' in device) {
|
||||
return { ok: false, code: device.refused }
|
||||
}
|
||||
// Why: a keyed row has no token, so presenting one can never match it.
|
||||
return device?.deviceToken === auth.deviceToken
|
||||
? { ok: true, device, clientCapabilities: auth.clientCapabilities }
|
||||
@@ -152,9 +144,7 @@ export function describeMobileE2EEAuthFailure(failure: {
|
||||
? 'Unauthorized'
|
||||
: failure.code === 'registry_unavailable'
|
||||
? 'Device registry unavailable'
|
||||
: failure.code === INSECURE_WEB_ORIGIN_AUTH_CODE
|
||||
? 'Insecure web origin'
|
||||
: 'Invalid e2ee_auth'
|
||||
: 'Invalid e2ee_auth'
|
||||
return {
|
||||
sealedError: {
|
||||
type: 'e2ee_error',
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type { WebSocket } from 'ws'
|
||||
import type { DeviceEntry, DeviceRegistry } from '../device-registry'
|
||||
import { isPendingTokenRow } from '../device-entry'
|
||||
import { EPHEMERAL_DEVICE_SWEEP_INTERVAL_MS } from '../ephemeral-device-rows'
|
||||
import {
|
||||
INSECURE_WEB_ORIGIN_AUTH_CODE,
|
||||
isInsecureRemoteWebOrigin
|
||||
} from '../../../shared/web-origin-security'
|
||||
import { devicePrincipalOf } from '../device-principal'
|
||||
import type { E2EEKeypair } from '../e2ee-keypair'
|
||||
import { E2EEChannel, type E2EEAuthenticatedDevice } from './e2ee-channel'
|
||||
@@ -17,8 +12,7 @@ import type { RpcBinarySender } from './rpc-binary-sender'
|
||||
type MobileSocketPayload = string | Uint8Array<ArrayBufferLike>
|
||||
|
||||
export type MobileSocketTransportMetadata =
|
||||
// webClient: the Orca web bundle marked its upgrade; webOrigin: that page's origin.
|
||||
| { transport: 'direct'; webOrigin?: string; webClient?: true }
|
||||
| { transport: 'direct' }
|
||||
| {
|
||||
transport: 'relay'
|
||||
relayHostId: string
|
||||
@@ -66,8 +60,6 @@ type MobileSocketWiringOptions = {
|
||||
onReady?: (socket: AuthenticatedMobileSocket) => void
|
||||
// Why: stale keys and missing registry entries both fail before RPC can explain the re-pair action.
|
||||
onUnpairedDeviceAuthFailure?: (metadata: MobileSocketTransportMetadata) => void
|
||||
// Operator opt-in for first pairings from a plain-http page on another machine.
|
||||
allowInsecureWebPairing?: () => boolean
|
||||
}
|
||||
|
||||
function toAuthenticatedDevice(device: DeviceEntry): E2EEAuthenticatedDevice {
|
||||
@@ -85,7 +77,6 @@ export class MobileSocketWiring {
|
||||
private readonly onClose: MobileSocketWiringOptions['onClose']
|
||||
private readonly onReady: MobileSocketWiringOptions['onReady']
|
||||
private readonly onUnpairedDeviceAuthFailure: MobileSocketWiringOptions['onUnpairedDeviceAuthFailure']
|
||||
private readonly allowInsecureWebPairing: () => boolean
|
||||
private readonly ephemeralSweep: ReturnType<typeof setInterval>
|
||||
private readonly channels = new Map<WebSocket, E2EEChannel>()
|
||||
private readonly connectionIds = new Map<WebSocket, string>()
|
||||
@@ -101,7 +92,6 @@ export class MobileSocketWiring {
|
||||
this.onClose = options.onClose
|
||||
this.onReady = options.onReady
|
||||
this.onUnpairedDeviceAuthFailure = options.onUnpairedDeviceAuthFailure
|
||||
this.allowInsecureWebPairing = options.allowInsecureWebPairing ?? (() => false)
|
||||
this.ephemeralSweep = setInterval(
|
||||
() => this.sweepIdleEphemeralDevices(Date.now()),
|
||||
EPHEMERAL_DEVICE_SWEEP_INTERVAL_MS
|
||||
@@ -200,16 +190,6 @@ export class MobileSocketWiring {
|
||||
if (!device) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
isPendingTokenRow(device) &&
|
||||
metadata.transport === 'direct' &&
|
||||
// Why the marker too: phones send an http Origin as well, and must never be gated.
|
||||
metadata.webClient === true &&
|
||||
isInsecureRemoteWebOrigin(metadata.webOrigin) &&
|
||||
!this.allowInsecureWebPairing()
|
||||
) {
|
||||
return { refused: INSECURE_WEB_ORIGIN_AUTH_CODE }
|
||||
}
|
||||
// Why: outer relay authorization cannot choose the local Orca
|
||||
// identity; E2EE must resolve the same device before readiness.
|
||||
if (metadata.transport === 'relay' && metadata.relayDeviceId !== device.deviceId) {
|
||||
|
||||
@@ -41,17 +41,9 @@ class FakeTransport implements MobileSocketTransport {
|
||||
}
|
||||
|
||||
const desktop = generateKeyPair()
|
||||
const LAN_PAGE = 'http://192.168.1.20:6768'
|
||||
const RELAY_HOST_ID = 'AbCdEf0123_-xyZ9'
|
||||
|
||||
// The new web bundle marks its upgrade; phones (React Native) send an http Origin but no marker.
|
||||
const WEB_CLIENT = { webOrigin: LAN_PAGE, webClient: true as const }
|
||||
|
||||
function harness(
|
||||
page: { webOrigin?: string; webClient?: true } | undefined,
|
||||
allowInsecureWebPairing = false,
|
||||
relay?: (deviceId: string) => MobileSocketTransportMetadata
|
||||
) {
|
||||
function harness(relay?: (deviceId: string) => MobileSocketTransportMetadata) {
|
||||
const registry = new DeviceRegistry(mkdtempSync(join(tmpdir(), 'orca-web-session-')))
|
||||
const transport = new FakeTransport()
|
||||
const ready: AuthenticatedMobileSocket[] = []
|
||||
@@ -65,13 +57,10 @@ function harness(
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onReady: (socket) => ready.push(socket),
|
||||
allowInsecureWebPairing: () => allowInsecureWebPairing
|
||||
onReady: (socket) => ready.push(socket)
|
||||
})
|
||||
let relayDeviceId = ''
|
||||
wiring.attachTransport(transport, () =>
|
||||
relay ? relay(relayDeviceId) : { transport: 'direct', ...page }
|
||||
)
|
||||
wiring.attachTransport(transport, () => (relay ? relay(relayDeviceId) : { transport: 'direct' }))
|
||||
|
||||
function connect(auth: (transcriptHashB64: string) => Record<string, unknown>) {
|
||||
const ws = new FakeSocket()
|
||||
@@ -107,64 +96,25 @@ function sign(key: nacl.SignKeyPair, purpose: 'auth' | 'register', transcriptHas
|
||||
return Buffer.from(nacl.sign.detached(message, key.secretKey)).toString('base64')
|
||||
}
|
||||
|
||||
describe('pairing from a plain-http page on another machine', () => {
|
||||
it('refuses a first pairing without operator opt-in, with a sealed reason', () => {
|
||||
const { registry, connect } = harness(WEB_CLIENT)
|
||||
const pending = registry.getOrCreatePendingDevice('Browser', 'runtime')
|
||||
const attempt = connect(() => ({ deviceToken: pending.token }))
|
||||
expect(attempt.reply).toEqual({ type: 'e2ee_error', error: { code: 'insecure_origin' } })
|
||||
expect(attempt.socket).toBeNull()
|
||||
expect(attempt.ws.close).toHaveBeenCalledWith(4001, 'Insecure web origin')
|
||||
expect(registry.getDevice(pending.deviceId)?.lastSeenAt).toBe(0)
|
||||
})
|
||||
|
||||
it('pairs when the operator opted in', () => {
|
||||
const { registry, connect } = harness(WEB_CLIENT, true)
|
||||
// Why no page origin here: the host no longer gates a first pairing on the page that opened the
|
||||
// socket (#27540's refusal broke LAN and Tailscale web pairing), so every direct client pairs alike.
|
||||
describe('first pairing over a direct socket', () => {
|
||||
it('pairs a never-used code and keeps an already-paired browser working', () => {
|
||||
const { registry, connect } = harness()
|
||||
const pending = registry.getOrCreatePendingDevice('Browser', 'runtime')
|
||||
expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({
|
||||
type: 'e2ee_authenticated'
|
||||
})
|
||||
})
|
||||
|
||||
it('never cuts a browser that was already paired', () => {
|
||||
const { registry, connect } = harness(WEB_CLIENT)
|
||||
const paired = registry.addDevice('Browser', 'runtime')
|
||||
registry.updateLastSeen(paired.deviceId)
|
||||
expect(connect(() => ({ deviceToken: paired.token })).reply).toMatchObject({
|
||||
expect(registry.getDevice(pending.deviceId)?.lastSeenAt).toBeGreaterThan(0)
|
||||
expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({
|
||||
type: 'e2ee_authenticated'
|
||||
})
|
||||
})
|
||||
|
||||
it('lets https, loopback and native clients pair as before', () => {
|
||||
for (const page of [
|
||||
{ webOrigin: 'https://desk.example.com', webClient: true as const },
|
||||
{ webOrigin: 'http://localhost:6768', webClient: true as const },
|
||||
undefined
|
||||
]) {
|
||||
const { registry, connect } = harness(page)
|
||||
const pending = registry.getOrCreatePendingDevice('Client', 'runtime')
|
||||
expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({
|
||||
type: 'e2ee_authenticated'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('phones over direct LAN are never gated by their Origin', () => {
|
||||
it('pairs a released phone whose socket sends an http LAN Origin but no web marker', () => {
|
||||
for (const scope of ['mobile', 'runtime'] as const) {
|
||||
const { registry, connect } = harness({ webOrigin: LAN_PAGE })
|
||||
const pending = registry.getOrCreatePendingDevice('Phone', scope)
|
||||
expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({
|
||||
type: 'e2ee_authenticated'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('relayed phones are never gated', () => {
|
||||
it('pairs over Relay whatever the page marker says', () => {
|
||||
const { registry, connect, bindRelay } = harness(WEB_CLIENT, false, (relayDeviceId) => ({
|
||||
describe('relayed phones', () => {
|
||||
it('pair over Relay', () => {
|
||||
const { registry, connect, bindRelay } = harness((relayDeviceId) => ({
|
||||
transport: 'relay',
|
||||
relayHostId: RELAY_HOST_ID,
|
||||
relayDeviceId,
|
||||
@@ -181,10 +131,7 @@ describe('relayed phones are never gated', () => {
|
||||
|
||||
describe('session-only browser keys', () => {
|
||||
it('registers an ephemeral row, sweeps it once idle and disconnected, and then revokes it', () => {
|
||||
const { registry, wiring, transport, connect } = harness({
|
||||
webOrigin: 'https://desk.example.com',
|
||||
webClient: true as const
|
||||
})
|
||||
const { registry, wiring, transport, connect } = harness()
|
||||
const paired = registry.addDevice('Browser', 'runtime')
|
||||
registry.updateLastSeen(paired.deviceId)
|
||||
const migrating = connect(() => ({ deviceToken: paired.token }))
|
||||
@@ -219,7 +166,7 @@ describe('session-only browser keys', () => {
|
||||
})
|
||||
|
||||
it('cuts sockets bound to a swept row', () => {
|
||||
const { registry, wiring, transport } = harness(undefined)
|
||||
const { registry, wiring, transport } = harness()
|
||||
const paired = registry.addDevice('Browser', 'runtime')
|
||||
registry.updateLastSeen(paired.deviceId)
|
||||
const key = nacl.sign.keyPair()
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createServer as createHttpServer, type Server as HttpServer } from 'nod
|
||||
import { WebSocketServer, type WebSocket } from 'ws'
|
||||
import type { RpcTransport } from './transport'
|
||||
import { createStaticWebClientHandler } from './static-web-client-handler'
|
||||
import { hasWebClientUpgradeMarker } from '../../../shared/web-origin-security'
|
||||
import {
|
||||
attachNodeWebSocketLifecycle,
|
||||
clearNodeWebSocketPreAuthTimer,
|
||||
@@ -65,7 +64,6 @@ export class WebSocketTransport implements RpcTransport {
|
||||
private readonly fallbackLadder: readonly number[]
|
||||
private persistedFallbackBindFailed = false
|
||||
private httpServer: HttpsServer | HttpServer | null = null
|
||||
private readonly webPages = new WeakMap<WebSocket, { webOrigin?: string; webClient: true }>()
|
||||
private wss: WebSocketServer | null = null
|
||||
private messageHandler: WebSocketMessageHandler | null = null
|
||||
private connectionCloseHandler: WebSocketConnectionCloseHandler | null = null
|
||||
@@ -119,11 +117,6 @@ export class WebSocketTransport implements RpcTransport {
|
||||
this.connectionCloseHandler = handler
|
||||
}
|
||||
|
||||
/** The Orca web bundle that opened `ws` (it marks its upgrade), with its page origin. */
|
||||
webPageOf(ws: WebSocket): { webOrigin?: string; webClient: true } | undefined {
|
||||
return this.webPages.get(ws)
|
||||
}
|
||||
|
||||
setClientId(ws: WebSocket, clientId: string): void {
|
||||
this.wsClientIds.set(ws, clientId)
|
||||
clearNodeWebSocketPreAuthTimer(ws, this.preAuthTimers)
|
||||
@@ -236,18 +229,11 @@ export class WebSocketTransport implements RpcTransport {
|
||||
maxPayload: WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES
|
||||
})
|
||||
|
||||
wss.on('connection', (ws, request) => {
|
||||
wss.on('connection', (ws) => {
|
||||
if (wss.clients.size > WEBSOCKET_TRANSPORT_MAX_CONNECTIONS) {
|
||||
rejectNodeWebSocketOverCapacity(ws)
|
||||
return
|
||||
}
|
||||
if (hasWebClientUpgradeMarker(request.url)) {
|
||||
const origin = request.headers.origin
|
||||
this.webPages.set(ws, {
|
||||
...(typeof origin === 'string' ? { webOrigin: origin } : {}),
|
||||
webClient: true
|
||||
})
|
||||
}
|
||||
this.handleConnection(ws)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ALLOW_INSECURE_WEB_PAIRING_ENV } from '../../../shared/web-origin-security'
|
||||
import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../shared/agent-launch-runtime-capability'
|
||||
import type { RuntimeTransportMetadata } from '../../../shared/runtime-bootstrap'
|
||||
import { watchRuntimeMetadataOwnership } from '../runtime-metadata-ownership-watch'
|
||||
@@ -200,10 +199,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch {
|
||||
fallbackLadder: wsFallbackPortLadder(this.wsPort)
|
||||
})
|
||||
const mobileSocketWiring = this.ensureMobileSocketWiring(deviceRegistry, e2eeKeypair)
|
||||
this.detachWebSocketWiring = mobileSocketWiring.attachTransport(wsTransport, (ws) => ({
|
||||
transport: 'direct',
|
||||
...wsTransport.webPageOf(ws)
|
||||
}))
|
||||
this.detachWebSocketWiring = mobileSocketWiring.attachTransport(wsTransport)
|
||||
|
||||
try {
|
||||
await wsTransport.start()
|
||||
@@ -282,7 +278,6 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch {
|
||||
this.runtime.onClientDisconnected(socket.device.clientId)
|
||||
}
|
||||
},
|
||||
allowInsecureWebPairing: () => process.env[ALLOW_INSECURE_WEB_PAIRING_ENV] === '1',
|
||||
// Why: relay attempts are authorized upstream; only direct failures should prompt local re-pairing.
|
||||
onUnpairedDeviceAuthFailure: (metadata) => {
|
||||
if (metadata.transport === 'direct') {
|
||||
|
||||
@@ -585,9 +585,7 @@
|
||||
},
|
||||
"pairingSecurity": {
|
||||
"hide": "Hide",
|
||||
"storedCodeInsecureOrigin": "This browser keeps a reusable pairing code, because this page is plain http on another machine. Anyone who can read this browser’s storage could use it. For a safer pairing, open Orca over https, at localhost on this computer, or through an SSH tunnel, and pair again.",
|
||||
"storedCodeNoEd25519": "This browser keeps a reusable pairing code, because it cannot create secure sign-in keys (Ed25519). Use a current browser and pair again for a safer pairing.",
|
||||
"sessionInsecureOrigin": "This pairing lasts only while this tab is open, because this page is plain http on another machine. For a pairing this browser remembers, open Orca over https, at localhost on this computer, or through an SSH tunnel.",
|
||||
"sessionNoEd25519": "This pairing lasts only while this tab is open, because this browser cannot create secure sign-in keys (Ed25519). Use a current browser for a pairing it remembers."
|
||||
},
|
||||
"runtimeAuth": {
|
||||
@@ -18183,6 +18181,10 @@
|
||||
"title": "Couldn't reach your Orca host",
|
||||
"description": "Nothing was lost. Orca will reload when the host answers, and won't save changes until then.",
|
||||
"retry": "Retry now"
|
||||
},
|
||||
"webPairingRefused": {
|
||||
"title": "Orca refused this browser",
|
||||
"retry": "Try again"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -414,9 +414,7 @@
|
||||
},
|
||||
"pairingSecurity": {
|
||||
"hide": "Ocultar",
|
||||
"storedCodeInsecureOrigin": "Este navegador guarda un código de emparejamiento reutilizable porque esta página usa http sin cifrar en otro equipo. Cualquiera que pueda leer el almacenamiento de este navegador podría usarlo. Para un emparejamiento más seguro, abre Orca por https, en localhost en este equipo o a través de un túnel SSH, y vuelve a emparejar.",
|
||||
"storedCodeNoEd25519": "Este navegador guarda un código de emparejamiento reutilizable porque no puede crear claves de inicio de sesión seguras (Ed25519). Usa un navegador actual y vuelve a emparejar para un emparejamiento más seguro.",
|
||||
"sessionInsecureOrigin": "Este emparejamiento solo dura mientras esta pestaña esté abierta, porque esta página usa http sin cifrar en otro equipo. Para un emparejamiento que este navegador recuerde, abre Orca por https, en localhost en este equipo o a través de un túnel SSH.",
|
||||
"sessionNoEd25519": "Este emparejamiento solo dura mientras esta pestaña esté abierta, porque este navegador no puede crear claves de inicio de sesión seguras (Ed25519). Usa un navegador actual para un emparejamiento que recuerde."
|
||||
},
|
||||
"runtimeAuth": {
|
||||
@@ -17980,6 +17978,10 @@
|
||||
"title": "No se pudo contactar con tu host de Orca",
|
||||
"description": "No se perdió nada. Orca se recargará cuando el host responda y no guardará cambios hasta entonces.",
|
||||
"retry": "Reintentar ahora"
|
||||
},
|
||||
"webPairingRefused": {
|
||||
"title": "Orca rechazó este navegador",
|
||||
"retry": "Intentar de nuevo"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -518,9 +518,7 @@
|
||||
},
|
||||
"pairingSecurity": {
|
||||
"hide": "Masquer",
|
||||
"storedCodeInsecureOrigin": "Ce navigateur conserve un code d'appairage réutilisable, car cette page est en http non chiffré sur une autre machine. Toute personne pouvant lire le stockage de ce navigateur pourrait l'utiliser. Pour un appairage plus sûr, ouvrez Orca en https, à localhost sur cet ordinateur, ou via un tunnel SSH, puis appairez à nouveau.",
|
||||
"storedCodeNoEd25519": "Ce navigateur conserve un code d'appairage réutilisable, car il ne peut pas créer de clés de connexion sécurisées (Ed25519). Utilisez un navigateur à jour et appairez à nouveau pour un appairage plus sûr.",
|
||||
"sessionInsecureOrigin": "Cet appairage ne dure que tant que cet onglet est ouvert, car cette page est en http non chiffré sur une autre machine. Pour un appairage que ce navigateur mémorise, ouvrez Orca en https, à localhost sur cet ordinateur, ou via un tunnel SSH.",
|
||||
"sessionNoEd25519": "Cet appairage ne dure que tant que cet onglet est ouvert, car ce navigateur ne peut pas créer de clés de connexion sécurisées (Ed25519). Utilisez un navigateur à jour pour un appairage qu'il mémorise."
|
||||
},
|
||||
"runtimeAuth": {
|
||||
@@ -18089,6 +18087,10 @@
|
||||
"title": "Impossible de joindre votre hôte Orca",
|
||||
"description": "Rien n'a été perdu. Orca se rechargera lorsque l'hôte répondra et n'enregistrera aucune modification d'ici là.",
|
||||
"retry": "Réessayer maintenant"
|
||||
},
|
||||
"webPairingRefused": {
|
||||
"title": "Orca a refusé ce navigateur",
|
||||
"retry": "Réessayer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -414,9 +414,7 @@
|
||||
},
|
||||
"pairingSecurity": {
|
||||
"hide": "非表示",
|
||||
"storedCodeInsecureOrigin": "このブラウザは再利用可能なペアリングコードを保持します。このページが別のマシン上の平文 http であるためです。このブラウザのストレージを読み取れる人は誰でもそれを使用できます。より安全にペアリングするには、https で、このコンピュータの localhost で、または SSH トンネル経由で Orca を開き、もう一度ペアリングしてください。",
|
||||
"storedCodeNoEd25519": "このブラウザは再利用可能なペアリングコードを保持します。安全なサインインキー (Ed25519) を作成できないためです。より安全にペアリングするには、最新のブラウザを使用してもう一度ペアリングしてください。",
|
||||
"sessionInsecureOrigin": "このページが別のマシン上の平文 http であるため、このペアリングはこのタブを開いている間だけ有効です。このブラウザが記憶するペアリングを行うには、https で、このコンピュータの localhost で、または SSH トンネル経由で Orca を開いてください。",
|
||||
"sessionNoEd25519": "このブラウザは安全なサインインキー (Ed25519) を作成できないため、このペアリングはこのタブを開いている間だけ有効です。このブラウザが記憶するペアリングを行うには、最新のブラウザを使用してください。"
|
||||
},
|
||||
"runtimeAuth": {
|
||||
@@ -17985,6 +17983,10 @@
|
||||
"title": "Orca ホストに接続できませんでした",
|
||||
"description": "失われたものはありません。ホストが応答すると Orca は再読み込みされます。それまで変更は保存されません。",
|
||||
"retry": "今すぐ再試行"
|
||||
},
|
||||
"webPairingRefused": {
|
||||
"title": "Orca がこのブラウザを拒否しました",
|
||||
"retry": "再試行"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -414,9 +414,7 @@
|
||||
},
|
||||
"pairingSecurity": {
|
||||
"hide": "숨기기",
|
||||
"storedCodeInsecureOrigin": "이 브라우저는 재사용 가능한 페어링 코드를 보관합니다. 이 페이지는 다른 컴퓨터에 있고 일반 http이기 때문입니다. 이 브라우저의 저장소를 읽을 수 있는 사람은 누구나 이 코드를 사용할 수 있습니다. 더 안전하게 페어링하려면 https로, 이 컴퓨터의 localhost에서, 또는 SSH 터널을 통해 Orca를 열고 다시 페어링하세요.",
|
||||
"storedCodeNoEd25519": "이 브라우저는 보안 로그인 키(Ed25519)를 만들 수 없기 때문에 재사용 가능한 페어링 코드를 보관합니다. 더 안전하게 페어링하려면 최신 브라우저를 사용하고 다시 페어링하세요.",
|
||||
"sessionInsecureOrigin": "이 페어링은 이 탭이 열려 있는 동안에만 유지됩니다. 이 페이지는 다른 컴퓨터에 있고 일반 http이기 때문입니다. 이 브라우저가 기억하는 페어링을 원한다면 https로, 이 컴퓨터의 localhost에서, 또는 SSH 터널을 통해 Orca를 여세요.",
|
||||
"sessionNoEd25519": "이 페어링은 이 탭이 열려 있는 동안에만 유지됩니다. 이 브라우저는 보안 로그인 키(Ed25519)를 만들 수 없기 때문입니다. 브라우저가 기억하는 페어링을 원한다면 최신 브라우저를 사용하세요."
|
||||
},
|
||||
"runtimeAuth": {
|
||||
@@ -17985,6 +17983,10 @@
|
||||
"title": "Orca 호스트에 연결할 수 없습니다",
|
||||
"description": "손실된 내용은 없습니다. 호스트가 응답하면 Orca가 다시 로드되며, 그때까지 변경사항은 저장되지 않습니다.",
|
||||
"retry": "지금 다시 시도"
|
||||
},
|
||||
"webPairingRefused": {
|
||||
"title": "Orca가 이 브라우저를 거부했습니다",
|
||||
"retry": "다시 시도"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -414,9 +414,7 @@
|
||||
},
|
||||
"pairingSecurity": {
|
||||
"hide": "隐藏",
|
||||
"storedCodeInsecureOrigin": "此浏览器会保存一个可重复使用的配对码,因为此页面是另一台机器上的纯 http 页面。任何能读取此浏览器存储的人都可以使用它。如需更安全的配对,请通过 https、本机的 localhost 或 SSH 隧道打开 Orca,然后重新配对。",
|
||||
"storedCodeNoEd25519": "此浏览器会保存一个可重复使用的配对码,因为它无法创建安全的登录密钥(Ed25519)。请使用最新版浏览器并重新配对,以获得更安全的配对。",
|
||||
"sessionInsecureOrigin": "此配对仅在此标签页打开期间有效,因为此页面是另一台机器上的纯 http 页面。如需让此浏览器记住配对,请通过 https、本机的 localhost 或 SSH 隧道打开 Orca。",
|
||||
"sessionNoEd25519": "此配对仅在此标签页打开期间有效,因为此浏览器无法创建安全的登录密钥(Ed25519)。请使用最新版浏览器以获得此浏览器能记住的配对。"
|
||||
},
|
||||
"runtimeAuth": {
|
||||
@@ -17985,6 +17983,10 @@
|
||||
"title": "无法连接到你的 Orca 主机",
|
||||
"description": "没有任何内容丢失。主机响应后 Orca 会重新加载,在此之前不会保存更改。",
|
||||
"retry": "立即重试"
|
||||
},
|
||||
"webPairingRefused": {
|
||||
"title": "Orca 拒绝了此浏览器",
|
||||
"retry": "重试"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ import { toast } from 'sonner'
|
||||
import { useAppStore } from '../store'
|
||||
import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status'
|
||||
import { recoverFromDegradedStartup } from './startup-degraded-recovery'
|
||||
import { createWebRuntimeUnauthorizedError } from '../web/web-runtime-client-error'
|
||||
|
||||
function recover(args: {
|
||||
isCancelled: () => boolean
|
||||
@@ -200,5 +201,16 @@ describe('recoverFromDegradedStartup', () => {
|
||||
|
||||
expect(vi.mocked(toast.error).mock.calls[0]?.[0]).toBe('Session restore failed')
|
||||
})
|
||||
|
||||
it('shows why the host refused this browser instead of a session-restore failure', async () => {
|
||||
const refusal = createWebRuntimeUnauthorizedError('insecure-origin')
|
||||
await recoverFrom(refusal)
|
||||
|
||||
const [title, options] = vi.mocked(toast.error).mock.calls[0] ?? []
|
||||
expect(title).toBe('Orca refused this browser')
|
||||
expect(options?.description).toBe(refusal.message)
|
||||
expect(refusal.message).toContain('ORCA_ALLOW_INSECURE_WEB_PAIRING=1')
|
||||
expect(statusListener).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
announceWebHostUnreachableAtStartup,
|
||||
isWebHostUnreachableAtStartup
|
||||
} from './startup-web-host-unreachable'
|
||||
import {
|
||||
announceWebPairingRefusedAtStartup,
|
||||
isWebPairingRefusedAtStartup
|
||||
} from './startup-web-pairing-refused'
|
||||
import {
|
||||
collectTerminalProviderSnapshotPtyIds,
|
||||
refreshTerminalProviderSnapshotCapabilities
|
||||
@@ -94,6 +98,8 @@ export async function recoverFromDegradedStartup(args: DegradedStartupRecoveryAr
|
||||
if (hostUnreachable) {
|
||||
// Still no-save until the reload: the host's catalog never loaded, so saving now would prune it.
|
||||
announceWebHostUnreachableAtStartup(abortSignal)
|
||||
} else if (isWebPairingRefusedAtStartup(error)) {
|
||||
announceWebPairingRefusedAtStartup(error)
|
||||
} else {
|
||||
announceDegradedStartup()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isWebClient } from '../components/sidebar/worktree-card-model'
|
||||
import { isWebRuntimeUnauthorizedError } from '../web/web-runtime-client-error'
|
||||
|
||||
/**
|
||||
* The paired host refused this browser's credentials (plain-http page, revoked or downgraded key).
|
||||
* Nothing about the session failed, so the generic restore toast would hide the only actionable reason.
|
||||
*/
|
||||
export function isWebPairingRefusedAtStartup(error: unknown): error is Error {
|
||||
return isWebClient() && isWebRuntimeUnauthorizedError(error)
|
||||
}
|
||||
|
||||
export function announceWebPairingRefusedAtStartup(error: Error): void {
|
||||
toast.error(translate('auto.startup.webPairingRefused.title', 'Orca refused this browser'), {
|
||||
// The error already carries the reason-specific, translated guidance.
|
||||
description: error.message,
|
||||
duration: Infinity,
|
||||
dismissible: true,
|
||||
action: {
|
||||
label: translate('auto.startup.webPairingRefused.retry', 'Try again'),
|
||||
onClick: () => {
|
||||
void window.api.app.relaunch()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function webRuntimeCredentialFor(
|
||||
throw new Error('No runtime endpoint is stored for this web client.')
|
||||
}
|
||||
const support = readWebDeviceKeySupport()
|
||||
if (!support) {
|
||||
if (!support || support === 'token-insecure-origin') {
|
||||
return tokenOnlyCredentialSource(endpoint.deviceToken)
|
||||
}
|
||||
keyStore ??=
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('browser device keys', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the probe only checks that IndexedDB exists.
|
||||
const indexedDB = {} as IDBFactory
|
||||
expect(await detectWebDeviceKeySupport({ isSecureContext: false, crypto, indexedDB })).toBe(
|
||||
'session-insecure-origin'
|
||||
'token-insecure-origin'
|
||||
)
|
||||
expect(await detectWebDeviceKeySupport({ isSecureContext: true, crypto, indexedDB })).toBe(
|
||||
'persistent'
|
||||
|
||||
@@ -3,10 +3,14 @@ import type { WebDeviceKeyRecord, WebDeviceKeyStore } from './web-device-key-sto
|
||||
|
||||
/**
|
||||
* - persistent: a non-extractable Ed25519 key survives reloads.
|
||||
* - session-insecure-origin: plain http on another machine; nothing secret is persisted.
|
||||
* - token-insecure-origin: plain http on another machine (no WebCrypto keys there); the pairing
|
||||
* code stays in local storage and signs in, so reloads, new tabs and bookmarks keep working.
|
||||
* - session-no-ed25519: a secure page whose browser cannot make Ed25519 keys.
|
||||
*/
|
||||
export type WebDeviceKeySupport = 'persistent' | 'session-insecure-origin' | 'session-no-ed25519'
|
||||
export type WebDeviceKeySupport = 'persistent' | 'token-insecure-origin' | 'session-no-ed25519'
|
||||
|
||||
/** Supports that sign in with a device key rather than the stored pairing code. */
|
||||
export type KeyedWebDeviceKeySupport = Exclude<WebDeviceKeySupport, 'token-insecure-origin'>
|
||||
|
||||
export type WebDeviceSigningKey = {
|
||||
publicKeyB64: string
|
||||
@@ -21,7 +25,7 @@ export async function detectWebDeviceKeySupport(scope: {
|
||||
indexedDB?: IDBFactory
|
||||
}): Promise<WebDeviceKeySupport> {
|
||||
if (!scope.isSecureContext) {
|
||||
return 'session-insecure-origin'
|
||||
return 'token-insecure-origin'
|
||||
}
|
||||
const subtle = scope.crypto?.subtle
|
||||
if (!subtle || !scope.indexedDB) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PAIRING_OFFER_VERSION } from '../../../shared/mobile-relay-pairing-offer'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
|
||||
const offer = {
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint: 'ws://100.108.226.109:6768',
|
||||
deviceToken: 'pairing-code',
|
||||
publicKeyB64: 'host-key'
|
||||
} as const
|
||||
|
||||
// A fresh module graph is what a page reload gives the web bundle: no in-memory pairing survives.
|
||||
async function loadWebClient() {
|
||||
vi.resetModules()
|
||||
const support = await import('./web-device-key-support')
|
||||
support.setWebDeviceKeySupportForTests('token-insecure-origin')
|
||||
return {
|
||||
environment: await import('./web-runtime-environment'),
|
||||
credentials: await import('./preload-api/web-runtime-credentials'),
|
||||
guidance: await import('./web-pairing-security-guidance')
|
||||
}
|
||||
}
|
||||
|
||||
describe('a plain-http page on another machine (LAN or Tailscale)', () => {
|
||||
beforeEach(() => {
|
||||
const items = new Map<string, string>()
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
getItem: (key: string) => items.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => items.set(key, value),
|
||||
removeItem: (key: string) => items.delete(key)
|
||||
}
|
||||
})
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('stays paired across a reload and signs in with the stored pairing code', async () => {
|
||||
const before = await loadWebClient()
|
||||
const paired = before.environment.createStoredWebRuntimeEnvironment({ name: 'Desk', offer })
|
||||
expect(paired.sessionOnly).toBeUndefined()
|
||||
before.environment.saveStoredWebRuntimeEnvironment(paired)
|
||||
|
||||
const after = await loadWebClient()
|
||||
const restored = after.environment.readStoredWebRuntimeEnvironment()
|
||||
expect(restored).toMatchObject({ id: paired.id, endpoints: [{ deviceToken: 'pairing-code' }] })
|
||||
const source = after.credentials.webRuntimeCredentialFor(restored!, {
|
||||
forgetStoredToken: vi.fn(),
|
||||
forgetPairing: vi.fn()
|
||||
})
|
||||
expect(source.nextPlan(true)).toEqual({ kind: 'legacy-token', deviceToken: 'pairing-code' })
|
||||
})
|
||||
|
||||
it('shows no pairing-security notice', async () => {
|
||||
const { environment, guidance } = await loadWebClient()
|
||||
const paired = environment.createStoredWebRuntimeEnvironment({ name: 'Desk', offer })
|
||||
expect(guidance.webPairingSecurityGuidance('token-insecure-origin', paired)).toBeNull()
|
||||
expect(guidance.webSessionPairingGuidance('token-insecure-origin')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -3,46 +3,40 @@ import type { WebDeviceKeySupport } from './web-device-key'
|
||||
import type { StoredWebRuntimeEnvironment } from './web-runtime-environment'
|
||||
import { MIGRATED_DEVICE_TOKEN_PREFIX } from './web-runtime-credential-source'
|
||||
|
||||
// Why plain-http pages get none: they keep a reusable pairing code exactly as before device keys,
|
||||
// which is the expected setup for LAN and Tailscale access, so there is nothing to act on.
|
||||
function needsGuidance(support: WebDeviceKeySupport | null): support is 'session-no-ed25519' {
|
||||
return support === 'session-no-ed25519'
|
||||
}
|
||||
|
||||
/** What to tell the user about how this browser holds its pairing; null when nothing is at risk. */
|
||||
export function webPairingSecurityGuidance(
|
||||
support: WebDeviceKeySupport | null,
|
||||
environment: StoredWebRuntimeEnvironment | null
|
||||
): string | null {
|
||||
if (!support || support === 'persistent' || !environment) {
|
||||
if (!needsGuidance(support) || !environment) {
|
||||
return null
|
||||
}
|
||||
if (environment.sessionOnly) {
|
||||
return webSessionPairingGuidance(support)
|
||||
}
|
||||
const storesCode = environment.endpoints.some(
|
||||
(endpoint) => !endpoint.deviceToken.startsWith(MIGRATED_DEVICE_TOKEN_PREFIX)
|
||||
)
|
||||
if (environment.sessionOnly) {
|
||||
return webSessionPairingGuidance(support)
|
||||
}
|
||||
if (!storesCode) {
|
||||
return null
|
||||
}
|
||||
return support === 'session-insecure-origin'
|
||||
return storesCode
|
||||
? translate(
|
||||
'auto.web.pairingSecurity.storedCodeInsecureOrigin',
|
||||
'This browser keeps a reusable pairing code, because this page is plain http on another machine. Anyone who can read this browser’s storage could use it. For a safer pairing, open Orca over https, at localhost on this computer, or through an SSH tunnel, and pair again.'
|
||||
)
|
||||
: translate(
|
||||
'auto.web.pairingSecurity.storedCodeNoEd25519',
|
||||
'This browser keeps a reusable pairing code, because it cannot create secure sign-in keys (Ed25519). Use a current browser and pair again for a safer pairing.'
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
/** Shown before and after pairing when this browser can only pair for the current tab. */
|
||||
export function webSessionPairingGuidance(support: WebDeviceKeySupport | null): string | null {
|
||||
if (!support || support === 'persistent') {
|
||||
return null
|
||||
}
|
||||
return support === 'session-insecure-origin'
|
||||
return needsGuidance(support)
|
||||
? translate(
|
||||
'auto.web.pairingSecurity.sessionInsecureOrigin',
|
||||
'This pairing lasts only while this tab is open, because this page is plain http on another machine. For a pairing this browser remembers, open Orca over https, at localhost on this computer, or through an SSH tunnel.'
|
||||
)
|
||||
: translate(
|
||||
'auto.web.pairingSecurity.sessionNoEd25519',
|
||||
'This pairing lasts only while this tab is open, because this browser cannot create secure sign-in keys (Ed25519). Use a current browser for a pairing it remembers.'
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
createSessionDeviceSigningKey,
|
||||
getOrCreatePendingWebDeviceKey,
|
||||
webDeviceSigningKey,
|
||||
type WebDeviceKeySupport,
|
||||
type KeyedWebDeviceKeySupport,
|
||||
type WebDeviceSigningKey
|
||||
} from './web-device-key'
|
||||
import { webDeviceKeyId, type WebDeviceKeyStore } from './web-device-key-store'
|
||||
@@ -62,7 +62,7 @@ export type WebRuntimeCredentialEnvironment = {
|
||||
|
||||
export function createWebRuntimeCredentialSource(args: {
|
||||
environment: WebRuntimeCredentialEnvironment
|
||||
support: WebDeviceKeySupport
|
||||
support: KeyedWebDeviceKeySupport
|
||||
store: WebDeviceKeyStore
|
||||
subtle: SubtleCrypto | null
|
||||
/** Durably replace the stored token with a placeholder once the key is active. */
|
||||
|
||||
@@ -255,5 +255,5 @@ export function replaceStoredTokenWithDeviceKey(
|
||||
|
||||
function isSessionOnlyBrowser(): boolean {
|
||||
const support = readWebDeviceKeySupport()
|
||||
return support !== null && support !== 'persistent'
|
||||
return support === 'session-no-ed25519'
|
||||
}
|
||||
|
||||
@@ -1,47 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasWebClientUpgradeMarker,
|
||||
isInsecureRemoteWebOrigin,
|
||||
withWebClientUpgradeMarker
|
||||
} from './web-origin-security'
|
||||
|
||||
describe('isInsecureRemoteWebOrigin', () => {
|
||||
it('flags plain http pages served to other machines', () => {
|
||||
expect(isInsecureRemoteWebOrigin('http://192.168.1.20:6768')).toBe(true)
|
||||
expect(isInsecureRemoteWebOrigin('http://desk.local')).toBe(true)
|
||||
expect(isInsecureRemoteWebOrigin('http://[fe80::1]:6768')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats https and loopback pages as secure', () => {
|
||||
expect(isInsecureRemoteWebOrigin('https://desk.example.com')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('http://localhost:6768')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('http://app.localhost')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('http://127.0.0.1:6768')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('http://127.8.0.1')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('http://[::1]:6768')).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves non-browser and unparseable origins to token auth', () => {
|
||||
expect(isInsecureRemoteWebOrigin(undefined)).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('null')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('file://')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('not a url')).toBe(false)
|
||||
})
|
||||
})
|
||||
import { withWebClientUpgradeMarker } from './web-origin-security'
|
||||
|
||||
describe('web client upgrade marker', () => {
|
||||
it('round-trips through the endpoint the web bundle dials', () => {
|
||||
const marked = withWebClientUpgradeMarker('ws://192.168.1.20:6768')
|
||||
expect(marked).toBe('ws://192.168.1.20:6768/?orca-client=web')
|
||||
expect(hasWebClientUpgradeMarker(new URL(marked).pathname + new URL(marked).search)).toBe(true)
|
||||
it('adds itself to the endpoint the web bundle dials', () => {
|
||||
expect(withWebClientUpgradeMarker('ws://192.168.1.20:6768')).toBe(
|
||||
'ws://192.168.1.20:6768/?orca-client=web'
|
||||
)
|
||||
expect(withWebClientUpgradeMarker('wss://desk.example.com/orca?x=1')).toBe(
|
||||
'wss://desk.example.com/orca?x=1&orca-client=web'
|
||||
)
|
||||
})
|
||||
|
||||
it('is absent for phones, CLIs and other paths', () => {
|
||||
expect(hasWebClientUpgradeMarker('/')).toBe(false)
|
||||
expect(hasWebClientUpgradeMarker(undefined)).toBe(false)
|
||||
expect(hasWebClientUpgradeMarker('/?orca-client=phone')).toBe(false)
|
||||
it('leaves an unparseable endpoint alone', () => {
|
||||
expect(withWebClientUpgradeMarker('not a url')).toBe('not a url')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +1,10 @@
|
||||
// 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. */
|
||||
// Why kept: hosts from v1.4.225 to v1.4.228 refuse a first pairing from a plain-http page on another
|
||||
// machine with this sealed code, and the browser still explains it when paired to one.
|
||||
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.
|
||||
// cannot say "browser". Only the web bundle adds this query to its upgrade URL; hosts ignore the
|
||||
// query string, so the marked upgrade connects to every host.
|
||||
export const WEB_CLIENT_UPGRADE_PARAM = 'orca-client' as const
|
||||
export const WEB_CLIENT_UPGRADE_VALUE = 'web' as const
|
||||
|
||||
@@ -44,18 +17,3 @@ export function withWebClientUpgradeMarker(endpoint: string): string {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@ import {
|
||||
} from './release-checkout'
|
||||
|
||||
/**
|
||||
* The updated web client marks its WebSocket upgrade with `?orca-client=web` so a host can tell
|
||||
* it from a phone (React Native also sends an http Origin). A released host must still accept the
|
||||
* marked upgrade, and this host must record the marker only when it is present.
|
||||
* The web client marks its WebSocket upgrade with `?orca-client=web`. Released hosts and this host
|
||||
* must both accept the marked upgrade from a plain-http LAN page, as they accept a phone's.
|
||||
*/
|
||||
const SUITE_TIMEOUT_MS = 180_000
|
||||
const LAN_PAGE = 'http://192.168.1.20:6768'
|
||||
@@ -69,7 +68,7 @@ describe('cross-version: web client upgrade marker', () => {
|
||||
await expect(dial(transport, true)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('this host records the marker for the web bundle only, never for a phone', async () => {
|
||||
it('this host accepts the marked upgrade and an unmarked one from a LAN page', async () => {
|
||||
const transport = new WebSocketTransport({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
@@ -77,9 +76,7 @@ describe('cross-version: web client upgrade marker', () => {
|
||||
})
|
||||
await transport.start()
|
||||
running.push(transport)
|
||||
const web = await dial(transport, true)
|
||||
expect(transport.webPageOf(web)).toEqual({ webOrigin: LAN_PAGE, webClient: true })
|
||||
const phone = await dial(transport, false)
|
||||
expect(transport.webPageOf(phone)).toBeUndefined()
|
||||
await expect(dial(transport, true)).resolves.toBeDefined()
|
||||
await expect(dial(transport, false)).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user