From c4c9b51294c653c29bf3852624dd0c534facbf0b Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:40:45 +0200 Subject: [PATCH] fix: force classic auth for macOS VNC (#1313) --- .../hosts/guacamole/macos-vnc-proxy.ts | 41 ++++++++++++++--- .../hosts/guacamole/macos-vnc-proxy.test.ts | 45 ++++++++++++++++--- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/src/backend/hosts/guacamole/macos-vnc-proxy.ts b/src/backend/hosts/guacamole/macos-vnc-proxy.ts index 215b2989..b3c420a8 100644 --- a/src/backend/hosts/guacamole/macos-vnc-proxy.ts +++ b/src/backend/hosts/guacamole/macos-vnc-proxy.ts @@ -1,6 +1,7 @@ import net from "net"; const RFB_BANNER_LENGTH = 12; +const VNC_AUTH_SECURITY_TYPE = 2; const MACOS_RFB_BANNER = Buffer.from("RFB 003.889\n", "ascii"); const STANDARD_RFB_BANNER = Buffer.from("RFB 003.008\n", "ascii"); @@ -32,17 +33,47 @@ export async function createMacosVncCompatibilityProxy({ client.pipe(upstream); let pending = Buffer.alloc(0); + const forwardMacosSecurityTypes = (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]); + if (pending.length < 1) return; + + const securityTypeCount = pending[0]; + if (securityTypeCount === 0) { + upstream.off("data", forwardMacosSecurityTypes); + client.write(pending); + upstream.pipe(client); + return; + } + if (pending.length < 1 + securityTypeCount) return; + + upstream.off("data", forwardMacosSecurityTypes); + const securityTypes = pending.subarray(1, 1 + securityTypeCount); + if (securityTypes.includes(VNC_AUTH_SECURITY_TYPE)) { + client.write(Buffer.from([1, VNC_AUTH_SECURITY_TYPE])); + } else { + client.write(pending.subarray(0, 1 + securityTypeCount)); + } + client.write(pending.subarray(1 + securityTypeCount)); + upstream.pipe(client); + }; + const forwardServerBanner = (chunk: Buffer) => { pending = Buffer.concat([pending, chunk]); if (pending.length < RFB_BANNER_LENGTH) return; upstream.off("data", forwardServerBanner); const banner = pending.subarray(0, RFB_BANNER_LENGTH); - client.write( - banner.equals(MACOS_RFB_BANNER) ? STANDARD_RFB_BANNER : banner, - ); - client.write(pending.subarray(RFB_BANNER_LENGTH)); - upstream.pipe(client); + const remainder = pending.subarray(RFB_BANNER_LENGTH); + if (banner.equals(MACOS_RFB_BANNER)) { + client.write(STANDARD_RFB_BANNER); + pending = Buffer.alloc(0); + upstream.on("data", forwardMacosSecurityTypes); + if (remainder.length > 0) forwardMacosSecurityTypes(remainder); + } else { + client.write(banner); + client.write(remainder); + upstream.pipe(client); + } }; upstream.on("data", forwardServerBanner); }); diff --git a/src/backend/tests/hosts/guacamole/macos-vnc-proxy.test.ts b/src/backend/tests/hosts/guacamole/macos-vnc-proxy.test.ts index b9e3f493..3d1f574c 100644 --- a/src/backend/tests/hosts/guacamole/macos-vnc-proxy.test.ts +++ b/src/backend/tests/hosts/guacamole/macos-vnc-proxy.test.ts @@ -32,14 +32,20 @@ async function read(socket: net.Socket, length: number): Promise { } describe("createMacosVncCompatibilityProxy", () => { - it("normalizes Apple's private RFB banner and preserves later traffic", async () => { + it("normalizes Apple's private RFB banner and forces classic VNC auth", async () => { let clientBanner = ""; + let selectedSecurityType = 0; const target = net.createServer((socket) => { socket.write("RFB 003."); socket.write("889\n"); socket.once("data", (data) => { clientBanner = data.toString("ascii"); - socket.write("security-types"); + socket.write(Buffer.from([5, 30, 33])); + socket.write(Buffer.from([36, 2, 35])); + socket.once("data", (selection) => { + selectedSecurityType = selection[0]; + socket.write("desktop-data"); + }); }); }); const targetPort = await listen(target); @@ -58,12 +64,18 @@ describe("createMacosVncCompatibilityProxy", () => { "RFB 003.008\n", ); client.write("RFB 003.008\n"); - expect((await read(client, 14)).toString("ascii")).toBe("security-types"); + expect([...(await read(client, 2))]).toEqual([1, 2]); + client.write(Buffer.from([2])); + expect((await read(client, 12)).toString("ascii")).toBe("desktop-data"); expect(clientBanner).toBe("RFB 003.008\n"); + expect(selectedSecurityType).toBe(2); }); - it("passes standard RFB banners through unchanged", async () => { - const target = net.createServer((socket) => socket.write("RFB 003.008\n")); + it("passes standard RFB negotiation through unchanged", async () => { + const target = net.createServer((socket) => { + socket.write("RFB 003.008\n"); + socket.once("data", () => socket.write(Buffer.from([2, 30, 2]))); + }); const targetPort = await listen(target); closers.push(() => target.close()); const proxy = await createMacosVncCompatibilityProxy({ @@ -76,5 +88,28 @@ describe("createMacosVncCompatibilityProxy", () => { const client = net.createConnection(proxy.port, "127.0.0.1"); closers.push(() => client.destroy()); expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n"); + client.write("RFB 003.008\n"); + expect([...(await read(client, 3))]).toEqual([2, 30, 2]); + }); + + it("passes Apple's security types through when VNC auth is unavailable", async () => { + const target = net.createServer((socket) => { + socket.write("RFB 003.889\n"); + socket.once("data", () => socket.write(Buffer.from([2, 30, 33]))); + }); + const targetPort = await listen(target); + closers.push(() => target.close()); + const proxy = await createMacosVncCompatibilityProxy({ + targetHost: "127.0.0.1", + targetPort, + bindHost: "127.0.0.1", + }); + closers.push(proxy.close); + + const client = net.createConnection(proxy.port, "127.0.0.1"); + closers.push(() => client.destroy()); + expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n"); + client.write("RFB 003.008\n"); + expect([...(await read(client, 3))]).toEqual([2, 30, 33]); }); });