From 488f1169e5fcdd45b720a98e222fcac2115f7dc5 Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:27:13 -0700 Subject: [PATCH] fix(server): authenticate websocket upgrades via session cookie WebKit on old iOS Safari never attaches the basic-auth Authorization header to WebSocket upgrade requests, so /ws got a 401 on every attempt and the UI hung at "Connecting" forever. Generate a random token from /dev/urandom when the transport's auth credentials are configured, hand it to clients as an HttpOnly session cookie on the first authenticated page response, and accept that cookie in checkAuth as an alternative to basic auth. Browsers do attach cookies to WebSocket handshakes, so the upgrade authenticates after one authenticated page load. No frontend changes needed. The spec drives an authenticated page load, then replays the handshake condition directly: an upgrade carrying only the session cookie is accepted, and one carrying nothing stays rejected. --- source/cydo/web/transport.d | 30 ++++++++++- tests/e2e/websocket-cookie-auth.spec.ts | 67 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/websocket-cookie-auth.spec.ts diff --git a/source/cydo/web/transport.d b/source/cydo/web/transport.d index e1c6ddb8..cca84b02 100644 --- a/source/cydo/web/transport.d +++ b/source/cydo/web/transport.d @@ -2,7 +2,7 @@ module cydo.web.transport; import std.conv : ConvException, to; import std.file : exists, isFile, remove; -import std.logger : infof, warningf; +import std.logger : fatalf, infof, warningf; import std.path : buildPath; import ae.net.asockets : DisconnectType, onNextTick, socketManager; @@ -78,6 +78,10 @@ class TransportAdapter private string webDistDir_; private string authUser_; private string authPass_; + // session cookie token, regenerated whenever auth credentials are set; old + // iOS Safari never sends the Authorization header on WebSocket upgrades, so + // /ws authenticates via this cookie instead + private string authCookieToken_; private WebSocketCallbacks websocketCallbacks_; private RawSourceLookupResult delegate(int tid, size_t seq) rawSourceLookup_; private McpCallbacks mcpCallbacks_; @@ -99,6 +103,16 @@ class TransportAdapter { authUser_ = user; authPass_ = pass; + if (authEnabled) + { + import std.file : read; + import std.format : format; + + auto entropy = cast(ubyte[]) read("/dev/urandom", 32); + if (entropy.length != 32) + fatalf("Short read from /dev/urandom (%d bytes)", entropy.length); + authCookieToken_ = format("%(%02x%)", entropy); + } } void startHttpServer(string sslCert, string sslKey) @@ -208,13 +222,27 @@ class TransportAdapter "object-src 'none'; " ~ "base-uri 'self'; " ~ "frame-ancestors 'none'"; + // hand a session cookie to clients that authenticated via basic auth; it + // rides along on WebSocket upgrades where old Safari omits the + // Authorization header + if (authCookieToken_.length > 0 && !hasValidAuthCookie(request)) + response.headers["Set-Cookie"] = + "cydo_auth=" ~ authCookieToken_ ~ "; Path=/; HttpOnly; SameSite=Strict"; conn.sendResponse(response); } + private bool hasValidAuthCookie(HttpRequest request) + { + return authCookieToken_.length > 0 + && request.getCookies().get("cydo_auth", null) == authCookieToken_; + } + private bool checkAuth(HttpRequest request, HttpServerConnection conn) { if (!authEnabled) return true; + if (hasValidAuthCookie(request)) + return true; auto response = new HttpResponseEx(); if (!response.authorize(request, (reqUser, reqPass) => reqUser == authUser_ && reqPass == authPass_)) { diff --git a/tests/e2e/websocket-cookie-auth.spec.ts b/tests/e2e/websocket-cookie-auth.spec.ts new file mode 100644 index 00000000..c4f7f52d --- /dev/null +++ b/tests/e2e/websocket-cookie-auth.spec.ts @@ -0,0 +1,67 @@ +import { request as httpRequest } from "http"; +import { test, expect } from "./fixtures"; + +test.use({ + backendEnv: { + CYDO_AUTH_USER: "user", + CYDO_AUTH_PASS: "test-pass", + }, + httpCredentials: { + username: "user", + password: "test-pass", + }, +}); + +/** Attempt a bare WebSocket upgrade handshake with exactly the given extra + * headers, resolving with the HTTP status the server answered. */ +function upgradeStatus(headers: Record): Promise { + return new Promise((resolve, reject) => { + const req = httpRequest({ + host: "localhost", + port: 3940, + path: "/ws", + headers: { + Connection: "Upgrade", + Upgrade: "websocket", + "Sec-WebSocket-Version": "13", + "Sec-WebSocket-Key": "AAAAAAAAAAAAAAAAAAAAAA==", + ...headers, + }, + }); + req.on("upgrade", (res, socket) => { + socket.destroy(); + resolve(res.statusCode ?? 0); + }); + req.on("response", (res) => { + res.destroy(); + resolve(res.statusCode ?? 0); + }); + req.on("error", reject); + req.end(); + }); +} + +test( + "websocket upgrade authenticates via the session cookie alone", + { tag: "@claude-only" }, + async ({ page }) => { + await page.goto("/"); + await expect(page.locator('button[title="New task"]').first()).toBeVisible({ + timeout: 15_000, + }); + + const cookies = await page.context().cookies(); + const auth = cookies.find((cookie) => cookie.name === "cydo_auth"); + expect(auth).toBeTruthy(); + + // WebKit on old iOS Safari omits the Authorization header on upgrade + // requests, so the cookie must carry the handshake entirely on its own. + expect(await upgradeStatus({ Cookie: `cydo_auth=${auth!.value}` })).toBe( + 101, + ); + + // And the cookie is load-bearing: an upgrade with no credentials at all + // stays rejected. + expect(await upgradeStatus({})).toBe(401); + }, +);