From a663fefde11812f40b0839ae2834cbfecd7b9a10 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:41:52 -0700 Subject: [PATCH] fix(miner-ui): restrict run-state API to loopback --- .../src/run-state-api.test.ts | 29 +++++++++++++++++-- .../gittensory-miner-ui/vite-run-state-api.ts | 18 ++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/apps/gittensory-miner-ui/src/run-state-api.test.ts b/apps/gittensory-miner-ui/src/run-state-api.test.ts index 819eb278b..29bcf55fa 100644 --- a/apps/gittensory-miner-ui/src/run-state-api.test.ts +++ b/apps/gittensory-miner-ui/src/run-state-api.test.ts @@ -16,8 +16,8 @@ function deps(overrides: Partial = {}): RunStateApiDeps { } describe("handleRunStateRequest (#4305)", () => { - it("serves the run-state rows via the existing run-state.js exports", async () => { - const handled = await handleRunStateRequest("GET", "/api/run-state", deps()); + it("serves the run-state rows via the existing run-state.js exports for loopback clients", async () => { + const handled = await handleRunStateRequest("GET", "/api/run-state", deps(), "127.0.0.1"); expect(handled).toEqual({ status: 200, body: JSON.stringify({ rows }) }); }); @@ -41,6 +41,31 @@ describe("handleRunStateRequest (#4305)", () => { expect(listed).toBe(false); }); + it("denies non-loopback clients before loading the local store", async () => { + let loaded = false; + const handled = await handleRunStateRequest( + "GET", + "/api/run-state", + deps({ + loadRunStateModule: async () => { + loaded = true; + throw new Error("must not load"); + }, + }), + "192.168.1.50", + ); + expect(handled).toEqual({ + status: 403, + body: JSON.stringify({ error: "run-state API is only available from loopback clients" }), + }); + expect(loaded).toBe(false); + }); + + it("allows IPv6-mapped loopback clients", async () => { + const handled = await handleRunStateRequest("GET", "/api/run-state", deps(), "::ffff:127.0.0.1"); + expect(handled).toEqual({ status: 200, body: JSON.stringify({ rows }) }); + }); + it("falls through (null) for other paths and non-GET methods", async () => { expect(await handleRunStateRequest("GET", "/api/other", deps())).toBeNull(); expect(await handleRunStateRequest("POST", "/api/run-state", deps())).toBeNull(); diff --git a/apps/gittensory-miner-ui/vite-run-state-api.ts b/apps/gittensory-miner-ui/vite-run-state-api.ts index 47540569d..c056ce2ee 100644 --- a/apps/gittensory-miner-ui/vite-run-state-api.ts +++ b/apps/gittensory-miner-ui/vite-run-state-api.ts @@ -22,6 +22,16 @@ export type RunStateApiDeps = { fileExists: (path: string) => boolean; }; +function isLoopbackAddress(remoteAddress: string | undefined): boolean { + if (remoteAddress === undefined) return true; + return ( + remoteAddress === "::1" || + remoteAddress === "::ffff:127.0.0.1" || + remoteAddress === "127.0.0.1" || + remoteAddress.startsWith("127.") + ); +} + const defaultDeps: RunStateApiDeps = { loadRunStateModule: () => import("../../packages/gittensory-miner/lib/run-state.js") as Promise, fileExists: existsSync, @@ -33,8 +43,12 @@ export async function handleRunStateRequest( method: string | undefined, url: string | undefined, deps: RunStateApiDeps = defaultDeps, + remoteAddress?: string, ): Promise<{ status: number; body: string } | null> { if (url !== "/api/run-state" || (method !== undefined && method !== "GET")) return null; + if (!isLoopbackAddress(remoteAddress)) { + return { status: 403, body: JSON.stringify({ error: "run-state API is only available from loopback clients" }) }; + } try { const runState = await deps.loadRunStateModule(); // Fresh install: no DB file yet. Serve the empty list WITHOUT initializing the store (that would create @@ -54,14 +68,14 @@ export function runStateApiPlugin(deps: RunStateApiDeps = defaultDeps): Plugin { const attach = (middlewares: { use: ( fn: ( - req: { method?: string; url?: string }, + req: { method?: string; socket?: { remoteAddress?: string }; url?: string }, res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, next: () => void, ) => void, ) => void; }) => { middlewares.use((req, res, next) => { - void handleRunStateRequest(req.method, req.url, deps).then((handled) => { + void handleRunStateRequest(req.method, req.url, deps, req.socket?.remoteAddress).then((handled) => { if (!handled) return next(); res.statusCode = handled.status; res.setHeader("Content-Type", "application/json");