diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index cf6afb040f..6666c5fceb 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1702,6 +1702,8 @@ paths: type: array items: $ref: "#/components/schemas/ApiKey" + "401": + description: Authentication required post: tags: [API Keys] summary: Create API key @@ -1718,8 +1720,43 @@ paths: responses: "201": description: Created API key (includes full key value) + "401": + description: Authentication required /api/keys/{id}: + get: + tags: [API Keys] + summary: Get API key + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: API key metadata + "401": + description: Authentication required + "404": + description: Key not found + patch: + tags: [API Keys] + summary: Update API key + parameters: + - $ref: "#/components/parameters/ResourceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + "200": + description: API key settings updated + "400": + description: Invalid update request + "401": + description: Authentication required + "404": + description: Key not found delete: tags: [API Keys] summary: Delete API key @@ -1728,6 +1765,10 @@ paths: responses: "200": description: Key deleted + "401": + description: Authentication required + "404": + description: Key not found /api/combos: get: @@ -3444,11 +3485,18 @@ paths: properties: password: type: string + minLength: 1 responses: "200": description: JWT token returned + "400": + description: Invalid login request "401": description: Invalid password + "403": + description: Password setup required + "429": + description: Too many failed attempts /api/auth/logout: post: diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index a7afff2d7a..464d712045 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -125,6 +125,11 @@ const EXTRA_MODULE_ENTRIES = [ src: ["scripts", "dev", "peer-stamp.mjs"], dest: ["peer-stamp.mjs"], }, + { + label: "HTTP method guard (server-ws.mjs dependency)", + src: ["scripts", "dev", "http-method-guard.cjs"], + dest: ["http-method-guard.cjs"], + }, { label: "responses-ws-proxy (server-ws.mjs dependency)", src: ["scripts", "dev", "responses-ws-proxy.mjs"], diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 499f91d657..5b7c66106f 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -35,6 +35,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ ".env.example", "BUILD_SHA", "docs/reference/openapi.yaml", + "http-method-guard.cjs", "open-sse/mcp-server/server.js", // LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads // (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server. @@ -124,6 +125,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "dist/server-ws.mjs", "dist/responses-ws-proxy.mjs", "dist/peer-stamp.mjs", + "dist/http-method-guard.cjs", "dist/webdav-handler.mjs", "bin/cli/program.mjs", "bin/mcp-server.mjs", diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index 729a88a0f5..bd8612adfa 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -40,6 +40,7 @@ const ROOT = join(__dirname, "..", ".."); const NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx"; const DIST_DIR = join(ROOT, "dist"); +const METHOD_GUARD_REQUIRE = 'require("./http-method-guard.cjs").installHttpMethodGuard();\n'; function walkFiles(dir: string, rootDir: string = dir, files: string[] = []): string[] { let entries: string[] = []; @@ -153,6 +154,20 @@ assembleStandalone({ }); console.log(" ✅ Standalone bundle assembled to dist/"); +const distServer = join(DIST_DIR, "server.js"); +const methodGuardSrc = join(ROOT, "scripts", "dev", "http-method-guard.cjs"); +const methodGuardDest = join(DIST_DIR, "http-method-guard.cjs"); +if (existsSync(methodGuardSrc)) { + cpSync(methodGuardSrc, methodGuardDest); +} +if (existsSync(distServer)) { + const serverSource = readFileSync(distServer, "utf8"); + if (!serverSource.includes("installHttpMethodGuard")) { + writeFileSync(distServer, METHOD_GUARD_REQUIRE + serverSource); + console.log(" ✅ Patched dist/server.js with HTTP method guard."); + } +} + // ── Step 8: Compile + copy MITM cert utilities ───────────── const mitmSrc = join(ROOT, "src", "mitm"); const mitmDest = join(DIST_DIR, "src", "mitm"); diff --git a/scripts/dev/http-method-guard.cjs b/scripts/dev/http-method-guard.cjs new file mode 100644 index 0000000000..f46051cae8 --- /dev/null +++ b/scripts/dev/http-method-guard.cjs @@ -0,0 +1,82 @@ +"use strict"; + +const http = require("node:http"); + +const HIGH_RISK_METHOD_RULES = [ + [/^\/api\/auth\/login\/?$/, ["POST"]], + [/^\/api\/auth\/logout\/?$/, ["POST"]], + [/^\/api\/keys\/?$/, ["GET", "POST"]], + [/^\/api\/keys\/[^/]+\/?$/, ["GET", "PATCH", "DELETE"]], +]; + +let installed = false; + +function getPathname(req) { + const rawUrl = typeof req?.url === "string" && req.url ? req.url : "/"; + try { + return new URL(rawUrl, "http://localhost").pathname; + } catch { + return rawUrl.split("?")[0] || "/"; + } +} + +function getAllowedMethods(pathname) { + for (const [pattern, methods] of HIGH_RISK_METHOD_RULES) { + if (pattern.test(pathname)) return methods; + } + return null; +} + +function getAllowHeader(pathname) { + const methods = getAllowedMethods(pathname); + return methods ? methods.join(", ") : null; +} + +function maybeHandleDisallowedMethod(req, res) { + const method = typeof req?.method === "string" ? req.method.toUpperCase() : ""; + const pathname = getPathname(req); + const methods = getAllowedMethods(pathname); + if (!methods || method === "OPTIONS" || methods.includes(method)) return false; + + res.statusCode = 405; + res.setHeader("Allow", methods.join(", ")); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end( + JSON.stringify({ + error: { + code: "METHOD_NOT_ALLOWED", + message: `${method || "Method"} is not allowed`, + }, + }) + ); + return true; +} + +function wrapRequestListenerWithMethodGuard(listener) { + return function methodGuardRequestHandler(req, res) { + if (maybeHandleDisallowedMethod(req, res)) return; + return listener.call(this, req, res); + }; +} + +function installHttpMethodGuard() { + if (installed) return; + installed = true; + + const originalCreateServer = http.createServer.bind(http); + http.createServer = function createServerWithMethodGuard(...args) { + const lastFnIdx = args.map((arg) => typeof arg === "function").lastIndexOf(true); + if (lastFnIdx >= 0) { + args[lastFnIdx] = wrapRequestListenerWithMethodGuard(args[lastFnIdx]); + } + return originalCreateServer(...args); + }; +} + +module.exports = { + getAllowHeader, + maybeHandleDisallowedMethod, + wrapRequestListenerWithMethodGuard, + installHttpMethodGuard, +}; diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index 23a6d326ed..84ecf3f0aa 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -9,9 +9,12 @@ import { resolveRuntimePorts, withRuntimePortEnv } from "../build/runtime-env.mj import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs"; +import methodGuard from "./http-method-guard.cjs"; import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs"; import { randomUUID } from "node:crypto"; +const { maybeHandleDisallowedMethod } = methodGuard; + // Pre-read DATA_DIR from local .env before bootstrap resolves paths if (!process.env.DATA_DIR) { try { @@ -83,6 +86,7 @@ async function start() { }); const server = http.createServer((req, res) => { + if (maybeHandleDisallowedMethod(req, res)) return; // Stamp the real TCP peer IP before Next sees the request, so the authz // middleware can decide LOCAL_ONLY locality without trusting the Host header. stampPeerIp(req); diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index b12f2093cf..ceb0fde088 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -3,9 +3,11 @@ import { randomUUID } from "node:crypto"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs"; import { maybeHandleWebdav } from "./webdav-handler.mjs"; +import methodGuard from "./http-method-guard.cjs"; const originalCreateServer = http.createServer.bind(http); const proxiesByPort = new Map(); +const { wrapRequestListenerWithMethodGuard } = methodGuard; process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID(); // Per-process secret proving the trusted peer-IP stamp came from this server. @@ -71,9 +73,9 @@ http.createServer = function createServerWithResponsesWs(...args) { // createServer; wrap it so the real TCP peer IP is stamped before Next runs. const lastFnIdx = args.map((a) => typeof a === "function").lastIndexOf(true); if (lastFnIdx >= 0) { - // WebDAV intercept wraps outermost (first to run), then peer-stamp, then Next. - args[lastFnIdx] = wrapRequestListenerWithWebdav( - wrapRequestListenerWithPeerStamp(args[lastFnIdx]) + // Method guard runs before Next because Next 16 rejects TRACE while constructing requests. + args[lastFnIdx] = wrapRequestListenerWithMethodGuard( + wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(args[lastFnIdx])) ); } @@ -89,7 +91,9 @@ http.createServer = function createServerWithResponsesWs(...args) { if (eventName === "request" && typeof listener === "function") { return originalOn( eventName, - wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener)) + wrapRequestListenerWithMethodGuard( + wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener)) + ) ); } return originalOn(eventName, listener); @@ -102,7 +106,9 @@ http.createServer = function createServerWithResponsesWs(...args) { if (eventName === "request" && typeof listener === "function") { return originalAddListener( eventName, - wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener)) + wrapRequestListenerWithMethodGuard( + wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener)) + ) ); } return originalAddListener(eventName, listener); diff --git a/src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx b/src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx index 5796fc90fb..02365225fa 100644 --- a/src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx +++ b/src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx @@ -2,18 +2,18 @@ // src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx -import { useState } from "react"; +import dynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; +import { useState } from "react"; +import type { StreamMetrics } from "@/shared/schemas/playground"; import StudioTopBar, { type StudioTab } from "./components/StudioTopBar"; import StudioConfigPane, { type ConfigState } from "./components/StudioConfigPane"; import { DEFAULT_PARAMS } from "./components/ParamSliders"; -import dynamic from "next/dynamic"; -import type { StreamMetrics } from "@/shared/schemas/playground"; +import CompareTab from "./components/tabs/CompareTab"; // Lazy-load tabs to reduce initial bundle size const ChatTab = dynamic(() => import("./components/tabs/ChatTab"), { ssr: false }); const ApiTab = dynamic(() => import("./components/tabs/ApiTab"), { ssr: false }); -const CompareTab = dynamic(() => import("./components/tabs/CompareTab"), { ssr: false }); const BuildTab = dynamic(() => import("./components/tabs/BuildTab"), { ssr: false }); const INITIAL_METRICS: StreamMetrics = { @@ -92,23 +92,14 @@ export function PlaygroundStudio() { {effectiveTab === "chat" && ( )} - {effectiveTab === "compare" && ( - - )} - {effectiveTab === "api" && ( - - )} - {effectiveTab === "build" && ( - - )} + {effectiveTab === "compare" && } + {effectiveTab === "api" && } + {effectiveTab === "build" && } {/* Config pane — always visible, collapsible */} {/* SLOT_PRESETS and SLOT_IMPROVE are inside StudioConfigPane */} - + ); diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx index 8f208b8460..a06b1a7cff 100644 --- a/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx @@ -31,6 +31,13 @@ const INITIAL_METRICS: StreamMetrics = { costUsd: null, }; +function createColumnId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `compare-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + /** * ColumnMetricsTracker — plain (non-React) imperative object for tracking per-column stream metrics. * Uses simple mutable state (no React hooks) so it can be stored in a ref and called safely @@ -91,7 +98,7 @@ class ColumnMetricsTracker { export default function CompareTab({ configState }: CompareTabProps) { const [columns, setColumns] = useState(() => [ { - id: crypto.randomUUID(), + id: createColumnId(), model: configState.model, status: "idle", metrics: INITIAL_METRICS, @@ -161,7 +168,7 @@ export default function CompareTab({ configState }: CompareTabProps) { function addColumn() { if (columns.length >= MAX_COLUMNS) return; const model = newModel.trim() || configState.model; - const id = crypto.randomUUID(); + const id = createColumnId(); setColumns((prev) => [ ...prev, { id, model, status: "idle", metrics: INITIAL_METRICS, response: "" }, @@ -266,8 +273,7 @@ export default function CompareTab({ configState }: CompareTabProps) { continue; } - const choices = - (parsed["choices"] as Array> | undefined) ?? []; + const choices = (parsed["choices"] as Array> | undefined) ?? []; const delta = choices[0]?.["delta"] as Record | undefined; const content = delta?.["content"]; diff --git a/tests/unit/dast-method-not-allowed.test.ts b/tests/unit/dast-method-not-allowed.test.ts new file mode 100644 index 0000000000..4a7f16e0d1 --- /dev/null +++ b/tests/unit/dast-method-not-allowed.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const { maybeHandleDisallowedMethod } = require("../../scripts/dev/http-method-guard.cjs"); + +test("raw HTTP guard rejects high-risk unsupported methods before Next.js handles them", () => { + const cases: Array<{ + label: string; + method: string; + url: string; + allow: string; + }> = [ + { label: "login TRACE", method: "TRACE", url: "/api/auth/login", allow: "POST" }, + { label: "login QUERY", method: "QUERY", url: "/api/auth/login", allow: "POST" }, + { label: "logout QUERY", method: "QUERY", url: "/api/auth/logout", allow: "POST" }, + { label: "keys QUERY", method: "QUERY", url: "/api/keys", allow: "GET, POST" }, + { + label: "key detail QUERY", + method: "QUERY", + url: "/api/keys/0", + allow: "GET, PATCH, DELETE", + }, + ]; + + for (const testCase of cases) { + let body = ""; + const headers = new Map(); + const response = { + statusCode: 200, + setHeader(name: string, value: string) { + headers.set(name.toLowerCase(), value); + }, + end(chunk: string) { + body += chunk; + }, + }; + + const handled = maybeHandleDisallowedMethod( + { method: testCase.method, url: testCase.url }, + response + ); + assert.equal(handled, true, testCase.label); + assert.equal(response.statusCode, 405, testCase.label); + assert.equal(headers.get("allow"), testCase.allow, testCase.label); + assert.match(body, /METHOD_NOT_ALLOWED/, testCase.label); + } +}); + +test("raw HTTP guard allows documented methods through", () => { + const response = { + setHeader() { + throw new Error("allowed methods should not write headers"); + }, + end() { + throw new Error("allowed methods should not end the response"); + }, + }; + + assert.equal( + maybeHandleDisallowedMethod({ method: "POST", url: "/api/auth/login" }, response), + false + ); + assert.equal(maybeHandleDisallowedMethod({ method: "GET", url: "/api/keys" }, response), false); + assert.equal( + maybeHandleDisallowedMethod({ method: "OPTIONS", url: "/api/keys" }, response), + false + ); + assert.equal( + maybeHandleDisallowedMethod({ method: "QUERY", url: "/api/health/ping" }, response), + false + ); +}); + +test("OpenAPI documents high-risk route auth and setup responses", () => { + const spec = readFileSync("docs/reference/openapi.yaml", "utf8"); + const apiKeyDetailStart = spec.indexOf(" /api/keys/{id}:"); + const apiKeyDetailEnd = spec.indexOf("\n /api/combos:", apiKeyDetailStart); + const apiKeyDetail = spec.slice(apiKeyDetailStart, apiKeyDetailEnd); + + assert.match(apiKeyDetail, /\n get:/); + assert.match(apiKeyDetail, /\n patch:/); + assert.match(apiKeyDetail, /\n delete:/); + assert.match(apiKeyDetail, /"401":\n\s+description: Authentication required/); + assert.match(apiKeyDetail, /"404":\n\s+description: Key not found/); + + const loginStart = spec.indexOf(" /api/auth/login:"); + const loginEnd = spec.indexOf("\n /api/auth/logout:", loginStart); + const login = spec.slice(loginStart, loginEnd); + assert.match(login, /"400":\n\s+description: Invalid login request/); + assert.match(login, /"401":\n\s+description: Invalid password/); + assert.match(login, /"403":\n\s+description: Password setup required/); + assert.match(login, /"429":\n\s+description: Too many failed attempts/); +}); diff --git a/tests/unit/v388-phase4-playground.test.ts b/tests/unit/v388-phase4-playground.test.ts index 40530c5196..6f68428ee2 100644 --- a/tests/unit/v388-phase4-playground.test.ts +++ b/tests/unit/v388-phase4-playground.test.ts @@ -20,15 +20,40 @@ test("playground compare: prompt input + rAF throttle + user message in request" assert.ok(src.includes("requestAnimationFrame"), "throttles stream updates via rAF"); assert.ok(/role:\s*"user"/.test(src), "request body includes a user message"); assert.ok(src.includes("setPrompt"), "has a prompt input control"); + assert.ok(src.includes("createColumnId"), "uses a browser-compatible column id helper"); + assert.ok(!src.includes("id: crypto.randomUUID()"), "does not call randomUUID inline"); +}); + +test("playground compare: tab is not lazy-loaded behind a click-time chunk", () => { + const src = read("src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx"); + assert.ok( + src.includes('import CompareTab from "./components/tabs/CompareTab"'), + "CompareTab is statically imported" + ); + assert.ok( + !src.includes('dynamic(() => import("./components/tabs/CompareTab")'), + "CompareTab is not loaded with next/dynamic" + ); }); test("playground build: wizard with 3 modes reusing editors; BuildTab keeps handlers", () => { - const wiz = read("src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx"); - assert.ok(wiz.includes('"tools"') && wiz.includes('"json"') && wiz.includes('"both"'), "three modes"); - assert.ok(wiz.includes("ToolsBuilder") && wiz.includes("StructuredOutputEditor"), "reuses both editors"); + const wiz = read( + "src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx" + ); + assert.ok( + wiz.includes('"tools"') && wiz.includes('"json"') && wiz.includes('"both"'), + "three modes" + ); + assert.ok( + wiz.includes("ToolsBuilder") && wiz.includes("StructuredOutputEditor"), + "reuses both editors" + ); const tab = read("src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx"); assert.ok(tab.includes(" {