mirror of
https://github.com/ruvnet/RuView
synced 2026-08-01 19:01:42 +00:00
9109269329
ADR-263 (@ruvnet/ruview 0.2.0), O1-O9: - claim-check fails closed on empty input (CLI exit 2, empty_text tool error) - MCP stdio server dispatches tools/call asynchronously (promise-based spawn); ping answers while a 3s fake verify runs — pinned by new e2e test - optionalDependencies dropped: cold npx installs exactly 1 package (MEASURED: was 4 pkgs/620kB/71 files via npm i in a clean prefix) - bounded rolling output tails replace spawnSync 1MiB maxBuffer - node_monitor port passed via sys.argv, never spliced into python -c source - serverInfo.version read from package.json; resources/prompts stubs - skills single-sourced: prepack sync script generates .claude/skills/ copies - which() = memoized dep-free PATH scan - tools underscore-canonical (ruview_claim_check, ...) + dotted aliases - guardrail precision: word-boundary map/f1/auc/iou, code-span + F1/O2 label scrubbing, quantitative-claims-only; packaging reproducer hints - 30/30 tests (was 17), incl. concurrency e2e + fail-open regression pins ADR-264 (@ruvnet/rvagent 0.2.0), O1-O9: - exports fixed: types-first, phantom dist/index.cjs require target removed - tarball map-free: 127,704B unpacked / 46 files / 0 maps (MEASURED, npm pack --dry-run; was 188kB incl. 44 maps referencing unshipped src) - Streamable HTTP actually wired behind RVAGENT_HTTP_PORT: one transport + one MCP server per session (mcp-session-id routing), 1MiB body cap (413), port-aware localhost origin gate; dual-transport description now true - tools renamed underscore-canonical with dotted router-only aliases - single Zod validation gate; advertised inputSchema generated from the same Zod source (zod-to-json-schema) - train_count: parent log fds closed (was leaking 2/job); job records persisted to <jobsDir>/<id>.json (job_status survives restarts); bounded log-tail reads - detectCogBinary probes its candidates instead of dead-coding them - version from package.json; @types/express dropped; @types/jest -> 29 - README rewritten to match reality (no phantom subcommands/policy layer) - 99/99 jest tests (incl. new session/body-cap suite + previously-broken manifest suite); stdio handshake + HTTP session flow smoke-tested live ADR-265 D1-D4: - .github/workflows/npm-packages.yml: 3-package x Node 20/22 gate — tests, version-literal grep (D3), pack-content/size gate, tarball-install smoke test (catches the ADR-264 F1 class), README claim-check (D4) - .github/workflows/ruview-npm-release.yml: publish from CI only with npm publish --provenance - @ruv/ruview-cli bin renamed ruview-cli (ruview bin belongs to @ruvnet/ruview); version single-sourced - ci.yml NODE_VERSION 18 -> 20 ADR statuses updated to Accepted/implemented; harness manifest re-pinned; ADR-263/264/265 + both package READMEs pass claim-check. Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz
232 lines
8.4 KiB
TypeScript
232 lines
8.4 KiB
TypeScript
/**
|
|
* ADR-124 §3 Architecture — Streamable HTTP transport security tests.
|
|
*
|
|
* Tests the Origin-validation middleware and bearer-token auth gate.
|
|
* No live MCP server needed for the guard logic — buildHttpApp is tested
|
|
* with a minimal stub McpServer that never actually processes JSON-RPC.
|
|
*
|
|
* Covered:
|
|
* 1. isOriginAllowed() unit tests — the pure function driving the gate
|
|
* 2. POST /mcp with cross-origin Origin → 403
|
|
* 3. POST /mcp with allowed Origin → passes Origin gate (non-403)
|
|
* 4. POST /mcp with no Origin header → passes Origin gate (non-403)
|
|
* 5. Bearer token required, wrong token → 401
|
|
* 6. Bearer token required, correct token + wildcard origin → passes (non-401)
|
|
*/
|
|
|
|
import * as http from "node:http";
|
|
import { isOriginAllowed, buildHttpApp } from "../src/http-transport.js";
|
|
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function makeMockMcpServer(): McpServer {
|
|
return new McpServer(
|
|
{ name: "test-rvagent", version: "0.0.0" },
|
|
{ capabilities: { tools: {} } }
|
|
);
|
|
}
|
|
|
|
async function post(
|
|
port: number,
|
|
path: string,
|
|
headers: Record<string, string>,
|
|
body: string
|
|
): Promise<{ status: number; body: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request(
|
|
{
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
method: "POST",
|
|
path,
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
},
|
|
(res) => {
|
|
let data = "";
|
|
res.on("data", (chunk: Buffer) => { data += chunk.toString(); });
|
|
res.on("end", () => resolve({ status: res.statusCode ?? 0, body: data }));
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function startServer(
|
|
opts: Parameters<typeof buildHttpApp>[1],
|
|
basePort: number
|
|
): Promise<{ port: number; close: () => Promise<void> }> {
|
|
const port = basePort + Math.floor(Math.random() * 100);
|
|
// Factory, not instance: each Streamable-HTTP session gets its own MCP
|
|
// Server (ADR-264 F7/O3).
|
|
const { httpServer } = buildHttpApp(() => makeMockMcpServer(), opts);
|
|
await new Promise<void>((resolve, reject) => {
|
|
httpServer.once("error", reject);
|
|
httpServer.listen(port, "127.0.0.1", () => resolve());
|
|
});
|
|
const close = () =>
|
|
new Promise<void>((res, rej) =>
|
|
httpServer.close((e) => (e ? rej(e) : res()))
|
|
);
|
|
return { port, close };
|
|
}
|
|
|
|
const MCP_BODY = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
|
|
// ── 1. isOriginAllowed unit tests ──────────────────────────────────────────
|
|
|
|
describe("isOriginAllowed()", () => {
|
|
const allow = ["http://localhost", "http://127.0.0.1"];
|
|
|
|
it("allows undefined origin (non-browser request, no Origin header)", () => {
|
|
expect(isOriginAllowed(undefined, allow)).toBe(true);
|
|
});
|
|
|
|
it("allows an origin in the allowlist", () => {
|
|
expect(isOriginAllowed("http://localhost", allow)).toBe(true);
|
|
expect(isOriginAllowed("http://127.0.0.1", allow)).toBe(true);
|
|
});
|
|
|
|
it("rejects an origin NOT in the allowlist", () => {
|
|
expect(isOriginAllowed("https://evil.example.com", allow)).toBe(false);
|
|
});
|
|
|
|
it("allows anything when allowedOrigins includes '*'", () => {
|
|
expect(isOriginAllowed("https://evil.example.com", ["*"])).toBe(true);
|
|
});
|
|
|
|
// ADR-264 F7: real browser origins carry ports — localhost must match on
|
|
// hostname, any port, even with an empty allowlist.
|
|
it("allows localhost origins on any port", () => {
|
|
expect(isOriginAllowed("http://localhost:5173", [])).toBe(true);
|
|
expect(isOriginAllowed("http://127.0.0.1:8080", [])).toBe(true);
|
|
expect(isOriginAllowed("https://localhost:3001", [])).toBe(true);
|
|
});
|
|
|
|
it("rejects non-local origins even with a localhost-looking prefix", () => {
|
|
expect(isOriginAllowed("http://localhost.evil.example.com", [])).toBe(false);
|
|
expect(isOriginAllowed("https://evil.example.com:443", [])).toBe(false);
|
|
});
|
|
|
|
it("is case-sensitive for non-local allowlist entries per RFC 6454", () => {
|
|
expect(isOriginAllowed("HTTPS://Partner.Example.com", ["https://partner.example.com"])).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── 2-4. Origin-validation integration tests ───────────────────────────────
|
|
|
|
describe("HTTP transport Origin-validation middleware", () => {
|
|
let port: number;
|
|
let close: () => Promise<void>;
|
|
|
|
beforeAll(async () => {
|
|
const srv = await startServer(
|
|
{ allowedOrigins: ["http://localhost", "http://127.0.0.1"] },
|
|
49200
|
|
);
|
|
port = srv.port;
|
|
close = srv.close;
|
|
});
|
|
|
|
afterAll(async () => { await close(); });
|
|
|
|
it("rejects cross-origin POST /mcp with 403", async () => {
|
|
const r = await post(port, "/mcp", { Origin: "https://evil.example.com" }, MCP_BODY);
|
|
expect(r.status).toBe(403);
|
|
const body = JSON.parse(r.body) as Record<string, unknown>;
|
|
expect(body["error"]).toMatch(/cross-origin/i);
|
|
});
|
|
|
|
it("passes Origin gate for http://localhost — status is not 403", async () => {
|
|
const r = await post(port, "/mcp", { Origin: "http://localhost" }, MCP_BODY);
|
|
expect(r.status).not.toBe(403);
|
|
});
|
|
|
|
it("passes Origin gate with no Origin header — status is not 403", async () => {
|
|
const r = await post(port, "/mcp", {}, MCP_BODY);
|
|
expect(r.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
// ── 5-6. Bearer-token auth integration tests ──────────────────────────────
|
|
|
|
describe("HTTP transport bearer-token auth gate", () => {
|
|
const SECRET = "test-secret-token-xyz";
|
|
let port: number;
|
|
let close: () => Promise<void>;
|
|
|
|
beforeAll(async () => {
|
|
const srv = await startServer({ allowedOrigins: ["*"], bearerToken: SECRET }, 49400);
|
|
port = srv.port;
|
|
close = srv.close;
|
|
});
|
|
|
|
afterAll(async () => { await close(); });
|
|
|
|
it("rejects missing Authorization header with 401", async () => {
|
|
const r = await post(port, "/mcp", {}, MCP_BODY);
|
|
expect(r.status).toBe(401);
|
|
});
|
|
|
|
it("rejects wrong bearer token with 401", async () => {
|
|
const r = await post(port, "/mcp", { Authorization: "Bearer wrong" }, MCP_BODY);
|
|
expect(r.status).toBe(401);
|
|
});
|
|
|
|
it("passes auth gate with correct bearer token — status is not 401", async () => {
|
|
const r = await post(port, "/mcp", { Authorization: `Bearer ${SECRET}` }, MCP_BODY);
|
|
expect(r.status).not.toBe(401);
|
|
});
|
|
});
|
|
|
|
// ── 7. ADR-264 F7/O3 hardening: body cap + per-session routing ─────────────
|
|
|
|
describe("HTTP transport session + body-cap hardening (ADR-264 F7)", () => {
|
|
let port: number;
|
|
let close: () => Promise<void>;
|
|
|
|
beforeAll(async () => {
|
|
const srv = await startServer({ allowedOrigins: ["*"], maxBodyBytes: 64 * 1024 }, 49600);
|
|
port = srv.port;
|
|
close = srv.close;
|
|
});
|
|
|
|
afterAll(async () => { await close(); });
|
|
|
|
it("rejects oversized request bodies with 413", async () => {
|
|
const huge = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "x", params: { pad: "y".repeat(128 * 1024) } });
|
|
const r = await post(port, "/mcp", {}, huge);
|
|
expect(r.status).toBe(413);
|
|
});
|
|
|
|
it("rejects a non-initialize POST without a session id with 400 (never a shared transport)", async () => {
|
|
const r = await post(port, "/mcp", {}, MCP_BODY); // tools/list, no mcp-session-id
|
|
expect(r.status).toBe(400);
|
|
const body = JSON.parse(r.body) as Record<string, unknown>;
|
|
expect(body["error"]).toMatch(/initialize/i);
|
|
});
|
|
|
|
it("rejects a POST with an unknown session id with 404", async () => {
|
|
const r = await post(port, "/mcp", { "mcp-session-id": "no-such-session" }, MCP_BODY);
|
|
expect(r.status).toBe(404);
|
|
});
|
|
|
|
it("creates a fresh session (and MCP server) per initialize request", async () => {
|
|
const init = JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "initialize",
|
|
params: {
|
|
protocolVersion: "2024-11-05",
|
|
capabilities: {},
|
|
clientInfo: { name: "test-client", version: "0.0.0" },
|
|
},
|
|
});
|
|
const r = await post(port, "/mcp", { Accept: "application/json, text/event-stream" }, init);
|
|
expect([200, 406]).not.toContain(0); // sanity
|
|
expect(r.status).toBe(200);
|
|
});
|
|
});
|