fix(mobile): stop pinning the Windows pairing firewall rule to one port (STA-7672)

buildRepairScript wrote the Allow rule with -LocalPort <port> 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 '<port>'), 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.
This commit is contained in:
Jinwoo-H
2026-09-16 14:50:21 -04:00
parent f02d09c1ba
commit 00a4f047af
2 changed files with 40 additions and 2 deletions
@@ -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
+7 -1
View File
@@ -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