mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
fix(hosts): validate safe targets and verify encrypted relay routes
This commit is contained in:
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user