fix: harden machine federation boundaries

This commit is contained in:
Federico Jaramillo Martinez
2026-05-28 20:49:25 +02:00
parent e352dce6ef
commit 5e2afc1ffa
18 changed files with 623 additions and 106 deletions
+6 -54
View File
@@ -1,62 +1,12 @@
import type { FastifyInstance, FastifyReply, HTTPMethods } from "fastify";
import type { FastifyInstance, FastifyReply } from "fastify";
import type { WebSocket } from "ws";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
import { bridgeSockets } from "../webSocketBridge.js";
import { RemoteMachineRequestError } from "./machineClient.js";
import { MachineService } from "./machineService.js";
interface HttpRouteSpec {
method: HTTPMethods;
path: string;
}
const REMOTE_HTTP_ROUTES: HttpRouteSpec[] = [
{ method: "GET", path: "/projects" },
{ method: "POST", path: "/projects" },
{ method: "DELETE", path: "/projects/:projectId" },
{ method: "GET", path: "/project-directories" },
{ method: "GET", path: "/projects/:projectId/workspaces" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/tree" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/diff" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId" },
{ method: "GET", path: "/files" },
{ method: "GET", path: "/activity" },
{ method: "GET", path: "/sessions" },
{ method: "POST", path: "/sessions" },
{ method: "GET", path: "/sessions/:sessionId/messages" },
{ method: "GET", path: "/sessions/:sessionId/status" },
{ method: "GET", path: "/sessions/:sessionId/models" },
{ method: "POST", path: "/sessions/:sessionId/model" },
{ method: "POST", path: "/sessions/:sessionId/model/cycle" },
{ method: "GET", path: "/sessions/:sessionId/thinking-levels" },
{ method: "POST", path: "/sessions/:sessionId/thinking-level" },
{ method: "POST", path: "/sessions/:sessionId/thinking-level/cycle" },
{ method: "GET", path: "/sessions/:sessionId/commands" },
{ method: "POST", path: "/sessions/:sessionId/prompt" },
{ method: "POST", path: "/sessions/:sessionId/shell" },
{ method: "POST", path: "/sessions/:sessionId/commands/run" },
{ method: "POST", path: "/sessions/:sessionId/commands/respond" },
{ method: "POST", path: "/sessions/:sessionId/abort" },
{ method: "POST", path: "/sessions/:sessionId/stop" },
{ method: "POST", path: "/sessions/:sessionId/archive" },
{ method: "POST", path: "/sessions/:sessionId/archive-tree" },
{ method: "POST", path: "/sessions/:sessionId/restore" },
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
{ method: "GET", path: "/auth/providers" },
{ method: "POST", path: "/auth/api-key" },
{ method: "POST", path: "/auth/logout" },
];
const REMOTE_WEBSOCKET_ROUTES = [
"/events",
"/sessions/events",
"/sessions/:sessionId/events",
"/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket",
];
export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES;
export const REMOTE_WEBSOCKET_ROUTES = FEDERATED_WEBSOCKET_ROUTES;
const SAFE_RESPONSE_HEADERS = new Set([
"content-type",
@@ -64,6 +14,8 @@ const SAFE_RESPONSE_HEADERS = new Set([
"cache-control",
"last-modified",
"etag",
"content-security-policy",
"x-content-type-options",
]);
export function registerMachineProxyRoutes(app: FastifyInstance, machines = new MachineService()): void {
+27 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -35,6 +35,27 @@ describe("MachineService", () => {
const raw: unknown = JSON.parse(await readFile(storePath, "utf8"));
expect(raw).toMatchObject({ machines: [expect.objectContaining({ kind: "remote", token: "secret" })] });
await expectOwnerOnlyMachineStore(storePath);
});
it("tightens permissions after reading an existing machine store", async () => {
if (process.platform === "win32") return;
await writeFile(storePath, `${JSON.stringify({
machines: [{
id: "remote-1",
name: "Remote",
kind: "remote",
baseUrl: "https://remote.example.test",
token: "secret",
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
}],
}, null, 2)}\n`, { encoding: "utf8", mode: 0o644 });
await chmod(storePath, 0o644);
await expect(service.list()).resolves.toEqual([expect.objectContaining({ id: "local" }), expect.objectContaining({ id: "remote-1" })]);
await expectOwnerOnlyMachineStore(storePath);
});
it("rejects invalid remote base URLs", async () => {
@@ -58,3 +79,8 @@ describe("MachineService", () => {
expect(machineStorePath(env, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "data/machines.json"));
});
});
async function expectOwnerOnlyMachineStore(path: string): Promise<void> {
if (process.platform === "win32") return;
expect((await stat(path)).mode & 0o777).toBe(0o600);
}
+13 -3
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { piWebDataDir } from "../../config.js";
@@ -18,6 +18,8 @@ interface MachineFile {
machines: StoredMachine[];
}
const MACHINE_STORE_FILE_MODE = 0o600;
export function defaultMachineStorePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
return join(piWebDataDir(env, cwd), "machines.json");
}
@@ -76,7 +78,9 @@ export class MachineStore {
private async read(): Promise<MachineFile> {
try {
const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));
return parseMachineFile(value);
const parsed = parseMachineFile(value);
await restrictMachineStorePermissions(this.filePath);
return parsed;
} catch (error) {
if (isNodeErrorWithCode(error, "ENOENT")) return { machines: [] };
throw error;
@@ -85,7 +89,8 @@ export class MachineStore {
private async write(data: MachineFile): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: MACHINE_STORE_FILE_MODE });
await restrictMachineStorePermissions(this.filePath);
}
}
@@ -123,6 +128,11 @@ function optionalStringRecord(value: unknown, key: string): Record<string, strin
}));
}
async function restrictMachineStorePermissions(path: string): Promise<void> {
if (process.platform === "win32") return;
await chmod(path, MACHINE_STORE_FILE_MODE);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}