fix(server): require the daemon password on relay connections (#7668)

* fix(server): require the daemon password on relay connections

A password-protected daemon still admitted relay clients that sent no
credential in their encrypted hello, so a leaked pairing link alone gave
full control. Remove the COMPAT(relayPasswordOptional) shim: relay clients
now need the password like direct ones.

Fixes #4087

* test(server): cover relay password admission end to end

Run a password-protected daemon behind a local relay and connect with
the real host client over relay://: no password and a wrong password are
refused, the right password is admitted and serves RPCs.

* test(server): run relay password regression in CI

* test(ci): include relay password regression in command expectation

---------

Co-authored-by: Nicholas Maglowsch Salgueiro <21068323+nickmaglowsch@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Nicholas Maglowsch Salgueiro
2026-10-23 13:15:14 +07:00
committed by GitHub
co-authored by Nicholas Maglowsch Salgueiro Mohamed Boudra
parent 99a1d9a7a2
commit 89727f43bc
10 changed files with 65 additions and 29 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ The daemon requires a valid cryptographic handshake before processing any comman
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
When a daemon password is configured, relay clients send it in the encrypted `hello` message, so the relay never sees it. The daemon rejects a relay client that sends a wrong password or none, so a leaked pairing link alone does not grant access to a password-protected daemon.
## Local daemon trust boundary
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
-12
View File
@@ -134,7 +134,6 @@ describe("hello admission", () => {
credential: { kind: "localCredential", token: "stale" },
passwordHash: undefined,
localCredential: "current",
transport: "direct",
}),
).toMatchObject({ admission: { principalId: "owner" } });
});
@@ -146,7 +145,6 @@ describe("hello admission", () => {
const input = {
passwordHash: CORRECT_PASSWORD_HASH,
localCredential: second,
transport: "direct" as const,
};
expect(
await resolveSessionAdmission({
@@ -172,19 +170,9 @@ describe("hello admission", () => {
credential: { kind: "password", password: "wrong" },
}),
).toEqual({ rejection: "incorrect_password" });
expect(
await resolveSessionAdmission({
...input,
transport: "relay",
credential: { kind: "password", password: "wrong" },
}),
).toEqual({ rejection: "incorrect_password" });
expect(await resolveSessionAdmission({ ...input, credential: undefined })).toEqual({
rejection: "password_required",
});
expect(
await resolveSessionAdmission({ ...input, transport: "relay", credential: undefined }),
).toMatchObject({ admission: { principalId: "owner" } });
} finally {
await deleteLocalCredential(home);
await rm(home, { recursive: true, force: true });
@@ -7,7 +7,10 @@ import {
encodeTerminalStreamFrame,
TerminalStreamOpcode,
} from "@getpaseo/protocol/binary-frames/index";
import type { DaemonClient as HostClient } from "@getpaseo/client/internal/daemon-client";
import {
getDaemonAuthFailureReason,
type DaemonClient as HostClient,
} from "@getpaseo/client/internal/daemon-client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { describe, expect, test } from "vitest";
import { withTimeout } from "../../utils/promise-timeout.js";
@@ -540,6 +543,56 @@ async function waitForCapturedLog(
120000,
);
test("password-protected daemon refuses a relay client without the password", async () => {
let daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
const clients: HostClient[] = [];
try {
await startRelay({ useLocalRelay: true });
daemon = await createTestPaseoDaemon({
relayEnabled: true,
relayEndpoint: `127.0.0.1:${relayPort}`,
relayUseTls: false,
relayPublicUseTls: false,
auth: { password: hashSync("relay-password", 4) },
});
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: true,
relayEndpoint: daemon.config.relayEndpoint,
});
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
const connectOverRelay = (password?: string): Promise<void> => {
const connection = new URL(`relay://127.0.0.1:${relayPort}/${serverId}`);
connection.searchParams.set("key", daemonPublicKeyB64);
if (password) connection.searchParams.set("password", password);
const client = createHostClient(connection.toString(), {
clientId: crypto.randomUUID(),
reconnect: { enabled: false },
});
clients.push(client);
return client.connect();
};
// The pairing offer alone must not grant access to a password-protected daemon.
const rejection = await connectOverRelay().then(
() => null,
(error: unknown) => error,
);
expect(getDaemonAuthFailureReason(rejection)).toBe("password_required");
await expect(connectOverRelay("wrong-password")).rejects.toThrow("Incorrect password");
await connectOverRelay("relay-password");
expect((await createPaseoApi(clients.at(-1)!).workspaces.list()).entries).toEqual([]);
} finally {
try {
await Promise.all(clients.map((client) => client.close()));
await daemon?.close();
} finally {
await stopRelay();
}
}
}, 90000);
test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => {
await startRelay({ useLocalRelay: true });
const daemon = await createTestPaseoDaemon({
@@ -12,17 +12,12 @@ export async function resolveSessionAdmission(input: {
credential: WSHelloMessage["auth"];
passwordHash: string | undefined;
localCredential: string | null;
transport: "direct" | "relay";
}): Promise<AdmissionResolution> {
const { credential, passwordHash, localCredential, transport } = input;
const { credential, passwordHash, localCredential } = input;
if (!passwordHash) {
return { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } };
}
if (!credential) {
// COMPAT(relayPasswordOptional): added in v0.9.1, remove once release N mobile builds are live on App Store and Play.
if (transport === "relay") {
return { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } };
}
return { rejection: "password_required" };
}
if (credential.kind === "localCredential") {
@@ -541,7 +541,7 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
}
});
test("admits a hello password and an old relay hello, but rejects a wrong password", async () => {
test("admits a relay hello password, but rejects a wrong or missing password", async () => {
const server = createServer({
auth: {
password: "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW",
@@ -563,7 +563,8 @@ describe("relay external socket reconnect behavior", () => {
const legacySocket = new MockSocket();
await server.attachExternalSocket(legacySocket, { transport: "relay" });
legacySocket.emit("message", JSON.stringify(createHelloMessage("relay-legacy")));
await vi.waitFor(() => expect(sentServerInfoEnvelopes(legacySocket)).toHaveLength(1));
await vi.waitFor(() => expect(legacySocket.readyState).toBe(3));
expect(sentServerInfoEnvelopes(legacySocket)).toHaveLength(0);
const wrongSocket = new MockSocket();
await server.attachExternalSocket(wrongSocket, { transport: "relay" });
@@ -1816,7 +1816,6 @@ export class VoiceAssistantWebSocketServer {
credential: message.auth,
passwordHash: this.passwordHash,
localCredential: this.credentialSource?.localCredential?.() ?? null,
transport: pending.identity.transport === "relay" ? "relay" : "direct",
});
if (this.pendingConnections.get(ws) !== pending) return false;
if ("rejection" in resolved) {
+2 -2
View File
@@ -86,7 +86,7 @@ Configure via `daemon.hostnames` in `config.json`:
By default, anyone who can reach the daemon's listening address can connect. On localhost this is fine, only local processes have access. But if you bind to a network interface (e.g. your LAN IP or `0.0.0.0`), or if you don't fully trust your local network, you can require a password.
When a password is configured, all HTTP requests must include an `Authorization: Bearer <password>` header and all WebSocket connections must authenticate via subprotocol. Unauthenticated requests receive a `401 Unauthorized` response. Only the `/api/health` liveness endpoint is exempt, so that process supervisors and load balancers can probe without credentials.
When a password is configured, all HTTP requests must include an `Authorization: Bearer <password>` header, and every WebSocket client, including relay clients, must send the password when it connects. Relay clients send it inside the end-to-end encrypted channel, so the relay never sees it. Unauthenticated HTTP requests receive a `401 Unauthorized` response. Only the `/api/health` liveness endpoint is exempt, so that process supervisors and load balancers can probe without credentials.
If you enable the [bundled web UI](/docs/web-ui), its static files are also served without the password so the login screen can render. This is by design, the API and WebSocket still require authentication before any agent data is returned or any command runs. Set a password before binding the daemon to a network so the data behind the page stays protected.
@@ -152,7 +152,7 @@ Hub CLI credentials are bearer secrets. Remote Hub origins must use HTTPS; clear
## Recommendations
- **Use the relay** for mobile access, it's the simplest option and all traffic is end-to-end encrypted
- **Treat the QR code like a password**, anyone with the pairing offer can connect to your daemon
- **Treat the QR code like a password**, anyone with the pairing offer can connect to your daemon unless you also set a password
- **Set a password** if you bind to a network address, it prevents unauthorized clients from controlling your agents
- **Never bind to 0.0.0.0 without a password**, without one, any device on your network can connect
- **Scope Docker mounts tightly**, agents can access mounted workspaces and provider credentials
+2 -2
View File
@@ -83,7 +83,7 @@ Paseo 使用主机白名单校验传入请求的 `Host` 请求头。主机无法
默认情况下,任何能访问 daemon 监听地址的人都可以连接。在 localhost 上这没有问题,只有本地进程能访问。但如果你绑定到某个网络接口(例如你的局域网 IP 或 `0.0.0.0`),或者你不完全信任本地网络,可以要求提供密码。
配置密码后,所有 HTTP 请求都必须包含 `Authorization: Bearer <password>` 请求头,所有 WebSocket 连接都必须通过子协议进行验证。未经验证的请求会收到 `401 Unauthorized` 响应。只有 `/api/health` 存活检查端点例外,以便进程管理器和负载均衡器无需凭据即可探测。
配置密码后,所有 HTTP 请求都必须包含 `Authorization: Bearer <password>` 请求头,所有 WebSocket 客户端(包括中继客户端)都必须在连接时发送密码。中继客户端在端到端加密通道内发送密码,中继无法看到它。未经验证的 HTTP 请求会收到 `401 Unauthorized` 响应。只有 `/api/health` 存活检查端点例外,以便进程管理器和负载均衡器无需凭据即可探测。
如果你启用了[内置的 Web UI](/docs/web-ui),它的静态文件同样无需密码即可提供,以便显示登录界面。这是有意设计的:API 和 WebSocket 在返回任何 Agent 数据或执行任何命令之前仍然需要验证。在把 daemon 绑定到网络之前请设置密码,让页面背后的数据受到保护。
@@ -149,7 +149,7 @@ Hub CLI 凭据是 bearer 密钥。远程 Hub 地址必须使用 HTTPS;只有
## 建议 {#recommendations}
- **使用中继**进行移动端访问,这是最简单的选择,且所有流量都经过端到端加密
- **像对待密码一样对待二维码**,任何拿到配对信息的人都可以连接你的 daemon
- **像对待密码一样对待二维码**,除非你同时设置了密码,否则任何拿到配对信息的人都可以连接你的 daemon
- **设置密码**,如果你绑定到网络地址,它可以阻止未经授权的客户端控制你的 Agent
- **不要在没有密码的情况下绑定到 0.0.0.0**,否则你网络中的任何设备都可以连接
- **严格限定 Docker 挂载范围**,Agent 可以访问挂载的工作区和 Provider 凭据
+1 -1
View File
@@ -487,7 +487,7 @@ test("Windows units and integration run independently with complete routed cover
assert.doesNotMatch(invocations[0], / -t /);
assert.equal(
invocations[1],
'vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t "merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts"',
'vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t "merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses"',
);
const serverConfig = readFileSync(new URL("packages/server/vitest.config.ts", repoRoot), "utf8");
assert.doesNotMatch(serverConfig, /maxWorkers/);