diff --git a/skills/share/index.ts b/skills/share/index.ts index 701a1b4..bdfdce7 100755 --- a/skills/share/index.ts +++ b/skills/share/index.ts @@ -10,7 +10,7 @@ * bun /app/bin/share -h Show this help */ -import { existsSync, statSync, unlinkSync, mkdirSync } from "node:fs"; +import { existsSync, statSync, unlinkSync, mkdirSync, readFileSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { extname, resolve, dirname } from "node:path"; import { createConnection } from "node:net"; @@ -23,7 +23,7 @@ import { STORE_PATH, type TokenEntry, } from "./store.js"; -import { SOCKET_PATH } from "../../src/rpc.js"; +import { SOCKET_PATH, SECRET_PATH } from "../../src/rpc.js"; // ── Constants ───────────────────────────────────────────────────────────────── @@ -80,9 +80,15 @@ function baseUrl(): string { async function rpcCall(method: string, params: Record): Promise { return new Promise((resolve, reject) => { + let secret: string; + try { + secret = readFileSync(SECRET_PATH, "utf8").trim(); + } catch { + return reject(new Error(`Cannot read RPC secret from ${SECRET_PATH}`)); + } const socket = createConnection(SOCKET_PATH); socket.on("connect", () => { - socket.write(JSON.stringify({ jsonrpc: "2.0", method, params, id: 1 }) + "\n"); + socket.write(JSON.stringify({ jsonrpc: "2.0", method, params: { ...params, secret }, id: 1 }) + "\n"); }); socket.on("data", () => { socket.destroy(); resolve(); }); socket.on("error", reject); diff --git a/src/rpc.ts b/src/rpc.ts index a660bb5..d6a7f3d 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -5,22 +5,55 @@ * Other local processes (e.g. skills/share) connect to register HTTP routes * that the main Bun server then proxies. * + * Security: + * - A shared secret is generated at startup and written to SECRET_PATH. + * Every RPC call must include params.secret matching this value. + * - Route targets must be http://localhost: or http://127.0.0.1:. + * - Route patterns must start with an allowed prefix (ALLOWED_PATTERNS). + * * Supported methods: - * route.register { pattern: string, target: string } - * route.unregister { pattern: string } + * route.register { pattern: string, target: string, secret: string } + * route.unregister { pattern: string, secret: string } */ import { createServer } from "node:net"; -import { existsSync, unlinkSync } from "node:fs"; +import { existsSync, unlinkSync, writeFileSync } from "node:fs"; +import { randomBytes, timingSafeEqual } from "node:crypto"; export const SOCKET_PATH = "/tmp/backoffice.sock"; +export const SECRET_PATH = "/tmp/backoffice-rpc.secret"; + +const ALLOWED_PATTERNS = ["/share"]; +const LOCALHOST_TARGET = /^http:\/\/(?:localhost|127\.0\.0\.1):\d+$/; /** pattern (e.g. "/share") → target base URL (e.g. "http://localhost:3001") */ export const routeRegistry = new Map(); +let rpcSecret = ""; + +function verifySecret(presented: string | undefined): boolean { + if (!presented || !rpcSecret) return false; + const a = Buffer.from(presented, "utf8"); + const b = Buffer.from(rpcSecret, "utf8"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +function isAllowedPattern(pattern: string): boolean { + return ALLOWED_PATTERNS.some((p) => pattern === p || pattern.startsWith(p + "/")); +} + +function isAllowedTarget(target: string): boolean { + return LOCALHOST_TARGET.test(target); +} + export function startRpcServer(): void { if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH); + rpcSecret = randomBytes(32).toString("hex"); + writeFileSync(SECRET_PATH, rpcSecret + "\n", { mode: 0o600 }); + console.log(`[rpc] Secret written to ${SECRET_PATH}`); + const server = createServer((socket) => { let buf = ""; @@ -64,12 +97,22 @@ function handleRpc(msg: unknown): object { const id = req.id ?? null; const params: RpcParams = req.params ?? {}; + if (!verifySecret(params["secret"])) { + return { jsonrpc: "2.0", error: { code: -32600, message: "Invalid or missing secret" }, id }; + } + if (req.method === "route.register") { const pattern = params["pattern"]; const target = params["target"]; if (!pattern || !target) { return { jsonrpc: "2.0", error: { code: -32602, message: "pattern and target required" }, id }; } + if (!isAllowedPattern(pattern)) { + return { jsonrpc: "2.0", error: { code: -32602, message: `pattern not allowed: ${pattern}` }, id }; + } + if (!isAllowedTarget(target)) { + return { jsonrpc: "2.0", error: { code: -32602, message: `target must be http://localhost:` }, id }; + } routeRegistry.set(pattern, target); console.log(`[rpc] Registered route: ${pattern} → ${target}`); return { jsonrpc: "2.0", result: { ok: true }, id };