156 lines
14 KiB
TypeScript
156 lines
14 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import { createHash } from "node:crypto"
|
|
import { spawn } from "node:child_process"
|
|
import { createServer } from "node:net"
|
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
import { resolve, isAbsolute } from "node:path"
|
|
import { SignJWT } from "jose"
|
|
import { writeFile } from "node:fs/promises"
|
|
assert.equal(process.env.OWN_EXTERNAL_PROCESS_PG, "true"); assert.equal(process.env.NODE_ENV, "test")
|
|
assert(process.env.OWN_EXTERNAL_SOURCE_ROOT && isAbsolute(process.env.OWN_EXTERNAL_SOURCE_ROOT))
|
|
assert(process.env.OWN_EXTERNAL_RESULT_FILE && isAbsolute(process.env.OWN_EXTERNAL_RESULT_FILE))
|
|
const source = (path: string) => pathToFileURL(resolve(process.env.OWN_EXTERNAL_SOURCE_ROOT!, "backend/src", path)).href
|
|
const database = new URL(process.env.DATABASE_URL ?? "")
|
|
assert.equal(database.hostname, "127.0.0.1"); assert.equal(database.username, "mcp_test"); assert.equal(database.pathname, "/postgres"); assert.notEqual(database.port, "5432")
|
|
const listener = createServer(); await new Promise<void>(r => listener.listen(0, "127.0.0.1", r)); const port = (listener.address() as { port: number }).port
|
|
await new Promise<void>(r => listener.close(() => r()))
|
|
const origin = `http://127.0.0.1:${port}`, endpoint = origin + "/mcp"
|
|
process.env.MCP_PUBLIC_URL = endpoint; process.env.MCP_UI_URL = origin; process.env.CORS_ORIGIN = origin
|
|
const { pool, closePool } = await import(source("db/index.js"))
|
|
const { applySqlMigrations } = await import(source("db/migrate.js"))
|
|
const { mcpConnectionCreateSchema } = await import("@mmapp/contracts/mcp")
|
|
const { createMcpConnection, rotateConnectionToken, setConnectionStatus, updateMcpSettings } = await import(source("modules/mcp/store.js"))
|
|
const { env } = await import(source("config.js"))
|
|
const { buildApp } = await import(source("index.js"))
|
|
const actor = { id: "owned-external-process-owner", email: "external@example.invalid", name: "Owned external SDK fixture", apps: ["mm"],
|
|
permissions: ["mm:settings:admin", "mm:mcp:admin", "mm:servers:admin"], expiresAt: Date.now() + 3600000, issuedAt: Date.now() - 1000 }
|
|
let currentId: string | undefined, app: Awaited<ReturnType<typeof buildApp>> | undefined, child: ReturnType<typeof spawn> | undefined
|
|
const credentials: string[] = []
|
|
const bridgePids = new Set<number>()
|
|
let childExit: Promise<number> | undefined
|
|
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
|
function alive(pid: number) { try { process.kill(pid, 0); return true } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; throw error } }
|
|
async function drainChildAndBridges() {
|
|
if (child && child.exitCode === null && child.signalCode === null) { child.kill(); await Promise.race([childExit, delay(5000).then(() => { throw Error("Owned external child failed to exit") })]) }
|
|
const deadline = Date.now() + 5000
|
|
while ([...bridgePids].some(alive) && Date.now() < deadline) await delay(25)
|
|
const remaining = [...bridgePids].filter(alive)
|
|
for (const pid of remaining) process.kill(pid)
|
|
const killDeadline = Date.now() + 5000
|
|
while (remaining.some(alive) && Date.now() < killDeadline) await delay(25)
|
|
assert(!remaining.some(alive), "Owned SDK bridge grandchild failed to exit")
|
|
console.log("Owned client and all observed production bridge processes exited:", JSON.stringify({ childPid: child?.pid, exitCode: child?.exitCode, signalCode: child?.signalCode, bridgePids: [...bridgePids] }))
|
|
}
|
|
try {
|
|
await applySqlMigrations(pool)
|
|
for (const table of ["traffic_settings", "uptime_settings", "traffic_flow_settings", "servers_api_ping_settings", "certificate_renew_settings", "backup_schedule_settings", "geoip_settings", "internet_path_settings", "router_log_settings"]) await pool.query(`UPDATE ${table} SET enabled=false WHERE id=1`)
|
|
const secret = "OWNED_DEVICE_CREDENTIAL_MUST_NOT_ESCAPE"
|
|
const allowed = (await pool.query("INSERT INTO servers(name,host,enabled,password) VALUES('owned allowed','192.0.2.1',false,$1) RETURNING id", [secret])).rows[0].id
|
|
const forbidden = (await pool.query("INSERT INTO servers(name,host,enabled) VALUES('owned forbidden','192.0.2.2',false) RETURNING id")).rows[0].id
|
|
await updateMcpSettings({ enabled: true }, actor)
|
|
app = await buildApp({ startScheduler: false, logger: false }); await app.listen({ port, host: "127.0.0.1" })
|
|
const jwt = await new SignJWT({ sub: actor.id, apps: actor.apps, permissions: actor.permissions }).setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret))
|
|
const portalHeaders = { authorization: `Bearer ${jwt}`, "content-type": "application/json" }
|
|
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_connections WHERE owner_id=$1", [actor.id])).rows[0].count, 0)
|
|
const callback = "http://localhost:45678/callback?registered=1"
|
|
const registration = await fetch(origin + "/api/mcp/oauth/clients", { method: "POST", headers: portalHeaders,
|
|
body: JSON.stringify({ name: "Owned denial fixture", redirectUris: [callback] }) })
|
|
assert.equal(registration.status, 200)
|
|
const { clientId } = await registration.json() as { clientId: string }
|
|
const consent = (payload: Record<string, unknown>, headers = portalHeaders) => fetch(origin + "/api/mcp/oauth/consent", { method: "POST", headers, body: JSON.stringify(payload) })
|
|
const authorize = async (state: string) => {
|
|
const parameters = new URLSearchParams({ client_id: clientId, redirect_uri: callback, response_type: "code", resource: endpoint,
|
|
code_challenge_method: "S256", code_challenge: createHash("sha256").update("v".repeat(64)).digest("base64url"), state })
|
|
const response = await fetch(`${origin}/oauth/authorize?${parameters}`, { redirect: "manual" })
|
|
assert.equal(response.status, 302)
|
|
const requestId = new URL(response.headers.get("location")!).searchParams.get("oauthRequest")
|
|
assert.ok(requestId)
|
|
return requestId
|
|
}
|
|
const requestId = await authorize("deny-without-connection")
|
|
assert.equal((await consent({ requestId, approve: false }, { ...portalHeaders, authorization: "" })).status, 401)
|
|
assert.equal((await consent({ requestId, approve: true })).status, 400)
|
|
const refusals = await Promise.all([consent({ requestId, approve: false }), consent({ requestId, approve: false })])
|
|
assert.deepEqual(refusals.map(response => response.status).sort(), [200, 404])
|
|
const { redirectUrl } = await refusals.find(response => response.status === 200)!.json() as { redirectUrl: string }
|
|
const redirect = new URL(redirectUrl)
|
|
const expected = new URL(callback)
|
|
expected.searchParams.set("state", "deny-without-connection")
|
|
expected.searchParams.set("iss", origin)
|
|
expected.searchParams.set("error", "access_denied")
|
|
assert.equal(redirect.href, expected.href)
|
|
const otherOwner = { ...actor, id: "owned-external-process-other-owner" }
|
|
const unrelated = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Unrelated paused connection", mode: "observer",
|
|
expiresAt: new Date(Date.now() + 600000).toISOString(), permissions: ["mm:servers:read"],
|
|
scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false } }), otherOwner)
|
|
await setConnectionStatus(unrelated.connection.id, "paused", otherOwner)
|
|
const legacyRequestId = await authorize("legacy-denial")
|
|
const legacy = await consent({ requestId: legacyRequestId, connectionId: unrelated.connection.id, approve: false })
|
|
assert.equal(legacy.status, 200)
|
|
assert.equal((await consent({ requestId: legacyRequestId, connectionId: unrelated.connection.id, approve: false })).status, 404)
|
|
const expired = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Expired connection", mode: "observer",
|
|
expiresAt: new Date(Date.now() + 600000).toISOString(), permissions: ["mm:servers:read"],
|
|
scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false } }), actor)
|
|
await pool.query("UPDATE mcp_connections SET policy=jsonb_set(policy,'{expiresAt}',to_jsonb($2::text)) WHERE id=$1", [expired.connection.id, "2000-01-01T00:00:00Z"])
|
|
const expiredRequestId = await authorize("expired-approval")
|
|
assert.equal((await consent({ requestId: expiredRequestId, connectionId: expired.connection.id, approve: true })).status, 403)
|
|
const expiredRequest = (await pool.query("SELECT consumed_at,code_digest,connection_id FROM mcp_oauth_requests WHERE id=$1", [expiredRequestId])).rows[0]
|
|
assert.deepEqual(expiredRequest, { consumed_at: null, code_digest: null, connection_id: null })
|
|
assert.equal((await consent({ requestId: expiredRequestId, approve: false })).status, 200)
|
|
const deniedRows = (await pool.query("SELECT consumed_at,code_digest,connection_id FROM mcp_oauth_requests WHERE client_id=$1", [clientId])).rows
|
|
assert.equal(deniedRows.length, 3)
|
|
assert(deniedRows.every(row => row.consumed_at instanceof Date && row.code_digest === null && row.connection_id === null))
|
|
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_credentials WHERE client_id=$1", [clientId])).rows[0].count, 0)
|
|
const denialAudit = (await pool.query("SELECT actor_id,connection_id,payload FROM mcp_audit WHERE action='oauth.consent' AND payload->>'clientId'=$1", [clientId])).rows
|
|
assert.equal(denialAudit.length, 3)
|
|
assert(denialAudit.every(row => row.actor_id === actor.id && row.connection_id === null && row.payload.approved === false))
|
|
console.log("OAuth consent: expired approval refused before code issuance, denial uses exact redirect and one-use request without credentials")
|
|
const transcript: unknown[] = []; let failure: unknown, result: unknown
|
|
child = spawn(process.execPath, [fileURLToPath(new URL("./client.mjs", import.meta.url))], { cwd: process.cwd(), windowsHide: true, stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
env: { ...Object.fromEntries(["SystemRoot", "WINDIR", "TEMP", "TMP", "PATH", "Path"].filter(k => process.env[k]).map(k => [k, process.env[k]!])),
|
|
OWN_EXTERNAL_CLIENT: "true", OWN_MCP_ENDPOINT: endpoint, OWN_SERVER_PID: String(process.pid), OWN_ALLOWED_SERVER: String(allowed), OWN_FORBIDDEN_SERVER: String(forbidden),
|
|
OWN_BRIDGE_FILE: fileURLToPath(new URL(source("scripts/mcp-stdio.ts"))), OWN_PORTAL_JWT: jwt, OWN_SERVER_SECRET: secret, OWN_EXTERNAL_CLEANUP_FAULT: process.env.OWN_EXTERNAL_CLEANUP_FAULT } })
|
|
childExit = new Promise<number>(resolve => { child!.once("exit", status => resolve(status ?? 1)); child!.once("error", () => resolve(1)) })
|
|
let stderr = "", stdout = ""
|
|
child.stderr?.on("data", chunk => { stderr += String(chunk); if (stderr.length > 1048576) child?.kill() })
|
|
child.stdout?.on("data", chunk => { stdout += String(chunk); if (stdout.length > 1048576) child?.kill() })
|
|
child.on("message", async (raw: unknown) => {
|
|
const message = raw as { kind: string; command?: string; id?: number; pid?: number; result?: unknown; phase?: string; errorType?: string }
|
|
if (message.kind === "bridge-pid") { assert(Number.isSafeInteger(message.pid) && message.pid! > 0 && message.pid !== process.pid && message.pid !== child?.pid); bridgePids.add(message.pid!); return }
|
|
if (message.kind === "arm-cleanup-timeout" && process.env.OWN_EXTERNAL_CLEANUP_FAULT === "timeout") { setTimeout(() => { failure = { phase: "owned-deliberate-cleanup-timeout" }; child?.kill() }, 25); return }
|
|
if (message.kind === "result") { result = message.result; return }
|
|
if (message.kind === "failure") { failure = { phase: message.phase, errorType: message.errorType }; return }
|
|
if (message.kind !== "control" || !Number.isSafeInteger(message.id)) return
|
|
try {
|
|
let value: unknown
|
|
if (message.command === "new-connection") {
|
|
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Owned external SDK process", mode: "observer", expiresAt: new Date(Date.now() + 1200000).toISOString(),
|
|
permissions: ["mm:servers:read"], scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false }, limits: { callsPerMinute: 200 } }), actor)
|
|
currentId = issued.connection.id; credentials.push(issued.token); value = { token: issued.token, connectionId: currentId }
|
|
} else if (message.command === "large-result" || message.command === "reset-result") {
|
|
await pool.query("UPDATE servers SET comment=$2 WHERE id=$1", [allowed, message.command === "large-result" ? "Z".repeat(40000) + "OWNED_TRUNCATION_TAIL" : ""])
|
|
value = { ok: true }
|
|
} else {
|
|
assert(currentId)
|
|
if (message.command === "rotate") { const rotated = await rotateConnectionToken(currentId, actor); credentials.push(rotated.token); value = rotated }
|
|
else if (message.command === "pause" || message.command === "resume" || message.command === "revoke") { await setConnectionStatus(currentId, message.command === "pause" ? "paused" : message.command === "resume" ? "active" : "revoked", actor); value = { ok: true } }
|
|
else throw Error("Unsupported exact fixture control")
|
|
}
|
|
transcript.push({ command: message.command, connectionId: currentId })
|
|
child?.send({ kind: "reply", id: message.id, value })
|
|
} catch { child?.send({ kind: "reply", id: message.id, error: true }) }
|
|
})
|
|
const timer = setTimeout(() => { failure = { phase: "whole-external-client-deadline" }; child?.kill() }, 120000)
|
|
const code = await childExit; clearTimeout(timer)
|
|
if (code !== 0 || !result) { console.log("OWNED_EXTERNAL_PROCESS_FAILURE", JSON.stringify({ code, failure, stdout: stdout.slice(0,1000), stderr: stderr.slice(0,1000) })); throw Error("External SDK process did not complete") }
|
|
const logs = JSON.stringify((await pool.query("SELECT tool,input,result,error_code FROM mcp_requests ORDER BY created_at")).rows)
|
|
for (const token of [...credentials, jwt, secret]) { assert(!logs.includes(token)); assert(!stdout.includes(token)); assert(!stderr.includes(token)) }
|
|
const final = { ...(result as object), actualOwnPg: true, actualProductionHttpServer: true, serverFixturePid: process.pid,
|
|
controlTransitions: transcript, auditCredentialLeakAbsent: true, denialWithoutConnection: true,
|
|
legacyDenialIgnoresPausedForeignConnection: true, nativeRouterCalls: 0, productionDatabaseTouched: false }
|
|
await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2))
|
|
console.log("Owned external process SDK result:", JSON.stringify(final))
|
|
} finally {
|
|
try { await drainChildAndBridges() } finally { try { if (app) await app.close() } finally { await closePool() } }
|
|
}
|