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); + }, +);