Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion source/cydo/web/transport.d
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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_;
Expand All @@ -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)
Expand Down Expand Up @@ -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_))
{
Expand Down
67 changes: 67 additions & 0 deletions tests/e2e/websocket-cookie-auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): Promise<number> {
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);
},
);