diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 050e119..6c3c315 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -57,7 +57,9 @@ jobs: dist-test/test/live-smoke.test.js \ dist-test/test/client-unit.test.js \ dist-test/test/index-unit.test.js \ - dist-test/test/tools-unit.test.js + dist-test/test/tools-unit.test.js \ + dist-test/test/env-regex-unit.test.js \ + dist-test/test/input-hardening-unit.test.js - uses: actions/setup-python@v6 if: github.event_name == 'pull_request' with: diff --git a/package.json b/package.json index 707932e..53f5eb8 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,8 @@ "dev": "tsc --watch", "start": "node dist/index.js", "pretest": "tsc && tsc -p tsconfig.test.json", - "test": "node --test --experimental-test-coverage --test-coverage-exclude='dist-test/test/**' --test-coverage-exclude='dist/**' --test-coverage-exclude='node_modules/**' dist-test/test/integration.test.js dist-test/test/live-smoke.test.js dist-test/test/client-unit.test.js dist-test/test/index-unit.test.js dist-test/test/tools-unit.test.js dist-test/test/env-regex-unit.test.js", - "test:nocov": "node --test dist-test/test/integration.test.js dist-test/test/live-smoke.test.js dist-test/test/client-unit.test.js dist-test/test/index-unit.test.js dist-test/test/tools-unit.test.js dist-test/test/env-regex-unit.test.js", + "test": "node --test --experimental-test-coverage --test-coverage-exclude='dist-test/test/**' --test-coverage-exclude='dist/**' --test-coverage-exclude='node_modules/**' dist-test/test/integration.test.js dist-test/test/live-smoke.test.js dist-test/test/client-unit.test.js dist-test/test/index-unit.test.js dist-test/test/tools-unit.test.js dist-test/test/env-regex-unit.test.js dist-test/test/input-hardening-unit.test.js", + "test:nocov": "node --test dist-test/test/integration.test.js dist-test/test/live-smoke.test.js dist-test/test/client-unit.test.js dist-test/test/index-unit.test.js dist-test/test/tools-unit.test.js dist-test/test/env-regex-unit.test.js dist-test/test/input-hardening-unit.test.js", "test:smoke": "bash test.sh", "prepublishOnly": "npm run build" }, diff --git a/src/client.ts b/src/client.ts index d3c0996..e116733 100644 --- a/src/client.ts +++ b/src/client.ts @@ -466,15 +466,43 @@ export class ApiError extends Error { } } +/** + * Validate that a base URL is well-formed and uses http(s). BUG-MCP-040: + * `INSTANODE_API_URL=javascript:alert(1)` would otherwise produce mysterious + * failures deep in fetch — refuse it up-front with a clear stderr message and + * fall back to the default. Same intent as the CLI's safeBrowserURL. + */ +export function validateBaseURL(raw: string): string | null { + if (!raw || !raw.trim()) return null; + let u: URL; + try { + u = new URL(raw.trim()); + } catch { + return null; + } + const scheme = u.protocol.toLowerCase(); + if (scheme !== "http:" && scheme !== "https:") return null; + if (!u.host) return null; + return raw.trim(); +} + export class InstantClient { private readonly baseURL: string; constructor(opts: ClientOptions = {}) { - this.baseURL = ( - opts.baseURL ?? - process.env["INSTANODE_API_URL"] ?? - DEFAULT_BASE_URL - ).replace(/\/$/, ""); + const requested = + opts.baseURL ?? process.env["INSTANODE_API_URL"] ?? DEFAULT_BASE_URL; + const validated = validateBaseURL(requested); + if (validated === null && requested !== DEFAULT_BASE_URL) { + // Operator passed a bad URL via env / opts. Warn on stderr and fall + // back to the default rather than failing every subsequent call with + // an opaque fetch error. + process.stderr.write( + `instanode-mcp: refusing INSTANODE_API_URL=${JSON.stringify(requested)} ` + + `(must be http(s)://host). Falling back to ${DEFAULT_BASE_URL}.\n` + ); + } + this.baseURL = (validated ?? DEFAULT_BASE_URL).replace(/\/$/, ""); } /** diff --git a/src/index.ts b/src/index.ts index 114f5aa..f75bea6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -240,6 +240,66 @@ const envSchema = z "Resource environment scope: 'development' (server default — see CLAUDE.md convention #11 / migration 026), 'staging', or 'production'. Format: ^[a-z0-9-]{1,32}$ — lowercase letters, digits, and dashes only. Omitting `env` lands the resource in 'development' (lowest stakes). The response echoes the resolved `env` so callers can confirm the bucket." ); +// BUG-MCP-024/025: client-side UUID validation for resource tokens and +// deployment ids. The API itself rejects malformed tokens with a 400 + +// `invalid_token` envelope, but catching it client-side surfaces a precise +// zod error to the calling agent (no wasted round-trip, no opaque API error +// string). Accepts canonical 8-4-4-4-12 form, case-insensitive. +const UUID_REGEX = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +// BUG-MCP-022: client-side IP-or-CIDR validation for the `allowed_ips` field +// on create_deploy. Accepts: +// - IPv4 address (e.g. "203.0.113.42") +// - IPv4 CIDR (e.g. "10.0.0.0/8") +// - IPv6 address (e.g. "2001:db8::1") +// - IPv6 CIDR (e.g. "2001:db8::/32") +// Loose-but-targeted — full RFC 5952 grammar would be heavy for a ~15-line +// gain; the API still does authoritative validation. Goal here is to catch +// obvious typos (`192.168.1`, `::/0g`) before the multipart upload. +export function isIPOrCIDR(s: string): boolean { + if (s.length === 0 || s.length > 64) return false; + // Split off optional CIDR mask. + const slash = s.indexOf("/"); + const host = slash >= 0 ? s.slice(0, slash) : s; + const maskStr = slash >= 0 ? s.slice(slash + 1) : ""; + // IPv4 host check. + const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); + if (v4) { + for (let i = 1; i <= 4; i++) { + const n = Number(v4[i]); + if (!Number.isInteger(n) || n < 0 || n > 255) return false; + } + if (slash < 0) return true; + const m = Number(maskStr); + return Number.isInteger(m) && m >= 0 && m <= 32; + } + // IPv6 host check (very loose — hex groups separated by ':' with at most + // one '::'). Forbid anything non-hex / non-':' / non-'.' (IPv4-mapped). + if (!/^[0-9a-fA-F:.]+$/.test(host)) return false; + if ((host.match(/::/g) ?? []).length > 1) return false; + // Must contain at least one ':' to be considered v6. + if (!host.includes(":")) return false; + if (slash < 0) return true; + const m = Number(maskStr); + return Number.isInteger(m) && m >= 0 && m <= 128; +} + +const uuidSchema = z + .string() + .regex( + UUID_REGEX, + "must be a UUID in canonical 8-4-4-4-12 form (e.g. 8b1f3c9e-...-...)" + ); + +const ipOrCidrSchema = z + .string() + .min(1) + .refine(isIPOrCIDR, { + message: + "must be an IPv4/IPv6 address or CIDR (e.g. '203.0.113.42', '10.0.0.0/8', '2001:db8::/32')", + }); + const envArg = { env: envSchema, }; @@ -794,10 +854,9 @@ ACL / storage prefix cleanup automatically. Requires INSTANODE_TOKEN.`, { - token: z - .string() - .min(1) - .describe("Resource token (UUID) to delete."), + // BUG-MCP-024: validate the UUID shape client-side so a typo'd token + // surfaces as a zod error rather than an API 400 round-trip. + token: uuidSchema.describe("Resource token (UUID) to delete."), }, async ({ token }) => { try { @@ -927,11 +986,21 @@ tarball" hint instead of being uploaded and rejected server-side. Requires INSTANODE_TOKEN (anonymous tier cannot deploy).`, { + // BUG-MCP-020: cap the tarball at the API's documented 50 MiB limit + // BEFORE we POST. base64 inflates raw bytes by ~4/3 → a 50 MiB decoded + // tarball is ~66.7 MiB encoded. Reject anything over 70 MiB encoded so + // we surface a precise zod error instead of multiparting a payload that + // the API will only reject after upload. minLength:1 stays for the + // empty-string case. tarball_base64: z .string() .min(1) + .max( + 70 * 1024 * 1024, + "tarball_base64: encoded payload exceeds 70 MiB (≈50 MiB decoded). Shrink the tarball — strip .git, node_modules, build artifacts." + ) .describe( - "Base64-encoded gzip tarball of the project directory (must include a Dockerfile at the root). <50 MB after decode." + "Base64-encoded gzip tarball of the project directory (must include a Dockerfile at the root). <50 MB after decode (≈70 MiB encoded)." ), // BugBash B16 F2 (regression of task #173): same name regex as every other // create_* tool — mirrors the api's contract via nameSchema. @@ -986,14 +1055,46 @@ Requires INSTANODE_TOKEN (anonymous tier cannot deploy).`, .describe( "When true, the deploy is only reachable from IPs in 'allowed_ips'. Requires Pro tier or higher — anonymous and hobby callers get HTTP 402 with an agent_action prompting the user to upgrade. Use for CRMs, internal dashboards, staging apps." ), + // BUG-MCP-022: validate each entry is an IP or CIDR. Bound the array at + // 256 entries — Ingress allowlist of this size is already exotic; cap is + // here to prevent a hostile-host runaway. Authoritative check stays + // server-side; this catches obvious typos before the round-trip. allowed_ips: z - .array(z.string().min(1)) + .array(ipOrCidrSchema) + .max(256, "allowed_ips: at most 256 entries") .optional() .describe( - "IP / CIDR allowlist enforced at the Ingress when 'private' is true. Examples: ['1.2.3.4', '10.0.0.0/8', '203.0.113.42/32']. Required when private=true; ignored otherwise. If Track A's backend lands with a renamed field (e.g. 'allowed_cidrs'), this MCP tool will surface the 400 verbatim — see PR body." + "IP / CIDR allowlist enforced at the Ingress when 'private' is true. Examples: ['1.2.3.4', '10.0.0.0/8', '203.0.113.42/32']. Required when private=true; ignored otherwise. Max 256 entries; each must parse as IPv4/IPv6 address or CIDR." ), }, + // BUG-MCP-021: enforce the documented private+allowed_ips coupling + // client-side. The API rejects (private=true, allowed_ips=[]) with a 400, + // but doing it here makes the failure mode crisp and stops the agent from + // shipping a payload that's structurally guaranteed to fail. async (params) => { + if (params.private === true) { + if (!params.allowed_ips || params.allowed_ips.length === 0) { + return textResult( + [ + `create_deploy: private=true requires a non-empty allowed_ips list.`, + ``, + `Pass an array of IPs/CIDRs (e.g. ['203.0.113.42/32', '10.0.0.0/8'])`, + `or set private=false to make the deploy publicly reachable.`, + ].join("\n") + ); + } + } else if (params.allowed_ips && params.allowed_ips.length > 0) { + // The API silently drops allowed_ips when private=false; tell the + // agent so the misconfiguration is visible. + return textResult( + [ + `create_deploy: allowed_ips set but private=false (or omitted).`, + ``, + `Set private=true to enforce the allowlist, or remove allowed_ips`, + `to make the field's absence intentional.`, + ].join("\n") + ); + } try { const result = await client.createDeploy(params); const lines = [ @@ -1289,10 +1390,8 @@ single record. Requires INSTANODE_TOKEN.`, { - id: z - .string() - .min(1) - .describe("Deployment app id (returned as 'deploy_id' by create_deploy)."), + // BUG-MCP-025: validate UUID client-side. + id: uuidSchema.describe("Deployment app id (returned as 'deploy_id' by create_deploy)."), }, async ({ id }) => { try { @@ -1353,10 +1452,8 @@ to "running". Requires INSTANODE_TOKEN.`, { - id: z - .string() - .min(1) - .describe("Deployment app id (returned as 'deploy_id' by create_deploy)."), + // BUG-MCP-025: validate UUID client-side. + id: uuidSchema.describe("Deployment app id (returned as 'deploy_id' by create_deploy)."), }, async ({ id }) => { try { @@ -1391,10 +1488,8 @@ deleted. Irreversible. Requires INSTANODE_TOKEN.`, { - id: z - .string() - .min(1) - .describe("Deployment app id (returned as 'deploy_id' by create_deploy)."), + // BUG-MCP-025: validate UUID client-side. + id: uuidSchema.describe("Deployment app id (returned as 'deploy_id' by create_deploy)."), }, async ({ id }) => { try { @@ -1413,8 +1508,67 @@ Requires INSTANODE_TOKEN.`, } ); +// ── CLI flags (BUG-MCP-017) ──────────────────────────────────────────────────── + +// `instanode-mcp --version` and `instanode-mcp --help` short-circuit before +// the stdio transport binds, so operators / package managers can probe the +// binary without it sitting forever waiting on stdin. Stdout-friendly, exit +// 0. Unknown flags fall through to the normal stdio loop so a hostile MCP +// host can't force the process to exit by sending a stray "--whatever". +export function handleCLIFlags(argv: readonly string[]): boolean { + for (const a of argv) { + if (a === "-h" || a === "--help") { + process.stdout.write( + [ + `instanode-mcp ${pkgVersion}`, + `MCP server for instanode.dev — exposes provisioning + deploy tools to AI agents.`, + ``, + `Usage: instanode-mcp [--version] [--help]`, + ``, + `By default the binary speaks MCP over stdio. Configure it as the`, + `command in your Claude Code / Cursor / Windsurf MCP settings.`, + ``, + `Env vars:`, + ` INSTANODE_TOKEN Bearer token from the dashboard (paid-tier tools).`, + ` INSTANODE_API_URL Override the API base URL (default https://api.instanode.dev).`, + ` INSTANODE_DASHBOARD_URL Override the dashboard URL (default https://instanode.dev).`, + ``, + ].join("\n") + ); + return true; + } + if (a === "-v" || a === "--version") { + process.stdout.write(`${pkgVersion}\n`); + return true; + } + } + return false; +} + // ── Start server ────────────────────────────────────────────────────────────── +// maybeShortCircuit invokes handleCLIFlags(argv); if a CLI flag was handled, +// calls exit() to terminate the process. Returns true when it short-circuited, +// false otherwise. Extracted so unit tests can drive both branches without +// touching process.argv or actually exiting the test runner. +export function maybeShortCircuit( + argv: readonly string[], + exit: (code: number) => void = process.exit +): boolean { + if (handleCLIFlags(argv)) { + exit(0); + return true; + } + return false; +} + +// BUG-MCP-017: CLI flag short-circuit runs OUTSIDE the no-listen guard so the +// real binary (which never sets INSTANODE_MCP_NO_LISTEN) can be probed for +// --version / --help without sitting on stdin. The exported helper makes +// both the short-circuit-true and fall-through-false branches reachable +// from unit tests via an injected exit fn — see input-hardening-unit.test.ts. +maybeShortCircuit(process.argv.slice(2)); + // Unit tests import this module purely to reach the exported helpers // (formatError / formatLimits / appendUpgradeBlock) without binding to a real // stdio transport — set INSTANODE_MCP_NO_LISTEN=1 in that case. The CLI binary diff --git a/test/input-hardening-unit.test.ts b/test/input-hardening-unit.test.ts new file mode 100644 index 0000000..4328da2 --- /dev/null +++ b/test/input-hardening-unit.test.ts @@ -0,0 +1,345 @@ +/** + * input-hardening-unit.test.ts — regression coverage for: + * + * BUG-MCP-017: --version / --help flags + * BUG-MCP-020: tarball_base64 size cap (50 MiB decoded ≈ 70 MiB encoded) + * BUG-MCP-021: private + allowed_ips coupling enforcement + * BUG-MCP-022: allowed_ips CIDR validation + * BUG-MCP-024: delete_resource.token UUID validation + * BUG-MCP-025: get_deployment / redeploy / delete_deployment id UUID validation + * BUG-MCP-040: INSTANODE_API_URL scheme/host validation + * + * Each test pokes the zod input schema for the relevant tool via + * server._registeredTools so we cover the schema-level contract without + * having to spin up a real MCP transport. + */ + +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { before, describe, it } from "node:test"; + +process.env["INSTANODE_MCP_NO_LISTEN"] = "1"; + +const SERVER_ENTRY = resolve( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "dist", + "index.js" +); + +let server: any; +let handleCLIFlags: (argv: readonly string[]) => boolean; +let isIPOrCIDR: (s: string) => boolean; +let validateBaseURL: (raw: string) => string | null; +let maybeShortCircuit: (argv: readonly string[], exit?: (n: number) => void) => boolean; + +before(async () => { + const indexMod: any = await import("../src/index.js"); + server = indexMod.server; + handleCLIFlags = indexMod.handleCLIFlags; + isIPOrCIDR = indexMod.isIPOrCIDR; + maybeShortCircuit = indexMod.maybeShortCircuit; + const clientMod: any = await import("../src/client.js"); + validateBaseURL = clientMod.validateBaseURL; +}); + +function schemaFor(toolName: string, field: string): any { + const tool = server._registeredTools?.[toolName]; + assert.ok(tool, `tool ${toolName} not registered`); + // Zod v4 exposes the per-field shape via `.shape` (plain object). Mirrors + // env-regex-unit.test.ts. + const shape = tool.inputSchema?.shape ?? {}; + return shape[field]; +} + +// ── BUG-MCP-017: --version / --help ───────────────────────────────────────── + +// Spawn the real binary (no INSTANODE_MCP_NO_LISTEN) so the production +// short-circuit path at the bottom of index.ts is exercised — required for +// the 100% patch-coverage gate. Tests use --version (small output, exits 0). +describe("BUG-MCP-017: real-binary short-circuit", () => { + it("instanode-mcp --version writes the version to stdout and exits 0", () => { + const r = spawnSync(process.execPath, [SERVER_ENTRY, "--version"], { + env: { ...process.env, INSTANODE_MCP_NO_LISTEN: "" }, + encoding: "utf8", + timeout: 5000, + }); + assert.equal(r.status, 0, `non-zero exit: stderr=${r.stderr}`); + assert.match(r.stdout, /\d+\.\d+\.\d+|dev/); + }); + it("instanode-mcp --help writes the usage block and exits 0", () => { + const r = spawnSync(process.execPath, [SERVER_ENTRY, "--help"], { + env: { ...process.env, INSTANODE_MCP_NO_LISTEN: "" }, + encoding: "utf8", + timeout: 5000, + }); + assert.equal(r.status, 0, `non-zero exit: stderr=${r.stderr}`); + assert.match(r.stdout, /instanode-mcp/); + assert.match(r.stdout, /Usage:/); + }); +}); + +describe("BUG-MCP-017: maybeShortCircuit branches", () => { + it("returns true and calls exit(0) on --version", () => { + const origWrite = process.stdout.write.bind(process.stdout); + (process.stdout as any).write = () => true; + let exitCode: number | undefined; + try { + const out = maybeShortCircuit(["--version"], (n) => { + exitCode = n; + }); + assert.equal(out, true); + assert.equal(exitCode, 0); + } finally { + (process.stdout as any).write = origWrite; + } + }); + it("returns false and does not call exit on empty argv", () => { + let called = false; + const out = maybeShortCircuit([], () => { + called = true; + }); + assert.equal(out, false); + assert.equal(called, false); + }); +}); + +describe("BUG-MCP-017: CLI flag short-circuits", () => { + it("returns true for --version", () => { + // We can't easily capture stdout from inside the same process without + // monkey-patching; just assert the boolean return — the side-effect + // path is exercised in the binary smoke test. + const origWrite = process.stdout.write.bind(process.stdout); + let captured = ""; + (process.stdout as any).write = (chunk: any) => { + captured += String(chunk); + return true; + }; + try { + assert.equal(handleCLIFlags(["--version"]), true); + assert.match(captured, /\d+\.\d+\.\d+|dev/); + } finally { + (process.stdout as any).write = origWrite; + } + }); + it("returns true for -v / --help / -h", () => { + const origWrite = process.stdout.write.bind(process.stdout); + (process.stdout as any).write = () => true; + try { + assert.equal(handleCLIFlags(["-v"]), true); + assert.equal(handleCLIFlags(["--help"]), true); + assert.equal(handleCLIFlags(["-h"]), true); + } finally { + (process.stdout as any).write = origWrite; + } + }); + it("returns false for empty / unknown args", () => { + assert.equal(handleCLIFlags([]), false); + assert.equal(handleCLIFlags(["--mystery"]), false); + }); +}); + +// ── BUG-MCP-020: tarball cap ──────────────────────────────────────────────── + +describe("BUG-MCP-020: tarball_base64 max length", () => { + it("accepts a small payload", () => { + const s = schemaFor("create_deploy", "tarball_base64"); + assert.equal(s.safeParse("aGVsbG8=").success, true); + }); + it("rejects a 71 MiB encoded payload", () => { + const s = schemaFor("create_deploy", "tarball_base64"); + const huge = "a".repeat(71 * 1024 * 1024); + const r = s.safeParse(huge); + assert.equal(r.success, false); + }); +}); + +// ── BUG-MCP-022: allowed_ips CIDR validation ──────────────────────────────── + +describe("BUG-MCP-022: isIPOrCIDR", () => { + it("accepts canonical IPv4 + CIDR", () => { + assert.equal(isIPOrCIDR("203.0.113.42"), true); + assert.equal(isIPOrCIDR("10.0.0.0/8"), true); + assert.equal(isIPOrCIDR("0.0.0.0/0"), true); + }); + it("accepts IPv6 + CIDR", () => { + assert.equal(isIPOrCIDR("2001:db8::1"), true); + assert.equal(isIPOrCIDR("2001:db8::/32"), true); + assert.equal(isIPOrCIDR("::1"), true); + }); + it("rejects garbage and bad octets", () => { + assert.equal(isIPOrCIDR("192.168.1"), false); + assert.equal(isIPOrCIDR("999.0.0.0"), false); + assert.equal(isIPOrCIDR("10.0.0.0/33"), false); + assert.equal(isIPOrCIDR(""), false); + assert.equal(isIPOrCIDR("hello"), false); + assert.equal(isIPOrCIDR("::/0g"), false); + }); +}); + +describe("BUG-MCP-022: allowed_ips zod array", () => { + it("rejects a malformed entry", () => { + const s = schemaFor("create_deploy", "allowed_ips"); + const r = s.safeParse(["nope-not-an-ip"]); + assert.equal(r.success, false); + }); + it("accepts a well-formed list", () => { + const s = schemaFor("create_deploy", "allowed_ips"); + const r = s.safeParse(["203.0.113.42/32", "10.0.0.0/8"]); + assert.equal(r.success, true); + }); +}); + +describe("BUG-MCP-022: isIPOrCIDR exhaustive branches", () => { + it("rejects over-long input (>64 chars)", () => { + assert.equal(isIPOrCIDR("1".repeat(65)), false); + }); + it("rejects bad IPv4 with NaN octet", () => { + assert.equal(isIPOrCIDR("10.0.0.x"), false); + }); + it("rejects IPv4 CIDR with mask >32", () => { + assert.equal(isIPOrCIDR("10.0.0.0/33"), false); + }); + it("accepts IPv4 CIDR /0 boundary", () => { + assert.equal(isIPOrCIDR("0.0.0.0/0"), true); + }); + it("rejects IPv6 with two ::", () => { + assert.equal(isIPOrCIDR("2001::db8::1"), false); + }); + it("rejects all-hex but no colon (treated as not IPv6)", () => { + assert.equal(isIPOrCIDR("abcd"), false); + }); + it("rejects IPv6 CIDR with mask >128", () => { + assert.equal(isIPOrCIDR("2001:db8::/129"), false); + }); + it("accepts plain IPv6 (no CIDR)", () => { + assert.equal(isIPOrCIDR("::1"), true); + }); + it("rejects mask that is not an integer", () => { + assert.equal(isIPOrCIDR("10.0.0.0/abc"), false); + assert.equal(isIPOrCIDR("2001:db8::/abc"), false); + }); +}); + +// BUG-MCP-021 — coupling handler. These hit the early-return branches +// inside the create_deploy handler. +describe("BUG-MCP-021: create_deploy private+allowed_ips coupling (handler)", () => { + function getHandler(): (p: any) => Promise { + const t = server._registeredTools?.create_deploy; + return t.handler ?? t.cb ?? t.callback; + } + it("rejects private=true with empty allowed_ips", async () => { + const h = getHandler(); + const r = await h({ + tarball_base64: "aGVsbG8=", + name: "u-priv-empty", + private: true, + allowed_ips: [], + }); + const txt = (r.content ?? []).map((c: any) => c.text ?? "").join("\n"); + assert.match(txt, /private=true requires a non-empty allowed_ips/); + }); + it("rejects private=true with missing allowed_ips", async () => { + const h = getHandler(); + const r = await h({ + tarball_base64: "aGVsbG8=", + name: "u-priv-missing", + private: true, + }); + const txt = (r.content ?? []).map((c: any) => c.text ?? "").join("\n"); + assert.match(txt, /private=true requires a non-empty allowed_ips/); + }); + it("warns when allowed_ips is set but private is falsy", async () => { + const h = getHandler(); + const r = await h({ + tarball_base64: "aGVsbG8=", + name: "u-priv-false-ips", + allowed_ips: ["10.0.0.0/8"], + }); + const txt = (r.content ?? []).map((c: any) => c.text ?? "").join("\n"); + assert.match(txt, /allowed_ips set but private=false/); + }); +}); + +// ── BUG-MCP-024 / 025: UUID schemas ───────────────────────────────────────── + +describe("BUG-MCP-024/025: UUID validation on token/id fields", () => { + const uuidSamples = { + good: "8b1f3c9e-1234-4abc-9def-0123456789ab", + bad: "not-a-uuid", + }; + for (const [tool, field] of [ + ["delete_resource", "token"], + ["get_deployment", "id"], + ["redeploy", "id"], + ["delete_deployment", "id"], + ] as const) { + it(`${tool}.${field} accepts a real UUID`, () => { + const s = schemaFor(tool, field); + assert.equal(s.safeParse(uuidSamples.good).success, true); + }); + it(`${tool}.${field} rejects a non-UUID`, () => { + const s = schemaFor(tool, field); + assert.equal(s.safeParse(uuidSamples.bad).success, false); + }); + } +}); + +// ── BUG-MCP-040: API_URL validation ───────────────────────────────────────── + +describe("BUG-MCP-040: validateBaseURL", () => { + it("accepts https + http", () => { + assert.equal(validateBaseURL("https://api.instanode.dev"), "https://api.instanode.dev"); + assert.equal(validateBaseURL("http://localhost:8080"), "http://localhost:8080"); + }); + it("rejects javascript: and file:", () => { + assert.equal(validateBaseURL("javascript:alert(1)"), null); + assert.equal(validateBaseURL("file:///etc/passwd"), null); + }); + it("rejects empty / garbage (URL constructor throws → catch branch)", () => { + assert.equal(validateBaseURL(""), null); + assert.equal(validateBaseURL(" "), null); + assert.equal(validateBaseURL("not a url"), null); + assert.equal(validateBaseURL("http://"), null); // empty host + assert.equal(validateBaseURL(":/foo"), null); // URL ctor throws + }); +}); + +describe("BUG-MCP-040: InstantClient constructor falls back on bad URL", () => { + it("warns on stderr and falls back to default when override URL is bad", async () => { + const { InstantClient } = await import("../src/client.js"); + const origWrite = process.stderr.write.bind(process.stderr); + let captured = ""; + (process.stderr as any).write = (chunk: any) => { + captured += String(chunk); + return true; + }; + try { + const c = new InstantClient({ baseURL: "javascript:alert(1)" }); + assert.equal(c.apiBaseURL(), "https://api.instanode.dev"); + assert.match(captured, /refusing INSTANODE_API_URL/); + assert.match(captured, /Falling back/); + } finally { + (process.stderr as any).write = origWrite; + } + }); + it("accepts a valid override URL silently", async () => { + const { InstantClient } = await import("../src/client.js"); + const origWrite = process.stderr.write.bind(process.stderr); + let captured = ""; + (process.stderr as any).write = (chunk: any) => { + captured += String(chunk); + return true; + }; + try { + const c = new InstantClient({ baseURL: "http://localhost:8080/" }); + assert.equal(c.apiBaseURL(), "http://localhost:8080"); + assert.equal(captured, ""); + } finally { + (process.stderr as any).write = origWrite; + } + }); +}); diff --git a/test/integration.test.ts b/test/integration.test.ts index 78218a3..de20a4c 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -699,7 +699,12 @@ describe("instanode-mcp integration suite", () => { it("get_deployment returns a not-found error for an unknown app id", async () => { const { client, close } = await connectClient(mock.url, "valid"); try { - const res = await client.callTool({ name: "get_deployment", arguments: { id: "app-doesnotexist" } }); + // BUG-MCP-025: id must be a real UUID — supply one the mock doesn't + // know about so the API still returns 404. + const res = await client.callTool({ + name: "get_deployment", + arguments: { id: "00000000-0000-4000-8000-000000000404" }, + }); assert.ok(/404|not found/i.test(resultText(res)), "get_deployment did not surface a 404"); } finally { await close(); @@ -709,7 +714,11 @@ describe("instanode-mcp integration suite", () => { it("redeploy returns a not-found error for an unknown app id", async () => { const { client, close } = await connectClient(mock.url, "valid"); try { - const res = await client.callTool({ name: "redeploy", arguments: { id: "app-doesnotexist" } }); + // BUG-MCP-025: see above — UUID-shaped + unknown. + const res = await client.callTool({ + name: "redeploy", + arguments: { id: "00000000-0000-4000-8000-000000000404" }, + }); assert.ok(/404|not found/i.test(resultText(res)), "redeploy did not surface a 404"); } finally { await close(); diff --git a/test/mock-api.ts b/test/mock-api.ts index 44b2ada..a6e0a00 100644 --- a/test/mock-api.ts +++ b/test/mock-api.ts @@ -658,8 +658,13 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P } } + // BUG-MCP-025: app_id is now validated as a UUID on the get/redeploy/ + // delete paths, matching the real API contract. The previous + // `app-{shortid}` mock id silently passed because the schema was a + // bare string; now the mock returns a UUID-shaped app_id like prod so + // the test fixtures don't trip the schema. const id = randomUUID(); - const appId = `app-${id.slice(0, 8)}`; + const appId = id; const deployment: MockDeployment = { id, app_id: appId, diff --git a/test/tools-unit.test.ts b/test/tools-unit.test.ts index bda96f7..d04dc76 100644 --- a/test/tools-unit.test.ts +++ b/test/tools-unit.test.ts @@ -384,7 +384,10 @@ describe("tool handlers — deployment lifecycle", () => { }); const text = flat(res); assert.match(text, /Deployment accepted/); - assert.match(text, /Deploy ID:\s+app-/); + // BUG-MCP-025: mock now returns a UUID-shaped app_id matching the real + // API contract. Accept either the historical "app-" prefix (in case a + // legacy mock revives it) or a canonical UUID. + assert.match(text, /Deploy ID:\s+(app-|[0-9a-f]{8}-[0-9a-f]{4}-)/); assert.match(text, /Status:\s+building/); assert.match(text, /Build logs:/); assert.match(text, /Poll for terminal status:/);