mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 07:02:16 +03:00
fix(security): reduce the health payload for anonymous callers
GET /api/monitoring/health returned host-fingerprinting detail (app/node version, pid, memory, provider breaker config, MCP paths) to any anonymous caller — the common case on a keyless install. A non-management caller now receives only the liveness verdict (status + setupComplete), which is all a health / load-balancer probe needs; a management principal still gets the full payload. REQUIRE_API_KEY stays false by default (local-first) per operator choice. Reported by @kaimandalic via GHSA-mvf8-qc78-5mxm (information-disclosure portion).
This commit is contained in:
@@ -5,6 +5,7 @@ import { readRunningBuildSha } from "@/lib/monitoring/buildSha";
|
||||
import { APP_CONFIG } from "@/shared/constants/config";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
/**
|
||||
* GET /api/monitoring/health — System health overview
|
||||
@@ -20,10 +21,25 @@ import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
let healthPayloadCache: { payload: unknown; expiresAt: number } | null = null;
|
||||
const HEALTH_PAYLOAD_TTL_MS = 1000;
|
||||
|
||||
export async function GET() {
|
||||
// GHSA-mvf8-qc78-5mxm: the full health payload fingerprints the host (version,
|
||||
// node version, pid, memory, provider config). An anonymous caller — the common
|
||||
// case on a keyless install, and what a liveness/load-balancer probe needs — gets
|
||||
// only the liveness verdict; the detail is reserved for a management principal.
|
||||
function publicHealthView(payload: unknown): Record<string, unknown> {
|
||||
const p = (payload ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
status: p.status ?? "unknown",
|
||||
...(p.setupComplete !== undefined ? { setupComplete: p.setupComplete } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const fullView = (await requireManagementAuth(request, { alwaysRequireAuth: true })) === null;
|
||||
const cachedNow = Date.now();
|
||||
if (healthPayloadCache && cachedNow <= healthPayloadCache.expiresAt) {
|
||||
return NextResponse.json(healthPayloadCache.payload);
|
||||
return NextResponse.json(
|
||||
fullView ? healthPayloadCache.payload : publicHealthView(healthPayloadCache.payload)
|
||||
);
|
||||
}
|
||||
|
||||
const readHealthValue = <T>(label: string, reader: () => T, fallback: T): T => {
|
||||
@@ -187,7 +203,7 @@ export async function GET() {
|
||||
});
|
||||
|
||||
healthPayloadCache = { payload, expiresAt: Date.now() + HEALTH_PAYLOAD_TTL_MS };
|
||||
return NextResponse.json(payload);
|
||||
return NextResponse.json(fullView ? payload : publicHealthView(payload));
|
||||
} catch (error) {
|
||||
console.error("[API] GET /api/monitoring/health error:", error);
|
||||
return NextResponse.json({
|
||||
|
||||
48
tests/unit/monitoring-health-public-view.test.ts
Normal file
48
tests/unit/monitoring-health-public-view.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* GHSA-mvf8-qc78-5mxm — GET /api/monitoring/health returned host-fingerprinting
|
||||
* detail (version, node version, pid, memory, provider config) to anonymous
|
||||
* callers. It now serves only the liveness verdict to non-management callers.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-health-view-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const route = await import("../../src/app/api/monitoring/health/route.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("anonymous health GET is reduced to liveness only (GHSA-mvf8)", async () => {
|
||||
const res = await route.GET(new Request("http://localhost/api/monitoring/health") as never);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
assert.ok("status" in body, "liveness status must be present for probes");
|
||||
// No host fingerprinting for an anonymous caller.
|
||||
const keys = Object.keys(body);
|
||||
const allowed = new Set(["status", "setupComplete"]);
|
||||
for (const k of keys) {
|
||||
assert.ok(allowed.has(k), `anonymous health view leaked field: ${k}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("management session sees the full health payload", async () => {
|
||||
const sessionReq = (await makeManagementSessionRequest(
|
||||
"http://localhost/api/monitoring/health"
|
||||
)) as unknown as NextRequest;
|
||||
const res = await route.GET(sessionReq as never);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
assert.ok(
|
||||
Object.keys(body).length > 2,
|
||||
"a management caller must still receive the detailed payload"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user