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..35fdcf67a --- /dev/null +++ b/apps/gittensory-miner-ui/src/auth.test.ts @@ -0,0 +1,156 @@ +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 a non-/api/ request that falls through (the initial page load)", () => { + 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, 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, headers, 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); + expect(headers["Set-Cookie"]).toBeUndefined(); + }); + + it("lets an authenticated /api/* request fall through and re-stamps the same cookie", () => { + const middleware = captureMiddleware(); + 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", () => { + 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..b885d4548 --- /dev/null +++ b/apps/gittensory-miner-ui/vite-auth.ts @@ -0,0 +1,114 @@ +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 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: +// 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) 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: { + 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) => { + const rejection = handleAuthRequest(req.url, req.headers.cookie, token); + 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 { + 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(),