From af8ddb73bdf41a7df46c252568b30fb0dcdc55cf Mon Sep 17 00:00:00 2001 From: brainage04 Date: Wed, 22 Jul 2026 14:42:39 +1000 Subject: [PATCH] fix(host-service): authorize remote heartbeat pings --- packages/host-service/src/remote/policy.ts | 1 + packages/host-service/test/policy.test.ts | 28 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/host-service/src/remote/policy.ts b/packages/host-service/src/remote/policy.ts index 58d932cd..3881d7a8 100644 --- a/packages/host-service/src/remote/policy.ts +++ b/packages/host-service/src/remote/policy.ts @@ -472,6 +472,7 @@ export class TailscaleRemotePolicy implements RemoteConnectionPolicy { authorize(connection: RemoteConnection, frame: ClientFrame, context: RemoteAuthorizationContext): boolean { const state = this.#states.get(connection.connectionId); if (!state) return false; + if (frame.type === "ping") return true; const principal = this.#livePrincipal(state); if (!principal || state.justPaired) return false; const feature = frameFeature(frame); diff --git a/packages/host-service/test/policy.test.ts b/packages/host-service/test/policy.test.ts index 43baedc9..11ca6590 100644 --- a/packages/host-service/test/policy.test.ts +++ b/packages/host-service/test/policy.test.ts @@ -129,6 +129,34 @@ test("authenticated capability omission uses the explicit default, while empty i policy.close(); }); +test("heartbeat ping stays authorized while pairing and after authentication", () => { + const registry = new Registry(); + const policy = new TailscaleRemotePolicy({ registry }); + const heartbeat = { + v: "omp-app/1", + type: "ping", + nonce: "heartbeat", + timestamp: "2026-07-22T00:00:00.000Z", + } as ClientFrame; + const pairing = connection("pairing", { count: 0 }); + const pairingContext = { connectionId: pairing.connectionId, peer: pairing.peer }; + expect(policy.authorize(pairing, heartbeat, pairingContext)).toBe(false); + expect( + policy.authenticate(pairing, { ...hello("pairing"), authentication: undefined } as HelloFrame).authentication, + ).toBe("pairing-required"); + expect(policy.authorize(pairing, heartbeat, pairingContext)).toBe(true); + + const paired = connection("paired", { count: 0 }); + expect(policy.authenticate(paired, hello("paired")).authentication).toBe("paired"); + expect( + policy.authorize(paired, { ...heartbeat, nonce: "paired-heartbeat" } as ClientFrame, { + connectionId: paired.connectionId, + peer: paired.peer, + }), + ).toBe(true); + policy.close(); +}); + test("controller lease feature gates interception and replay is idempotent", () => { const registry = new Registry(); const policy = new TailscaleRemotePolicy({ registry, supportedFeatures: ["controller.lease"] });