chore(security): harden ipc socket
This commit is contained in:
63
src/web/ipc.test.ts
Normal file
63
src/web/ipc.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../logging.js", () => ({
|
||||
getChildLogger: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = originalHome;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("ipc hardening", () => {
|
||||
it("creates private socket dir and socket with tight perms", async () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "warelay-home-"));
|
||||
process.env.HOME = tmpHome;
|
||||
vi.resetModules();
|
||||
|
||||
const ipc = await import("./ipc.js");
|
||||
|
||||
const sendHandler = vi.fn().mockResolvedValue({ messageId: "msg1" });
|
||||
ipc.startIpcServer(sendHandler);
|
||||
|
||||
const dirStat = fs.lstatSync(path.join(tmpHome, ".warelay", "ipc"));
|
||||
expect(dirStat.mode & 0o777).toBe(0o700);
|
||||
|
||||
expect(ipc.isRelayRunning()).toBe(true);
|
||||
|
||||
const socketStat = fs.lstatSync(ipc.getSocketPath());
|
||||
expect(socketStat.isSocket()).toBe(true);
|
||||
if (typeof process.getuid === "function") {
|
||||
expect(socketStat.uid).toBe(process.getuid());
|
||||
}
|
||||
|
||||
ipc.stopIpcServer();
|
||||
expect(ipc.isRelayRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses to start when IPC dir is a symlink", async () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "warelay-home-"));
|
||||
const warelayDir = path.join(tmpHome, ".warelay");
|
||||
fs.mkdirSync(warelayDir, { recursive: true });
|
||||
fs.symlinkSync("/tmp", path.join(warelayDir, "ipc"));
|
||||
|
||||
process.env.HOME = tmpHome;
|
||||
vi.resetModules();
|
||||
|
||||
const ipc = await import("./ipc.js");
|
||||
const sendHandler = vi.fn().mockResolvedValue({ messageId: "msg1" });
|
||||
|
||||
expect(() => ipc.startIpcServer(sendHandler)).toThrow(/symlink/i);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,8 @@ import path from "node:path";
|
||||
|
||||
import { getChildLogger } from "../logging.js";
|
||||
|
||||
const SOCKET_PATH = path.join(os.homedir(), ".warelay", "relay.sock");
|
||||
const SOCKET_DIR = path.join(os.homedir(), ".warelay", "ipc");
|
||||
const SOCKET_PATH = path.join(SOCKET_DIR, "relay.sock");
|
||||
|
||||
export interface IpcSendRequest {
|
||||
type: "send";
|
||||
@@ -44,11 +45,21 @@ let server: net.Server | null = null;
|
||||
export function startIpcServer(sendHandler: SendHandler): void {
|
||||
const logger = getChildLogger({ module: "ipc-server" });
|
||||
|
||||
// Clean up stale socket file
|
||||
ensureSocketDir();
|
||||
try {
|
||||
assertSafeSocketPath(SOCKET_PATH);
|
||||
} catch (err) {
|
||||
logger.error({ error: String(err) }, "Refusing to start IPC server");
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Clean up stale socket file (only if safe to do so)
|
||||
try {
|
||||
fs.unlinkSync(SOCKET_PATH);
|
||||
} catch {
|
||||
// Ignore if doesn't exist
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
server = net.createServer((conn) => {
|
||||
@@ -134,6 +145,7 @@ export function stopIpcServer(): void {
|
||||
*/
|
||||
export function isRelayRunning(): boolean {
|
||||
try {
|
||||
assertSafeSocketPath(SOCKET_PATH);
|
||||
fs.accessSync(SOCKET_PATH);
|
||||
return true;
|
||||
} catch {
|
||||
@@ -223,3 +235,43 @@ export async function sendViaIpc(
|
||||
export function getSocketPath(): string {
|
||||
return SOCKET_PATH;
|
||||
}
|
||||
|
||||
function ensureSocketDir(): void {
|
||||
try {
|
||||
const stat = fs.lstatSync(SOCKET_DIR);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`IPC dir is a symlink: ${SOCKET_DIR}`);
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`IPC dir is not a directory: ${SOCKET_DIR}`);
|
||||
}
|
||||
// Enforce private permissions
|
||||
fs.chmodSync(SOCKET_DIR, 0o700);
|
||||
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
||||
throw new Error(`IPC dir owned by different user: ${SOCKET_DIR}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
fs.mkdirSync(SOCKET_DIR, { recursive: true, mode: 0o700 });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeSocketPath(socketPath: string): void {
|
||||
try {
|
||||
const stat = fs.lstatSync(socketPath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing IPC socket symlink: ${socketPath}`);
|
||||
}
|
||||
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
||||
throw new Error(`IPC socket owned by different user: ${socketPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return; // Missing is fine; creation will happen next.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user