From 00a4f047af56f3e889cf1818a00dc009fcf495f3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 16 Sep 2026 14:50:21 -0400 Subject: [PATCH] fix(mobile): stop pinning the Windows pairing firewall rule to one port (STA-7672) buildRepairScript wrote the Allow rule with -LocalPort for whichever port happened to be bound when the user ran repair, but the desktop's listen port is not stable. A persisted mobile-ws-fallback-port.json (preferred over the pinned port for STA-1511), a pairing widen rebind, or an OS-assigned port all move it, and nothing reconciled the rule with the new bind. The phone then knocked on a port the firewall did not cover and LAN/direct pairing failed with no diagnosis pointing at the firewall. The Allow rule is now written with -LocalPort Any. Inspection already accepted 'Any' (buildInspectionScript matches -eq 'Any' -or -eq ''), so the two halves can no longer desync. The rule stays scoped to this program, the Private profile, and -EdgeTraversalPolicy Block; the port still scopes which conflicting Block rules the repair removes. The port drift itself is cross-platform, but only Windows writes a per-port permission: macOS approves per app and Linux does not filter inbound LAN traffic by default. --- .../runtime/windows-mobile-firewall.test.ts | 34 ++++++++++++++++++- src/main/runtime/windows-mobile-firewall.ts | 8 ++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/windows-mobile-firewall.test.ts b/src/main/runtime/windows-mobile-firewall.test.ts index d561878a538..ab0e579d6cf 100644 --- a/src/main/runtime/windows-mobile-firewall.test.ts +++ b/src/main/runtime/windows-mobile-firewall.test.ts @@ -178,11 +178,43 @@ describe('windows mobile firewall', () => { expect(repairScript).toContain('$rule | Remove-NetFirewallRule') expect(repairScript).toContain('-Profile Private') expect(repairScript).toContain('-Protocol TCP') - expect(repairScript).toContain('-LocalPort 6769') expect(repairScript).toContain("-Program 'C:\\Users\\O''Brien\\Orca\\Orca.exe'") expect(repairScript).toContain('-EdgeTraversalPolicy Block') }) + it('writes a port-agnostic allow rule so a drifting bind port cannot outrun it (STA-7672)', async () => { + const runPowerShell = vi.fn().mockResolvedValue('{"launched":true,"exitCode":0}') + await repairWindowsMobileFirewall(6769, environment(runPowerShell)) + + const outerScript = runPowerShell.mock.calls[0]![0] as string + const encoded = outerScript.match(/'-EncodedCommand', '([^']+)'/)?.[1] + const repairScript = Buffer.from(encoded!, 'base64').toString('utf16le') + const newRule = repairScript.split('New-NetFirewallRule')[1]! + + // The desktop moves off 6768 on a persisted fallback, a pairing widen, or an + // OS-assigned port, and nothing reconciles a pinned rule with the new bind. + expect(newRule).toContain('-LocalPort Any') + expect(newRule).not.toContain('-LocalPort 6769') + // Still narrow where it matters: this program, private profile, no edge traversal. + expect(newRule).toContain('-Profile Private') + expect(newRule).toContain('-Protocol TCP') + expect(newRule).toContain('-EdgeTraversalPolicy Block') + // The port still scopes which conflicting Block rules the repair removes. + expect(repairScript.split('New-NetFirewallRule')[0]).toContain("-eq '6769'") + }) + + it('matches a port-agnostic rule whatever port the transport landed on', async () => { + // The repaired rule now carries LocalPort 'Any', so the inspection filter has + // to accept it for every bind the desktop can drift to (STA-7672). + for (const port of [6768, 6769, 51_234]) { + const runPowerShell = vi.fn().mockResolvedValue('{"privateFirewallEnabled":true}') + await inspectWindowsMobileFirewall(port, undefined, environment(runPowerShell)) + + const script = runPowerShell.mock.calls[0]![0] as string + expect(script).toContain(`[string]$_ -eq 'Any' -or [string]$_ -eq '${port}'`) + } + }) + it('keeps the elevated child encoded because Start-Process re-splits its ArgumentList', async () => { // Why: `Start-Process -ArgumentList` joins the array into one ShellExecuteEx parameter // string without quoting and PowerShell re-splits it on whitespace, which collapses runs diff --git a/src/main/runtime/windows-mobile-firewall.ts b/src/main/runtime/windows-mobile-firewall.ts index c90a025cb14..e32dc114613 100644 --- a/src/main/runtime/windows-mobile-firewall.ts +++ b/src/main/runtime/windows-mobile-firewall.ts @@ -214,6 +214,12 @@ function buildRepairScript(port: number, executablePath: string): string { // rules, so the user's repair action must remove exact-app conflicts first. // Removal deliberately ignores the Block rule's remote-address scope, // mirroring the fail-closed inspection (the phone address is unknown). + // `port` scopes only that removal. The Allow rule itself is written with + // `-LocalPort Any` because the desktop's listen port is not stable: a + // persisted fallback (mobile-ws-fallback-port.json), a pairing widen rebind, + // or an OS-assigned port all move it, and nothing reconciles a port-pinned + // rule with the new bind — which silently broke LAN pairing in STA-7672. The + // rule stays scoped to this program, Private profile, and no edge traversal. return `$ErrorActionPreference = 'Stop' $blockingRules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Block' }) foreach ($rule in $blockingRules) { @@ -226,7 +232,7 @@ foreach ($rule in $blockingRules) { } } Get-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -ErrorAction SilentlyContinue | Remove-NetFirewallRule -New-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -DisplayName ${quotePowerShell(FIREWALL_RULE_DISPLAY_NAME)} -Description 'Allows Orca Mobile to connect to this Orca desktop on private networks.' -Direction Inbound -Action Allow -Enabled True -Profile Private -Protocol TCP -LocalPort ${port} -Program ${quotePowerShell(executablePath)} -EdgeTraversalPolicy Block | Out-Null` +New-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -DisplayName ${quotePowerShell(FIREWALL_RULE_DISPLAY_NAME)} -Description 'Allows Orca Mobile to connect to this Orca desktop on private networks.' -Direction Inbound -Action Allow -Enabled True -Profile Private -Protocol TCP -LocalPort Any -Program ${quotePowerShell(executablePath)} -EdgeTraversalPolicy Block | Out-Null` } // Why the elevated child keeps `-EncodedCommand` while the local runner does not: `Start-Process