Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/gittensory-miner-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
156 changes: 156 additions & 0 deletions apps/gittensory-miner-ui/src/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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");
});
});
114 changes: 114 additions & 0 deletions apps/gittensory-miner-ui/vite-auth.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
if (!cookieHeader) return {};
const cookies: Record<string, string> = {};
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);
},
};
}
4 changes: 4 additions & 0 deletions apps/gittensory-miner-ui/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(),
Expand Down
Loading