fix(hosts): validate safe targets and verify encrypted relay routes

This commit is contained in:
Mohamed Boudra
2026-10-21 10:33:11 +02:00
parent 3b50636e14
commit 32fbcdc4a8
15 changed files with 412 additions and 57 deletions
+41 -1
View File
@@ -20,7 +20,7 @@ test("SSH daemon credentials are separate from SSH authentication and excluded f
kind: "ssh",
ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 },
password: "secret",
description: "ssh://deploy@build-box:2222 (daemon port 7777)",
description: "ssh://build-box:2222 (daemon port 7777)",
});
});
test.each([
@@ -31,6 +31,46 @@ test.each([
"unix://",
"tcp://host:0",
"C:\\Users\\socket",
"alice:example-secret@localhost:6767",
"not-a-scheme://localhost:6767",
"localhost/path:6767",
"localhost?password=example-secret:6767",
"localhost#example-secret:6767",
"tcp://localhost/path:6767",
"relay://alice:example-secret@localhost:6767/server?key=private",
])("invalid target syntax is rejected without echoing %s", (connection) => {
expect(() => parseHostTarget(connection)).toThrow("Invalid host connection");
});
test("SSH aliases and DNS names retain their connection semantics", () => {
expect(parseHostTarget("build-box.example.:6767").description).toBe(
"tcp://build-box.example.:6767",
);
expect(parseHostTarget("ssh://deploy@my_ssh-alias")).toMatchObject({
kind: "ssh",
ssh: { host: "deploy@my_ssh-alias" },
});
});
test("encoded SSH usernames cannot leak into the target description", () => {
expect(parseHostTarget("ssh://alice%3Aexample-secret@my-alias").description).toBe(
"ssh://my-alias (daemon port 6767)",
);
});
test("pairing offers cannot publish a credential-bearing or malformed relay authority", () => {
for (const endpoint of [
"alice:example-secret@localhost:6767",
"localhost/path:6767",
"localhost?token=example-secret:6767",
]) {
const offer = {
v: 2,
serverId: "srv_test",
daemonPublicKeyB64: "private-key",
relay: { endpoint, useTls: false },
};
const url = `https://app.paseo.sh/#offer=${Buffer.from(JSON.stringify(offer)).toString("base64url")}`;
expect(() => parseHostTarget(url)).toThrow("Invalid host connection");
}
});
+19 -5
View File
@@ -64,7 +64,7 @@ function parse(input: string): HostTarget {
}
if (input.startsWith("tcp://")) return tcpTarget(input);
if (/^wss?:\/\//.test(input)) return websocketTarget(input);
const endpoint = normalizeHostPort(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
return {
kind: "socket",
url: buildDaemonWebSocketUrl(endpoint, { useTls: false }),
@@ -72,23 +72,27 @@ function parse(input: string): HostTarget {
};
}
function relayTarget(offer: ConnectionOffer, password?: string): HostTarget {
const endpoint = validatedEndpoint(offer.relay.endpoint);
return {
kind: "socket",
url: buildRelayWebSocketUrl({
endpoint: offer.relay.endpoint,
endpoint,
serverId: offer.serverId,
role: "client",
useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
}),
offer,
password,
description: `relay://${offer.relay.endpoint}/${offer.serverId}`,
description: `relay://${endpoint}/${encodeURIComponent(offer.serverId)}`,
};
}
function tcpTarget(input: string): HostTarget {
const uri = new URL(input);
if (uri.pathname && uri.pathname !== "/") throw new Error("Unexpected TCP path");
if (uri.hash) throw new Error("Unexpected TCP fragment");
const parts = parseConnectionUri(input);
const endpoint = normalizeHostPort(
const endpoint = validatedEndpoint(
`${parts.isIpv6 ? `[${parts.host}]` : parts.host}:${parts.port}`,
);
return {
@@ -118,6 +122,16 @@ function sshTarget(input: string): HostTarget {
kind: "ssh",
ssh,
...(password ? { password } : {}),
description: `ssh://${ssh.host}${ssh.sshPort ? `:${ssh.sshPort}` : ""} (daemon port ${ssh.daemonPort})`,
description: `ssh://${uri.hostname}${ssh.sshPort ? `:${ssh.sshPort}` : ""} (daemon port ${ssh.daemonPort})`,
};
}
/** A bare endpoint has exactly one authority; URL syntax must use an explicit supported scheme. */
function validatedEndpoint(input: string): string {
if (!/^(?:[a-zA-Z0-9_][a-zA-Z0-9_.-]*|\[[0-9a-fA-F:.]+\]):[0-9]{1,5}$/.test(input))
throw new Error("Invalid endpoint");
// URL parsing validates IPv6 literals and rejects invalid authority/port encodings.
const url = new URL(`http://${input}`);
if (!url.hostname) throw new Error("Missing endpoint host");
return normalizeHostPort(input);
}
+34 -17
View File
@@ -902,8 +902,10 @@ export type RecentProviderSessionDescriptorPayload = z.infer<
typeof RecentProviderSessionDescriptorPayloadSchema
>;
// =====================================================================// Session Inbound Messages (Session receives these)
// =====================================================================
// ============================================================================
// Session Inbound Messages (Session receives these)
// ============================================================================
export const VoiceAudioChunkMessageSchema = z.object({
type: z.literal("voice_audio_chunk"),
audio: z.string(), // base64 encoded
@@ -1229,8 +1231,10 @@ export const SendAgentMessageSchema = z.object({
attachments: AgentAttachmentsSchema,
});
// =====================================================================// Agent RPCs (requestId-correlated)
// =====================================================================
// ============================================================================
// Agent RPCs (requestId-correlated)
// ============================================================================
const DirectorySyncRequestSchema = z.object({
generation: z.string().optional(),
afterSeq: z.number().int().nonnegative().optional(),
@@ -1654,8 +1658,10 @@ export const WriteProjectConfigRequestMessageSchema = z.object({
expectedRevision: PaseoConfigRevisionSchema.nullable(),
});
// =====================================================================// Dictation Streaming (lossless, resumable)
// =====================================================================
// ============================================================================
// Dictation Streaming (lossless, resumable)
// ============================================================================
export const DictationStreamStartMessageSchema = z.object({
type: z.literal("dictation_stream_start"),
dictationId: z.string(),
@@ -2952,8 +2958,10 @@ export const PushUnregisterResponseSchema = z.object({
}),
});
// =====================================================================// Terminal Messages
// =====================================================================
// ============================================================================
// Terminal Messages
// ============================================================================
export const ListTerminalsRequestSchema = z.object({
type: z.literal("list_terminals_request"),
cwd: z.string().optional(),
@@ -3436,8 +3444,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
// =====================================================================// Session Outbound Messages (Session emits these)
// =====================================================================
// ============================================================================
// Session Outbound Messages (Session emits these)
// ============================================================================
export const ActivityLogPayloadSchema = z.object({
subscriptionId: z.string().optional(),
id: z.string(),
@@ -3592,8 +3602,9 @@ export const ServerInfoStatusPayloadSchema = z
serverId: z.string().trim().min(1),
hostname: ServerInfoHostnameSchema.optional(),
version: ServerInfoVersionSchema.optional(),
// Authenticated producer for host registration ownership, separate from admission authority.
sourceId: z.string().optional(),
// COMPAT(sessionPermissions): optional while clients support older daemons.
principalId: z.string().optional(),
permissions: z.array(DaemonPermissionSchema).optional(),
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
desktopManaged: z.boolean().optional(),
@@ -6414,8 +6425,10 @@ export const ListCommandsResponseSchema = z.object({
}),
});
// =====================================================================// Terminal Outbound Messages
// =====================================================================
// ============================================================================
// Terminal Outbound Messages
// ============================================================================
const TerminalInfoSchema = z.object({
id: z.string(),
name: z.string(),
@@ -7580,8 +7593,10 @@ export type CaptureTerminalRequest = z.infer<typeof CaptureTerminalRequestSchema
export type CaptureTerminalResponse = z.infer<typeof CaptureTerminalResponseSchema>;
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
// =====================================================================// WebSocket Level Messages (wraps session messages)
// =====================================================================
// ============================================================================
// WebSocket Level Messages (wraps session messages)
// ============================================================================
// WebSocket-only messages (not session messages)
export const WSPingMessageSchema = z.object({
type: z.literal("ping"),
@@ -7671,8 +7686,10 @@ export type WSInboundMessage = z.infer<typeof WSInboundMessageSchema>;
export type WSOutboundMessage = z.infer<typeof WSOutboundMessageSchema>;
export type WSHelloMessage = z.infer<typeof WSHelloMessageSchema>;
// =====================================================================// Helper functions for message conversion
// =====================================================================
// ============================================================================
// Helper functions for message conversion
// ============================================================================
/**
* Extract session message from WebSocket message
* Returns null if message should be handled at WS level only