From bbbfb7e4cb2079b88069113df406c4db7e106f39 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:00:40 -0700 Subject: [PATCH 1/2] feat(miner-ui): require a same-origin session cookie on the local API Adds vite-auth.ts: a random per-process token delivered as an HttpOnly SameSite=Strict cookie, required on every /api/* request. Replaces the previous inconsistent/implicit loopback-IP trust (only one of the three API plugins even checked the caller's address) with real authentication that needs no client-side fetch changes. --- apps/gittensory-miner-ui/README.md | 11 ++ apps/gittensory-miner-ui/src/auth.test.ts | 150 ++++++++++++++++++++++ apps/gittensory-miner-ui/vite-auth.ts | 103 +++++++++++++++ apps/gittensory-miner-ui/vite.config.ts | 4 + 4 files changed, 268 insertions(+) create mode 100644 apps/gittensory-miner-ui/src/auth.test.ts create mode 100644 apps/gittensory-miner-ui/vite-auth.ts diff --git a/apps/gittensory-miner-ui/README.md b/apps/gittensory-miner-ui/README.md index 893c58624..0bd0bb4ba 100644 --- a/apps/gittensory-miner-ui/README.md +++ b/apps/gittensory-miner-ui/README.md @@ -15,3 +15,14 @@ Phase 6 data views (run history, portfolio cards) land in follow-up issues after | Env var | Required | Description | | --------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `VITE_MINER_UI_GRAFANA_URL` | No | If set (and non-empty), renders a footer link to your ORB/Grafana dashboard at this URL. Unset ⇒ no link. Must be `VITE_`-prefixed so Vite exposes it to the client bundle. It is a plain navigational link — no token or credential is ever appended. | + +## Local API authentication + +`/api/*` (run-state, portfolio-queue, ledgers, and any future endpoint under that prefix) requires a +same-origin session cookie — an unauthenticated request is rejected with `401`. The dev/preview server +(`vite-auth.ts`) generates a random token once per process and sets it as an `HttpOnly; SameSite=Strict` +cookie on every response; a browser that has loaded this app's own page (`/`) already carries the cookie +automatically on every subsequent same-origin `fetch()` call, so none of the client-side data fetchers need +to know about it. A request from another local process, or from a different page/origin the user has open +(including a DNS-rebinding attempt), has no way to obtain the cookie and is rejected. There is nothing to +configure — this is always on for both `vite dev` and `vite preview`. diff --git a/apps/gittensory-miner-ui/src/auth.test.ts b/apps/gittensory-miner-ui/src/auth.test.ts new file mode 100644 index 000000000..f58b9c58f --- /dev/null +++ b/apps/gittensory-miner-ui/src/auth.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { authPlugin, handleAuthRequest, isAuthenticatedRequest } from "../vite-auth"; + +const TOKEN = "deterministic-test-token"; + +describe("isAuthenticatedRequest (#4858)", () => { + it("rejects a request with no Cookie header at all", () => { + expect(isAuthenticatedRequest(undefined, TOKEN)).toBe(false); + }); + + it("rejects a Cookie header that never mentions the auth cookie", () => { + expect(isAuthenticatedRequest("othercookie=x; another=y", TOKEN)).toBe(false); + }); + + it("skips a malformed cookie pair with no '=' separator, without throwing", () => { + expect(isAuthenticatedRequest(`malformed; gittensory_miner_ui_token=${TOKEN}`, TOKEN)).toBe(true); + }); + + it("skips a cookie pair with an empty name (leading '=')", () => { + expect(isAuthenticatedRequest(`=novalue; gittensory_miner_ui_token=${TOKEN}`, TOKEN)).toBe(true); + }); + + it("rejects the auth cookie when its value doesn't match the server's token", () => { + expect(isAuthenticatedRequest("gittensory_miner_ui_token=wrong-value", TOKEN)).toBe(false); + }); + + it("accepts the auth cookie when its value matches the server's token exactly", () => { + expect(isAuthenticatedRequest(`gittensory_miner_ui_token=${TOKEN}`, TOKEN)).toBe(true); + }); +}); + +describe("handleAuthRequest (#4858)", () => { + it("falls through (null) for a request with no url", () => { + expect(handleAuthRequest(undefined, undefined, TOKEN)).toBeNull(); + }); + + it("falls through (null) for a non-/api/ request regardless of cookie state", () => { + expect(handleAuthRequest("/", undefined, TOKEN)).toBeNull(); + expect(handleAuthRequest("/assets/index.js", undefined, TOKEN)).toBeNull(); + }); + + it("falls through (null) for an authenticated /api/* request", () => { + expect(handleAuthRequest("/api/portfolio-queue", `gittensory_miner_ui_token=${TOKEN}`, TOKEN)).toBeNull(); + }); + + it("returns a 401 JSON body for an unauthenticated /api/* request", () => { + expect(handleAuthRequest("/api/portfolio-queue", undefined, TOKEN)).toEqual({ + status: 401, + body: JSON.stringify({ error: "unauthenticated: missing or invalid local miner-ui session cookie" }), + }); + }); +}); + +type CapturedRequestHandler = ( + req: { url?: string; headers: { cookie?: string } }, + res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, + next: () => void, +) => void; + +function captureMiddleware(deps = { generateToken: () => TOKEN }): CapturedRequestHandler { + let captured: CapturedRequestHandler | undefined; + const plugin = authPlugin(deps); + const server = { middlewares: { use: (fn: CapturedRequestHandler) => (captured = fn) } }; + // @ts-expect-error -- the test double only implements the subset of Vite's ViteDevServer this plugin reads. + plugin.configureServer(server); + if (!captured) throw new Error("authPlugin did not register a middleware"); + return captured; +} + +function fakeResponse() { + const headers: Record = {}; + let statusCode = 200; + let ended: string | undefined; + return { + res: { + get statusCode() { + return statusCode; + }, + set statusCode(value: number) { + statusCode = value; + }, + setHeader: (k: string, v: string) => { + headers[k] = v; + }, + end: (body: string) => { + ended = body; + }, + }, + headers, + getEnded: () => ended, + getStatus: () => statusCode, + }; +} + +describe("authPlugin (#4858)", () => { + it("stamps the Set-Cookie header on every response, including a request that falls through", () => { + const middleware = captureMiddleware(); + const { res, headers } = fakeResponse(); + let calledNext = false; + middleware({ url: "/", headers: {} }, res, () => { + calledNext = true; + }); + expect(headers["Set-Cookie"]).toBe(`gittensory_miner_ui_token=${TOKEN}; HttpOnly; SameSite=Strict; Path=/`); + expect(calledNext).toBe(true); + }); + + it("rejects an unauthenticated /api/* request with 401 and never calls next()", () => { + const middleware = captureMiddleware(); + const { res, getEnded, getStatus } = fakeResponse(); + let calledNext = false; + middleware({ url: "/api/portfolio-queue", headers: {} }, res, () => { + calledNext = true; + }); + expect(getStatus()).toBe(401); + expect(JSON.parse(getEnded() ?? "{}")).toEqual({ + error: "unauthenticated: missing or invalid local miner-ui session cookie", + }); + expect(calledNext).toBe(false); + }); + + it("lets an authenticated /api/* request fall through to the next middleware", () => { + const middleware = captureMiddleware(); + const { res } = fakeResponse(); + let calledNext = false; + middleware({ url: "/api/portfolio-queue", headers: { cookie: `gittensory_miner_ui_token=${TOKEN}` } }, res, () => { + calledNext = true; + }); + expect(calledNext).toBe(true); + }); + + it("uses deps.generateToken so a fixed test token is deterministic across requests", () => { + const middleware = captureMiddleware({ generateToken: () => "fixed-token-123" }); + const { res } = fakeResponse(); + let calledNext = false; + middleware({ url: "/api/ledgers", headers: { cookie: "gittensory_miner_ui_token=fixed-token-123" } }, res, () => { + calledNext = true; + }); + expect(calledNext).toBe(true); + }); + + it("also attaches via configurePreviewServer for `vite preview`", () => { + let captured: CapturedRequestHandler | undefined; + const plugin = authPlugin(); + const server = { middlewares: { use: (fn: CapturedRequestHandler) => (captured = fn) } }; + // @ts-expect-error -- same partial test double as configureServer above. + plugin.configurePreviewServer(server); + expect(captured).toBeTypeOf("function"); + }); +}); diff --git a/apps/gittensory-miner-ui/vite-auth.ts b/apps/gittensory-miner-ui/vite-auth.ts new file mode 100644 index 000000000..0ad109ee0 --- /dev/null +++ b/apps/gittensory-miner-ui/vite-auth.ts @@ -0,0 +1,103 @@ +import { randomBytes } from "node:crypto"; +import type { Plugin } from "vite"; + +// Local miner-ui API auth (#4858): the miner-ui's /api/* endpoints previously relied on undocumented, implicit +// loopback-only trust -- and even that was inconsistent (vite-run-state-api.ts's own isLoopbackAddress gated +// only ONE of the three API plugins; vite-portfolio-queue-api.ts and vite-ledgers-api.ts had no gate at all). +// Neither is real authentication: loopback-IP checks don't stop another local process, or a malicious page the +// user has open in the SAME browser, from hitting the loopback API. +// +// This adds a minimal-but-real mechanism instead of touching each API file individually: a random token +// generated ONCE per dev-server process, delivered to the browser as a same-origin HttpOnly SameSite=Strict +// cookie on every response (so the very first page load already carries it going forward), and required on +// every /api/* request thereafter. HttpOnly keeps it unreachable from any XSS in the SPA itself; SameSite=Strict +// means a cross-origin page -- including one from a DNS-rebinding attack, which resolves an ATTACKER-CONTROLLED +// hostname to 127.0.0.1 rather than reusing this dev server's own origin -- never has it attached automatically +// by the browser. Because it rides on the browser's own cookie jar, no client-side fetch call needs to change: +// the browser attaches it automatically to every same-origin request, including the existing fetchPortfolioQueue/ +// fetchLedgers/fetchRunState calls. +// +// Registered as the FIRST plugin in vite.config.ts so its middleware runs before runStateApiPlugin/ +// portfolioQueueApiPlugin/ledgersApiPlugin's own middlewares in the Connect chain: an unauthenticated /api/* +// request never reaches any of them. This also means any FUTURE /api/* endpoint (e.g. a write action) is +// covered automatically, with no per-endpoint auth wiring required. + +const COOKIE_NAME = "gittensory_miner_ui_token"; + +export type AuthDeps = { + /** Injectable so tests get a deterministic token instead of a real random one. */ + generateToken: () => string; +}; + +const defaultDeps: AuthDeps = { + generateToken: () => randomBytes(24).toString("hex"), +}; + +function parseCookieHeader(cookieHeader: string | undefined): Record { + if (!cookieHeader) return {}; + const cookies: Record = {}; + for (const pair of cookieHeader.split(";")) { + const separatorIndex = pair.indexOf("="); + if (separatorIndex === -1) continue; + const name = pair.slice(0, separatorIndex).trim(); + const value = pair.slice(separatorIndex + 1).trim(); + if (name) cookies[name] = value; + } + return cookies; +} + +/** True when the incoming request carries the server's own auth cookie. Exported so the plugin's request + * handling can be exercised directly in tests without a real HTTP server. */ +export function isAuthenticatedRequest(cookieHeader: string | undefined, token: string): boolean { + return parseCookieHeader(cookieHeader)[COOKIE_NAME] === token; +} + +/** The request handler, factored out of the Vite plugin shape so tests drive it directly (mirrors the sibling + * API files' handleXRequest pattern). Returns the 401 body when an /api/* request lacks a valid cookie, or + * null when the request should fall through to the next middleware (either it's authenticated, or it isn't + * an /api/* request at all and only needs the Set-Cookie header, applied by the caller). */ +export function handleAuthRequest( + url: string | undefined, + cookieHeader: string | undefined, + token: string, +): { status: number; body: string } | null { + if (!url?.startsWith("/api/")) return null; + if (isAuthenticatedRequest(cookieHeader, token)) return null; + return { + status: 401, + body: JSON.stringify({ error: "unauthenticated: missing or invalid local miner-ui session cookie" }), + }; +} + +/** Vite dev/preview middleware: generates one token per process and (a) stamps it onto every response as a + * same-origin cookie, and (b) rejects any /api/* request that doesn't present it. */ +export function authPlugin(deps: AuthDeps = defaultDeps): Plugin { + const token = deps.generateToken(); + const attach = (middlewares: { + use: ( + fn: ( + req: { url?: string; headers: { cookie?: string } }, + res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, + next: () => void, + ) => void, + ) => void; + }) => { + middlewares.use((req, res, next) => { + res.setHeader("Set-Cookie", `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/`); + const rejection = handleAuthRequest(req.url, req.headers.cookie, token); + if (!rejection) return next(); + res.statusCode = rejection.status; + res.setHeader("Content-Type", "application/json"); + res.end(rejection.body); + }); + }; + return { + name: "gittensory-miner-ui:auth", + configureServer(server) { + attach(server.middlewares); + }, + configurePreviewServer(server) { + attach(server.middlewares); + }, + }; +} diff --git a/apps/gittensory-miner-ui/vite.config.ts b/apps/gittensory-miner-ui/vite.config.ts index e4c188f9c..097dd093c 100644 --- a/apps/gittensory-miner-ui/vite.config.ts +++ b/apps/gittensory-miner-ui/vite.config.ts @@ -4,6 +4,7 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; +import { authPlugin } from "./vite-auth"; import { ledgersApiPlugin } from "./vite-ledgers-api"; import { portfolioQueueApiPlugin } from "./vite-portfolio-queue-api"; import { runStateApiPlugin } from "./vite-run-state-api"; @@ -14,6 +15,9 @@ export default defineConfig({ react(), tailwindcss(), tsconfigPaths(), + // Must run before the three API plugins below: it rejects any unauthenticated /api/* request before + // their own middlewares are reached (#4858). + authPlugin(), runStateApiPlugin(), portfolioQueueApiPlugin(), ledgersApiPlugin(), From 01c0ad5056bad49d57b16229b5b267f65839248c Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:15:40 -0700 Subject: [PATCH 2/2] fix(miner-ui): never set the auth cookie on an unauthenticated 401 Set-Cookie was applied unconditionally before the auth check, so an unauthenticated /api/* request could read the valid token straight off its own 401 response and replay it -- defeating the whole mechanism. Reject first, then only stamp the cookie on a response the caller is already authorized to receive (an authenticated /api/* request, or any non-/api/* page/asset load). --- apps/gittensory-miner-ui/src/auth.test.ts | 16 +++++++++---- apps/gittensory-miner-ui/vite-auth.ts | 29 ++++++++++++++++------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/apps/gittensory-miner-ui/src/auth.test.ts b/apps/gittensory-miner-ui/src/auth.test.ts index f58b9c58f..35fdcf67a 100644 --- a/apps/gittensory-miner-ui/src/auth.test.ts +++ b/apps/gittensory-miner-ui/src/auth.test.ts @@ -94,7 +94,7 @@ function fakeResponse() { } describe("authPlugin (#4858)", () => { - it("stamps the Set-Cookie header on every response, including a request that falls through", () => { + it("stamps the Set-Cookie header on a non-/api/ request that falls through (the initial page load)", () => { const middleware = captureMiddleware(); const { res, headers } = fakeResponse(); let calledNext = false; @@ -105,9 +105,13 @@ describe("authPlugin (#4858)", () => { expect(calledNext).toBe(true); }); - it("rejects an unauthenticated /api/* request with 401 and never calls next()", () => { + it("rejects an unauthenticated /api/* request with 401, never calls next(), and NEVER leaks the token via Set-Cookie", () => { + // Regression test: an earlier version set Set-Cookie on every response BEFORE checking auth, so an + // unauthenticated caller could read the valid token straight off this 401's own headers and replay it -- + // completely defeating the mechanism. The token must only ever reach a caller that is already + // authenticated, or a non-/api/* (page/asset) request. const middleware = captureMiddleware(); - const { res, getEnded, getStatus } = fakeResponse(); + const { res, headers, getEnded, getStatus } = fakeResponse(); let calledNext = false; middleware({ url: "/api/portfolio-queue", headers: {} }, res, () => { calledNext = true; @@ -117,16 +121,18 @@ describe("authPlugin (#4858)", () => { error: "unauthenticated: missing or invalid local miner-ui session cookie", }); expect(calledNext).toBe(false); + expect(headers["Set-Cookie"]).toBeUndefined(); }); - it("lets an authenticated /api/* request fall through to the next middleware", () => { + it("lets an authenticated /api/* request fall through and re-stamps the same cookie", () => { const middleware = captureMiddleware(); - const { res } = fakeResponse(); + const { res, headers } = fakeResponse(); let calledNext = false; middleware({ url: "/api/portfolio-queue", headers: { cookie: `gittensory_miner_ui_token=${TOKEN}` } }, res, () => { calledNext = true; }); expect(calledNext).toBe(true); + expect(headers["Set-Cookie"]).toBe(`gittensory_miner_ui_token=${TOKEN}; HttpOnly; SameSite=Strict; Path=/`); }); it("uses deps.generateToken so a fixed test token is deterministic across requests", () => { diff --git a/apps/gittensory-miner-ui/vite-auth.ts b/apps/gittensory-miner-ui/vite-auth.ts index 0ad109ee0..b885d4548 100644 --- a/apps/gittensory-miner-ui/vite-auth.ts +++ b/apps/gittensory-miner-ui/vite-auth.ts @@ -9,8 +9,10 @@ import type { Plugin } from "vite"; // // This adds a minimal-but-real mechanism instead of touching each API file individually: a random token // generated ONCE per dev-server process, delivered to the browser as a same-origin HttpOnly SameSite=Strict -// cookie on every response (so the very first page load already carries it going forward), and required on -// every /api/* request thereafter. HttpOnly keeps it unreachable from any XSS in the SPA itself; SameSite=Strict +// cookie on every response the caller is authorized to receive (so the very first page load already carries +// it going forward -- NOT on an unauthenticated /api/* request's own 401, which must never leak the token it +// is rejecting the caller for lacking), and required on every /api/* request thereafter. HttpOnly keeps it +// unreachable from any XSS in the SPA itself; SameSite=Strict // means a cross-origin page -- including one from a DNS-rebinding attack, which resolves an ATTACKER-CONTROLLED // hostname to 127.0.0.1 rather than reusing this dev server's own origin -- never has it attached automatically // by the browser. Because it rides on the browser's own cookie jar, no client-side fetch call needs to change: @@ -69,8 +71,14 @@ export function handleAuthRequest( }; } -/** Vite dev/preview middleware: generates one token per process and (a) stamps it onto every response as a - * same-origin cookie, and (b) rejects any /api/* request that doesn't present it. */ +/** Vite dev/preview middleware: generates one token per process and (a) rejects any /api/* request that + * doesn't present it, before (b) stamping the cookie onto a response that's allowed to proceed. The ORDER + * matters: handleAuthRequest returns non-null (a rejection) ONLY for an unauthenticated /api/* request, so + * the Set-Cookie header must never be set on that response -- otherwise the token itself would ride along + * with the very 401 that was supposed to deny the caller, letting anyone who can reach the loopback port + * read the token off a single unauthenticated request and replay it, defeating the whole mechanism. Every + * request that reaches the setHeader call below is either already authenticated or isn't an /api/* request + * at all (the initial page/asset load a real browser session is meant to obtain the cookie from). */ export function authPlugin(deps: AuthDeps = defaultDeps): Plugin { const token = deps.generateToken(); const attach = (middlewares: { @@ -83,12 +91,15 @@ export function authPlugin(deps: AuthDeps = defaultDeps): Plugin { ) => void; }) => { middlewares.use((req, res, next) => { - res.setHeader("Set-Cookie", `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/`); const rejection = handleAuthRequest(req.url, req.headers.cookie, token); - if (!rejection) return next(); - res.statusCode = rejection.status; - res.setHeader("Content-Type", "application/json"); - res.end(rejection.body); + if (rejection) { + res.statusCode = rejection.status; + res.setHeader("Content-Type", "application/json"); + res.end(rejection.body); + return; + } + res.setHeader("Set-Cookie", `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/`); + next(); }); }; return {