Archived
fix: harden machine federation boundaries
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user