diff --git a/packages/server-build/__tests__/security.test.ts b/packages/server-build/__tests__/security.test.ts index ceab5858..5ae295d0 100644 --- a/packages/server-build/__tests__/security.test.ts +++ b/packages/server-build/__tests__/security.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { assertAllowedCommand, assertNoFlagInjection } from "@paretools/shared"; +import { z } from "zod"; +import { assertAllowedCommand, assertNoFlagInjection, INPUT_LIMITS } from "@paretools/shared"; // --------------------------------------------------------------------------- // assertAllowedCommand — used by the build tool @@ -86,3 +87,197 @@ describe("assertNoFlagInjection", () => { expect(() => assertNoFlagInjection("pages/about-us.tsx", "entryPoints")).not.toThrow(); }); }); + +// --------------------------------------------------------------------------- +// tsc tool — project parameter flag injection +// --------------------------------------------------------------------------- + +describe("security: tsc tool — project parameter validation", () => { + it("accepts normal tsconfig paths", () => { + expect(() => assertNoFlagInjection("tsconfig.json", "project")).not.toThrow(); + expect(() => assertNoFlagInjection("./tsconfig.build.json", "project")).not.toThrow(); + expect(() => assertNoFlagInjection("packages/core/tsconfig.json", "project")).not.toThrow(); + }); + + it("rejects flag-like project values", () => { + expect(() => assertNoFlagInjection("--outDir=/tmp/evil", "project")).toThrow( + /must not start with "-"/, + ); + expect(() => assertNoFlagInjection("-p", "project")).toThrow(/must not start with "-"/); + expect(() => assertNoFlagInjection("--noEmit", "project")).toThrow(/must not start with "-"/); + expect(() => assertNoFlagInjection("--declaration", "project")).toThrow( + /must not start with "-"/, + ); + }); + + it("rejects whitespace-prefixed flag injection", () => { + expect(() => assertNoFlagInjection(" --outDir=/etc/passwd", "project")).toThrow( + /must not start with "-"/, + ); + expect(() => assertNoFlagInjection("\t-p", "project")).toThrow(/must not start with "-"/); + }); +}); + +// --------------------------------------------------------------------------- +// vite-build tool — mode and args[] flag injection +// --------------------------------------------------------------------------- + +describe("security: vite-build tool — mode parameter validation", () => { + it("accepts normal mode values", () => { + expect(() => assertNoFlagInjection("production", "mode")).not.toThrow(); + expect(() => assertNoFlagInjection("development", "mode")).not.toThrow(); + expect(() => assertNoFlagInjection("staging", "mode")).not.toThrow(); + }); + + it("rejects flag-like mode values", () => { + expect(() => assertNoFlagInjection("--mode=evil", "mode")).toThrow(/must not start with "-"/); + expect(() => assertNoFlagInjection("-m", "mode")).toThrow(/must not start with "-"/); + expect(() => assertNoFlagInjection("--outDir=/etc/passwd", "mode")).toThrow( + /must not start with "-"/, + ); + }); +}); + +describe("security: vite-build tool — args[] parameter validation", () => { + it("rejects flag-like args values", () => { + expect(() => assertNoFlagInjection("--outDir=/tmp/evil", "args")).toThrow( + /must not start with "-"/, + ); + expect(() => assertNoFlagInjection("-w", "args")).toThrow(/must not start with "-"/); + expect(() => assertNoFlagInjection("--base=/evil", "args")).toThrow(/must not start with "-"/); + }); +}); + +// --------------------------------------------------------------------------- +// webpack tool — config and args[] flag injection +// --------------------------------------------------------------------------- + +describe("security: webpack tool — config parameter validation", () => { + it("accepts normal config paths", () => { + expect(() => assertNoFlagInjection("webpack.config.js", "config")).not.toThrow(); + expect(() => assertNoFlagInjection("./config/webpack.prod.js", "config")).not.toThrow(); + }); + + it("rejects flag-like config values", () => { + expect(() => assertNoFlagInjection("--output-path=/etc/passwd", "config")).toThrow( + /must not start with "-"/, + ); + expect(() => assertNoFlagInjection("-c", "config")).toThrow(/must not start with "-"/); + expect(() => assertNoFlagInjection("--mode=production", "config")).toThrow( + /must not start with "-"/, + ); + }); + + it("rejects whitespace-prefixed flag injection", () => { + expect(() => assertNoFlagInjection(" --output-path=/tmp", "config")).toThrow( + /must not start with "-"/, + ); + }); +}); + +describe("security: webpack tool — args[] parameter validation", () => { + it("rejects flag-like args values", () => { + expect(() => assertNoFlagInjection("--output-path=/tmp/evil", "args")).toThrow( + /must not start with "-"/, + ); + expect(() => assertNoFlagInjection("--mode=production", "args")).toThrow( + /must not start with "-"/, + ); + expect(() => assertNoFlagInjection("-d", "args")).toThrow(/must not start with "-"/); + }); +}); + +// --------------------------------------------------------------------------- +// build tool — args[] validation (note: command uses assertAllowedCommand) +// --------------------------------------------------------------------------- + +describe("security: build tool — args[] parameter validation", () => { + it("rejects flag-like args values", () => { + expect(() => assertNoFlagInjection("--exec=rm -rf /", "args")).toThrow( + /must not start with "-"/, + ); + expect(() => assertNoFlagInjection("-rf", "args")).toThrow(/must not start with "-"/); + expect(() => assertNoFlagInjection("--output=/etc/passwd", "args")).toThrow( + /must not start with "-"/, + ); + }); + + it("accepts safe args values", () => { + expect(() => assertNoFlagInjection("run", "args")).not.toThrow(); + expect(() => assertNoFlagInjection("build", "args")).not.toThrow(); + expect(() => assertNoFlagInjection("src/index.ts", "args")).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Zod .max() input-limit constraints — Build tool schemas +// --------------------------------------------------------------------------- + +describe("Zod .max() constraints — Build tool schemas", () => { + describe("tsc project parameter (PATH_MAX)", () => { + const schema = z.string().max(INPUT_LIMITS.PATH_MAX); + + it("accepts a path within the limit", () => { + expect(schema.safeParse("tsconfig.json").success).toBe(true); + }); + + it("rejects a path exceeding PATH_MAX", () => { + const oversized = "p".repeat(INPUT_LIMITS.PATH_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("vite-build mode parameter (SHORT_STRING_MAX)", () => { + const schema = z.string().max(INPUT_LIMITS.SHORT_STRING_MAX); + + it("accepts a mode within the limit", () => { + expect(schema.safeParse("production").success).toBe(true); + }); + + it("rejects a mode exceeding SHORT_STRING_MAX", () => { + const oversized = "m".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("build command parameter (SHORT_STRING_MAX)", () => { + const schema = z.string().max(INPUT_LIMITS.SHORT_STRING_MAX); + + it("accepts a command within the limit", () => { + expect(schema.safeParse("npm").success).toBe(true); + }); + + it("rejects a command exceeding SHORT_STRING_MAX", () => { + const oversized = "c".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("args array (ARRAY_MAX + STRING_MAX)", () => { + const schema = z.array(z.string().max(INPUT_LIMITS.STRING_MAX)).max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects array exceeding ARRAY_MAX", () => { + const oversized = Array.from({ length: INPUT_LIMITS.ARRAY_MAX + 1 }, () => "arg"); + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("rejects arg string exceeding STRING_MAX", () => { + const oversized = ["a".repeat(INPUT_LIMITS.STRING_MAX + 1)]; + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("esbuild entryPoints (ARRAY_MAX + PATH_MAX)", () => { + const schema = z.array(z.string().max(INPUT_LIMITS.PATH_MAX)).max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects array exceeding ARRAY_MAX", () => { + const oversized = Array.from({ length: INPUT_LIMITS.ARRAY_MAX + 1 }, () => "src/index.ts"); + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("rejects path exceeding PATH_MAX", () => { + const oversized = ["p".repeat(INPUT_LIMITS.PATH_MAX + 1)]; + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); +}); diff --git a/packages/server-cargo/__tests__/security.test.ts b/packages/server-cargo/__tests__/security.test.ts index b472460c..229fcea9 100644 --- a/packages/server-cargo/__tests__/security.test.ts +++ b/packages/server-cargo/__tests__/security.test.ts @@ -8,7 +8,8 @@ * "--manifest-path=/evil" could be interpreted as a flag. */ import { describe, it, expect } from "vitest"; -import { assertNoFlagInjection } from "@paretools/shared"; +import { z } from "zod"; +import { assertNoFlagInjection, INPUT_LIMITS } from "@paretools/shared"; /** Malicious inputs that must be rejected by every guarded parameter. */ const MALICIOUS_INPUTS = [ @@ -95,3 +96,110 @@ describe("security: cargo run — args validation", () => { } }); }); + +// --------------------------------------------------------------------------- +// cargo build — no user-supplied string params (only path and boolean) +// --------------------------------------------------------------------------- + +describe("security: cargo build — no injectable string params", () => { + it("build tool only accepts path (PATH_MAX) and release (boolean)", () => { + // The build tool has no user-facing string params that could be flag-injected. + // path is validated by Zod .max(PATH_MAX) and release is a boolean. + // This test documents that explicitly. + const pathSchema = z.string().max(INPUT_LIMITS.PATH_MAX).optional(); + const releaseSchema = z.boolean().optional(); + + expect(pathSchema.safeParse("/some/path").success).toBe(true); + expect(releaseSchema.safeParse(true).success).toBe(true); + expect(releaseSchema.safeParse("--release").success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// cargo clippy — no user-supplied string params (only path) +// --------------------------------------------------------------------------- + +describe("security: cargo clippy — no injectable string params", () => { + it("clippy tool only accepts path (PATH_MAX)", () => { + // The clippy tool has no user-facing string params that could be flag-injected. + // path is validated by Zod .max(PATH_MAX). + const pathSchema = z.string().max(INPUT_LIMITS.PATH_MAX).optional(); + + expect(pathSchema.safeParse("/some/path").success).toBe(true); + expect(pathSchema.safeParse("p".repeat(INPUT_LIMITS.PATH_MAX + 1)).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// cargo fmt — no user-supplied string params (only path and boolean) +// --------------------------------------------------------------------------- + +describe("security: cargo fmt — no injectable string params", () => { + it("fmt tool only accepts path (PATH_MAX) and check (boolean)", () => { + // The fmt tool has no user-facing string params that could be flag-injected. + // path is validated by Zod .max(PATH_MAX) and check is a boolean. + const pathSchema = z.string().max(INPUT_LIMITS.PATH_MAX).optional(); + const checkSchema = z.boolean().optional(); + + expect(pathSchema.safeParse("./my-project").success).toBe(true); + expect(checkSchema.safeParse(true).success).toBe(true); + expect(checkSchema.safeParse("--check").success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// cargo remove — packages[] validation +// --------------------------------------------------------------------------- + +describe("security: cargo remove — packages[] validation", () => { + it("rejects flag-like package names", () => { + for (const malicious of MALICIOUS_INPUTS) { + expect(() => assertNoFlagInjection(malicious, "packages")).toThrow(/must not start with "-"/); + } + }); + + it("accepts safe package names", () => { + for (const safe of SAFE_INPUTS) { + expect(() => assertNoFlagInjection(safe, "packages")).not.toThrow(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Zod .max() input-limit constraints — Cargo tool schemas +// --------------------------------------------------------------------------- + +describe("Zod .max() constraints — Cargo tool schemas", () => { + describe("path parameter (PATH_MAX)", () => { + const schema = z.string().max(INPUT_LIMITS.PATH_MAX); + + it("accepts a path within the limit", () => { + expect(schema.safeParse("/home/user/project").success).toBe(true); + }); + + it("rejects a path exceeding PATH_MAX", () => { + const oversized = "p".repeat(INPUT_LIMITS.PATH_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("packages array (ARRAY_MAX + SHORT_STRING_MAX)", () => { + const schema = z + .array(z.string().max(INPUT_LIMITS.SHORT_STRING_MAX)) + .max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects array exceeding ARRAY_MAX", () => { + const oversized = Array.from({ length: INPUT_LIMITS.ARRAY_MAX + 1 }, (_, i) => `pkg-${i}`); + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("rejects package name exceeding SHORT_STRING_MAX", () => { + const oversized = ["x".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1)]; + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("accepts normal package names", () => { + expect(schema.safeParse(["serde", "tokio", "reqwest"]).success).toBe(true); + }); + }); +}); diff --git a/packages/server-docker/__tests__/security.test.ts b/packages/server-docker/__tests__/security.test.ts index cd718497..0168406a 100644 --- a/packages/server-docker/__tests__/security.test.ts +++ b/packages/server-docker/__tests__/security.test.ts @@ -5,9 +5,14 @@ * These tools accept user-provided strings (image names, container names) that * are passed as positional arguments to the Docker CLI. Without validation, * a malicious input like "--privileged" could be interpreted as a flag. + * + * Also tests assertValidPortMapping() for the run tool's ports parameter, + * and Zod .max() input-limit constraints on Docker tool schemas. */ import { describe, it, expect } from "vitest"; -import { assertNoFlagInjection } from "@paretools/shared"; +import { z } from "zod"; +import { assertNoFlagInjection, INPUT_LIMITS } from "@paretools/shared"; +import { assertValidPortMapping } from "../src/lib/validation.js"; describe("assertNoFlagInjection — run tool (image param)", () => { it("accepts a normal image name", () => { @@ -156,3 +161,251 @@ describe("assertNoFlagInjection — pull tool (image param)", () => { ); }); }); + +// --------------------------------------------------------------------------- +// assertValidPortMapping — used by the run tool's ports[] parameter +// --------------------------------------------------------------------------- + +describe("assertValidPortMapping", () => { + describe("accepts valid port mappings", () => { + const validMappings = [ + "8080", + "8080:80", + "127.0.0.1:8080:80", + "8080:80/tcp", + "8080:80/udp", + "8080:80/sctp", + "8080-8090:80-90", + "127.0.0.1:8080:80/udp", + "443:443", + "3000:3000/tcp", + ]; + + for (const mapping of validMappings) { + it(`accepts "${mapping}"`, () => { + expect(() => assertValidPortMapping(mapping)).not.toThrow(); + }); + } + }); + + describe("rejects invalid port mappings", () => { + const invalidMappings = [ + { value: "abc", label: "non-numeric" }, + { value: "--privileged", label: "flag injection (--privileged)" }, + { value: "", label: "empty string" }, + { value: "999999:80", label: "out-of-range port (999999)" }, + { value: "-p 8080:80", label: "flag prefix (-p 8080:80)" }, + { value: "8080:80/http", label: "invalid protocol (http)" }, + { value: "host:8080:80", label: "non-IP host" }, + { value: "; rm -rf /", label: "shell injection" }, + { value: "8080:80 --privileged", label: "trailing flag" }, + { value: "$(whoami):80", label: "command substitution" }, + ]; + + for (const { value, label } of invalidMappings) { + it(`rejects ${label}`, () => { + expect(() => assertValidPortMapping(value)).toThrow(/Invalid port mapping/); + }); + } + }); +}); + +// --------------------------------------------------------------------------- +// exec tool — workdir parameter flag injection +// --------------------------------------------------------------------------- + +describe("assertNoFlagInjection — exec tool (workdir param)", () => { + it("accepts a normal working directory", () => { + expect(() => assertNoFlagInjection("/app/src", "workdir")).not.toThrow(); + }); + + it("accepts a relative path", () => { + expect(() => assertNoFlagInjection("src/lib", "workdir")).not.toThrow(); + }); + + it("rejects --privileged as workdir", () => { + expect(() => assertNoFlagInjection("--privileged", "workdir")).toThrow(/Invalid workdir/); + }); + + it("rejects -w as workdir (flag injection)", () => { + expect(() => assertNoFlagInjection("-w", "workdir")).toThrow(/Invalid workdir/); + }); + + it("rejects --user=root as workdir", () => { + expect(() => assertNoFlagInjection("--user=root", "workdir")).toThrow(/Invalid workdir/); + }); +}); + +// --------------------------------------------------------------------------- +// pull tool — platform parameter flag injection +// --------------------------------------------------------------------------- + +describe("assertNoFlagInjection — pull tool (platform param)", () => { + it("accepts a normal platform string", () => { + expect(() => assertNoFlagInjection("linux/amd64", "platform")).not.toThrow(); + }); + + it("accepts linux/arm64", () => { + expect(() => assertNoFlagInjection("linux/arm64", "platform")).not.toThrow(); + }); + + it("rejects --all-tags as platform", () => { + expect(() => assertNoFlagInjection("--all-tags", "platform")).toThrow(/Invalid platform/); + }); + + it("rejects --privileged as platform", () => { + expect(() => assertNoFlagInjection("--privileged", "platform")).toThrow(/Invalid platform/); + }); + + it("rejects -a as platform", () => { + expect(() => assertNoFlagInjection("-a", "platform")).toThrow(/Invalid platform/); + }); +}); + +// --------------------------------------------------------------------------- +// run tool — volumes[] flag injection +// --------------------------------------------------------------------------- + +describe("assertNoFlagInjection — run tool (volumes param)", () => { + it("accepts a normal volume mount", () => { + expect(() => assertNoFlagInjection("/host/path:/container/path", "volumes")).not.toThrow(); + }); + + it("accepts named volumes", () => { + expect(() => assertNoFlagInjection("myvolume:/data", "volumes")).not.toThrow(); + }); + + it("rejects --privileged as volume", () => { + expect(() => assertNoFlagInjection("--privileged", "volumes")).toThrow(/Invalid volumes/); + }); + + it("rejects -v as volume value (flag injection)", () => { + expect(() => assertNoFlagInjection("-v", "volumes")).toThrow(/Invalid volumes/); + }); + + it("rejects --network=host as volume", () => { + expect(() => assertNoFlagInjection("--network=host", "volumes")).toThrow(/Invalid volumes/); + }); +}); + +// --------------------------------------------------------------------------- +// run tool — env[] flag injection +// --------------------------------------------------------------------------- + +describe("assertNoFlagInjection — run tool (env param)", () => { + it("accepts a normal env var", () => { + expect(() => assertNoFlagInjection("NODE_ENV=production", "env")).not.toThrow(); + }); + + it("accepts env var with complex value", () => { + expect(() => + assertNoFlagInjection("DATABASE_URL=postgres://user:pass@host:5432/db", "env"), + ).not.toThrow(); + }); + + it("rejects --privileged as env value", () => { + expect(() => assertNoFlagInjection("--privileged", "env")).toThrow(/Invalid env/); + }); + + it("rejects -e as env value (flag injection)", () => { + expect(() => assertNoFlagInjection("-e", "env")).toThrow(/Invalid env/); + }); + + it("rejects --cap-add=SYS_ADMIN as env value", () => { + expect(() => assertNoFlagInjection("--cap-add=SYS_ADMIN", "env")).toThrow(/Invalid env/); + }); +}); + +// --------------------------------------------------------------------------- +// Zod .max() input-limit constraints — Docker tools +// --------------------------------------------------------------------------- + +describe("Zod .max() constraints — Docker tool schemas", () => { + describe("image parameter (SHORT_STRING_MAX)", () => { + const imageSchema = z.string().max(INPUT_LIMITS.SHORT_STRING_MAX); + + it("accepts an image name within the limit", () => { + expect(imageSchema.safeParse("nginx:latest").success).toBe(true); + }); + + it("rejects an image name exceeding SHORT_STRING_MAX", () => { + const oversized = "a".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1); + expect(imageSchema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("container parameter (SHORT_STRING_MAX)", () => { + const containerSchema = z.string().max(INPUT_LIMITS.SHORT_STRING_MAX); + + it("accepts a container name within the limit", () => { + expect(containerSchema.safeParse("my-container").success).toBe(true); + }); + + it("rejects a container name exceeding SHORT_STRING_MAX", () => { + const oversized = "c".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1); + expect(containerSchema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("ports array (ARRAY_MAX + SHORT_STRING_MAX)", () => { + const portsSchema = z + .array(z.string().max(INPUT_LIMITS.SHORT_STRING_MAX)) + .max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects array exceeding ARRAY_MAX", () => { + const oversized = Array.from( + { length: INPUT_LIMITS.ARRAY_MAX + 1 }, + (_, i) => `${8000 + i}:80`, + ); + expect(portsSchema.safeParse(oversized).success).toBe(false); + }); + + it("rejects port string exceeding SHORT_STRING_MAX", () => { + const oversized = ["x".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1)]; + expect(portsSchema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("volumes array (ARRAY_MAX + PATH_MAX)", () => { + const volumesSchema = z + .array(z.string().max(INPUT_LIMITS.PATH_MAX)) + .max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects array exceeding ARRAY_MAX", () => { + const oversized = Array.from({ length: INPUT_LIMITS.ARRAY_MAX + 1 }, () => "/a:/b"); + expect(volumesSchema.safeParse(oversized).success).toBe(false); + }); + + it("rejects volume string exceeding PATH_MAX", () => { + const oversized = ["p".repeat(INPUT_LIMITS.PATH_MAX + 1)]; + expect(volumesSchema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("env array (ARRAY_MAX + STRING_MAX)", () => { + const envSchema = z.array(z.string().max(INPUT_LIMITS.STRING_MAX)).max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects array exceeding ARRAY_MAX", () => { + const oversized = Array.from({ length: INPUT_LIMITS.ARRAY_MAX + 1 }, () => "K=V"); + expect(envSchema.safeParse(oversized).success).toBe(false); + }); + + it("rejects env string exceeding STRING_MAX", () => { + const oversized = ["K=" + "v".repeat(INPUT_LIMITS.STRING_MAX)]; + expect(envSchema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("path parameter (PATH_MAX)", () => { + const pathSchema = z.string().max(INPUT_LIMITS.PATH_MAX); + + it("accepts a path within the limit", () => { + expect(pathSchema.safeParse("/home/user/project").success).toBe(true); + }); + + it("rejects a path exceeding PATH_MAX", () => { + const oversized = "/".repeat(INPUT_LIMITS.PATH_MAX + 1); + expect(pathSchema.safeParse(oversized).success).toBe(false); + }); + }); +}); diff --git a/packages/server-docker/src/lib/validation.ts b/packages/server-docker/src/lib/validation.ts new file mode 100644 index 00000000..f8558329 --- /dev/null +++ b/packages/server-docker/src/lib/validation.ts @@ -0,0 +1,20 @@ +/** + * Validates that a string matches a Docker port mapping format. + * Accepted formats: + * - "8080" (container port only) + * - "8080:80" (host:container) + * - "8080:80/tcp" (host:container/protocol) + * - "127.0.0.1:8080:80" (ip:host:container) + * - "127.0.0.1:8080:80/udp" + * - "8080-8090:80-90" (port ranges) + */ +const PORT_MAPPING_RE = + /^(?:(?:\d{1,3}\.){3}\d{1,3}:)?(?:\d{1,5}(?:-\d{1,5})?:)?\d{1,5}(?:-\d{1,5})?(?:\/(?:tcp|udp|sctp))?$/; + +export function assertValidPortMapping(value: string): void { + if (!PORT_MAPPING_RE.test(value)) { + throw new Error( + `Invalid port mapping: "${value}". Expected format like "8080", "8080:80", "127.0.0.1:8080:80/tcp", or a port range.`, + ); + } +} diff --git a/packages/server-docker/src/tools/run.ts b/packages/server-docker/src/tools/run.ts index 85e52d43..71fa54d7 100644 --- a/packages/server-docker/src/tools/run.ts +++ b/packages/server-docker/src/tools/run.ts @@ -5,27 +5,7 @@ import { docker } from "../lib/docker-runner.js"; import { parseRunOutput } from "../lib/parsers.js"; import { formatRun } from "../lib/formatters.js"; import { DockerRunSchema } from "../schemas/index.js"; - -/** - * Validates that a string matches a Docker port mapping format. - * Accepted formats: - * - "8080" (container port only) - * - "8080:80" (host:container) - * - "8080:80/tcp" (host:container/protocol) - * - "127.0.0.1:8080:80" (ip:host:container) - * - "127.0.0.1:8080:80/udp" - * - "8080-8090:80-90" (port ranges) - */ -const PORT_MAPPING_RE = - /^(?:(?:\d{1,3}\.){3}\d{1,3}:)?(?:\d{1,5}(?:-\d{1,5})?:)?\d{1,5}(?:-\d{1,5})?(?:\/(?:tcp|udp|sctp))?$/; - -export function assertValidPortMapping(value: string): void { - if (!PORT_MAPPING_RE.test(value)) { - throw new Error( - `Invalid port mapping: "${value}". Expected format like "8080", "8080:80", "127.0.0.1:8080:80/tcp", or a port range.`, - ); - } -} +import { assertValidPortMapping } from "../lib/validation.js"; export function registerRunTool(server: McpServer) { server.registerTool( diff --git a/packages/server-git/__tests__/security.test.ts b/packages/server-git/__tests__/security.test.ts index bf874b91..89a6a59f 100644 --- a/packages/server-git/__tests__/security.test.ts +++ b/packages/server-git/__tests__/security.test.ts @@ -7,7 +7,8 @@ * These tests ensure that values starting with "-" are rejected. */ import { describe, it, expect } from "vitest"; -import { assertNoFlagInjection } from "@paretools/shared"; +import { z } from "zod"; +import { assertNoFlagInjection, INPUT_LIMITS } from "@paretools/shared"; /** Malicious inputs that must be rejected by every guarded parameter. */ const MALICIOUS_INPUTS = [ @@ -136,3 +137,74 @@ describe("security: diff tool — ref validation", () => { } }); }); + +// --------------------------------------------------------------------------- +// Zod .max() input-limit constraints — Git tool schemas +// --------------------------------------------------------------------------- + +describe("Zod .max() constraints — Git tool schemas", () => { + describe("commit message (MESSAGE_MAX = 72,000)", () => { + const schema = z.string().max(INPUT_LIMITS.MESSAGE_MAX); + + it("accepts a message at the limit", () => { + const value = "m".repeat(INPUT_LIMITS.MESSAGE_MAX); + expect(schema.safeParse(value).success).toBe(true); + }); + + it("rejects a message exceeding MESSAGE_MAX by 1", () => { + const value = "m".repeat(INPUT_LIMITS.MESSAGE_MAX + 1); + expect(schema.safeParse(value).success).toBe(false); + }); + + it("accepts a normal commit message", () => { + expect(schema.safeParse("Fix the login bug").success).toBe(true); + }); + }); + + describe("branch/remote names (SHORT_STRING_MAX = 255)", () => { + const schema = z.string().max(INPUT_LIMITS.SHORT_STRING_MAX); + + it("accepts a branch name within the limit", () => { + expect(schema.safeParse("feature/auth-flow").success).toBe(true); + }); + + it("rejects a branch name exceeding SHORT_STRING_MAX", () => { + const oversized = "b".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("file paths (PATH_MAX = 4,096)", () => { + const schema = z.string().max(INPUT_LIMITS.PATH_MAX); + + it("accepts a file path within the limit", () => { + expect(schema.safeParse("src/components/Button.tsx").success).toBe(true); + }); + + it("rejects a file path exceeding PATH_MAX", () => { + const oversized = "f".repeat(INPUT_LIMITS.PATH_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("files array (ARRAY_MAX = 1,000 + PATH_MAX)", () => { + const schema = z.array(z.string().max(INPUT_LIMITS.PATH_MAX)).max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects array exceeding ARRAY_MAX", () => { + const oversized = Array.from( + { length: INPUT_LIMITS.ARRAY_MAX + 1 }, + (_, i) => `file-${i}.ts`, + ); + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("rejects file path exceeding PATH_MAX", () => { + const oversized = ["p".repeat(INPUT_LIMITS.PATH_MAX + 1)]; + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("accepts a normal file list", () => { + expect(schema.safeParse(["src/index.ts", "README.md"]).success).toBe(true); + }); + }); +}); diff --git a/packages/server-go/__tests__/security.test.ts b/packages/server-go/__tests__/security.test.ts index bce7d466..88b119ed 100644 --- a/packages/server-go/__tests__/security.test.ts +++ b/packages/server-go/__tests__/security.test.ts @@ -8,7 +8,8 @@ * be interpreted as a flag. */ import { describe, it, expect } from "vitest"; -import { assertNoFlagInjection } from "@paretools/shared"; +import { z } from "zod"; +import { assertNoFlagInjection, INPUT_LIMITS } from "@paretools/shared"; /** Malicious inputs that must be rejected by every guarded parameter. */ const MALICIOUS_INPUTS = [ @@ -112,3 +113,55 @@ describe("security: go run — buildArgs validation", () => { } }); }); + +// --------------------------------------------------------------------------- +// Zod .max() input-limit constraints — Go tool schemas +// --------------------------------------------------------------------------- + +describe("Zod .max() constraints — Go tool schemas", () => { + describe("packages/patterns array (ARRAY_MAX + SHORT_STRING_MAX)", () => { + const schema = z + .array(z.string().max(INPUT_LIMITS.SHORT_STRING_MAX)) + .max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects array exceeding ARRAY_MAX", () => { + const oversized = Array.from({ length: INPUT_LIMITS.ARRAY_MAX + 1 }, (_, i) => `./pkg${i}`); + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("rejects package path exceeding SHORT_STRING_MAX", () => { + const oversized = ["x".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1)]; + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("accepts normal package paths", () => { + expect(schema.safeParse(["./...", "./cmd/myapp"]).success).toBe(true); + }); + }); + + describe("path parameter (PATH_MAX)", () => { + const schema = z.string().max(INPUT_LIMITS.PATH_MAX); + + it("accepts a path within the limit", () => { + expect(schema.safeParse("/home/user/go/myproject").success).toBe(true); + }); + + it("rejects a path exceeding PATH_MAX", () => { + const oversized = "p".repeat(INPUT_LIMITS.PATH_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("run filter (SHORT_STRING_MAX)", () => { + const schema = z.string().max(INPUT_LIMITS.SHORT_STRING_MAX); + + it("accepts a filter within the limit", () => { + expect(schema.safeParse("TestMyFunction").success).toBe(true); + }); + + it("rejects a filter exceeding SHORT_STRING_MAX", () => { + const oversized = "T".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); +}); diff --git a/packages/server-lint/__tests__/security.test.ts b/packages/server-lint/__tests__/security.test.ts index f051ed59..6c0e7cd1 100644 --- a/packages/server-lint/__tests__/security.test.ts +++ b/packages/server-lint/__tests__/security.test.ts @@ -8,7 +8,8 @@ * as a flag. */ import { describe, it, expect } from "vitest"; -import { assertNoFlagInjection } from "@paretools/shared"; +import { z } from "zod"; +import { assertNoFlagInjection, INPUT_LIMITS } from "@paretools/shared"; /** Malicious inputs that must be rejected by every guarded parameter. */ const MALICIOUS_INPUTS = [ @@ -90,3 +91,40 @@ describe("security: format-check (Prettier) — patterns validation", () => { } }); }); + +// --------------------------------------------------------------------------- +// Zod .max() input-limit constraints — Lint tool schemas +// --------------------------------------------------------------------------- + +describe("Zod .max() constraints — Lint tool schemas", () => { + describe("patterns array (ARRAY_MAX + PATH_MAX)", () => { + const schema = z.array(z.string().max(INPUT_LIMITS.PATH_MAX)).max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects array exceeding ARRAY_MAX", () => { + const oversized = Array.from({ length: INPUT_LIMITS.ARRAY_MAX + 1 }, () => "src/"); + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("rejects pattern string exceeding PATH_MAX", () => { + const oversized = ["p".repeat(INPUT_LIMITS.PATH_MAX + 1)]; + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("accepts normal patterns", () => { + expect(schema.safeParse(["src/", "lib/", "*.ts"]).success).toBe(true); + }); + }); + + describe("path parameter (PATH_MAX)", () => { + const schema = z.string().max(INPUT_LIMITS.PATH_MAX); + + it("accepts a path within the limit", () => { + expect(schema.safeParse("/home/user/project").success).toBe(true); + }); + + it("rejects a path exceeding PATH_MAX", () => { + const oversized = "p".repeat(INPUT_LIMITS.PATH_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); +}); diff --git a/packages/server-npm/__tests__/security.test.ts b/packages/server-npm/__tests__/security.test.ts index 123055f5..1f389ec1 100644 --- a/packages/server-npm/__tests__/security.test.ts +++ b/packages/server-npm/__tests__/security.test.ts @@ -8,7 +8,8 @@ * as a flag. */ import { describe, it, expect } from "vitest"; -import { assertNoFlagInjection } from "@paretools/shared"; +import { z } from "zod"; +import { assertNoFlagInjection, INPUT_LIMITS } from "@paretools/shared"; /** Malicious inputs that must be rejected by every guarded parameter. */ const MALICIOUS_INPUTS = [ @@ -82,3 +83,53 @@ describe("security: npm run — script and args validation", () => { } }); }); + +// --------------------------------------------------------------------------- +// Zod .max() input-limit constraints — npm tool schemas +// --------------------------------------------------------------------------- + +describe("Zod .max() constraints — npm tool schemas", () => { + describe("script name (SHORT_STRING_MAX = 255)", () => { + const schema = z.string().max(INPUT_LIMITS.SHORT_STRING_MAX); + + it("accepts a script name within the limit", () => { + expect(schema.safeParse("build").success).toBe(true); + }); + + it("rejects a script name exceeding SHORT_STRING_MAX", () => { + const oversized = "s".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("args array (ARRAY_MAX + STRING_MAX)", () => { + const schema = z.array(z.string().max(INPUT_LIMITS.STRING_MAX)).max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects array exceeding ARRAY_MAX", () => { + const oversized = Array.from({ length: INPUT_LIMITS.ARRAY_MAX + 1 }, () => "arg"); + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("rejects arg string exceeding STRING_MAX", () => { + const oversized = ["a".repeat(INPUT_LIMITS.STRING_MAX + 1)]; + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("accepts normal args", () => { + expect(schema.safeParse(["express", "lodash"]).success).toBe(true); + }); + }); + + describe("path parameter (PATH_MAX = 4,096)", () => { + const schema = z.string().max(INPUT_LIMITS.PATH_MAX); + + it("accepts a path within the limit", () => { + expect(schema.safeParse("/home/user/project").success).toBe(true); + }); + + it("rejects a path exceeding PATH_MAX", () => { + const oversized = "p".repeat(INPUT_LIMITS.PATH_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); +}); diff --git a/packages/server-python/__tests__/security.test.ts b/packages/server-python/__tests__/security.test.ts index 9fa60b37..951824b0 100644 --- a/packages/server-python/__tests__/security.test.ts +++ b/packages/server-python/__tests__/security.test.ts @@ -8,7 +8,8 @@ * "--output=/etc/passwd" could be interpreted as a flag. */ import { describe, it, expect } from "vitest"; -import { assertNoFlagInjection } from "@paretools/shared"; +import { z } from "zod"; +import { assertNoFlagInjection, INPUT_LIMITS } from "@paretools/shared"; /** Malicious inputs that must be rejected by every guarded parameter. */ const MALICIOUS_INPUTS = [ @@ -143,3 +144,84 @@ describe("security: uv-install — packages and requirements validation", () => } }); }); + +// --------------------------------------------------------------------------- +// uv-run tool — command[0] validation +// --------------------------------------------------------------------------- + +describe("security: uv-run tool — command[0] validation", () => { + it("accepts normal command names", () => { + expect(() => assertNoFlagInjection("python", "command")).not.toThrow(); + expect(() => assertNoFlagInjection("flask", "command")).not.toThrow(); + expect(() => assertNoFlagInjection("pytest", "command")).not.toThrow(); + expect(() => assertNoFlagInjection("mypy", "command")).not.toThrow(); + expect(() => assertNoFlagInjection("uvicorn", "command")).not.toThrow(); + }); + + it("accepts commands with paths", () => { + expect(() => assertNoFlagInjection("/usr/bin/python3", "command")).not.toThrow(); + expect(() => assertNoFlagInjection("./venv/bin/python", "command")).not.toThrow(); + }); + + it("rejects flag-like command names", () => { + for (const malicious of MALICIOUS_INPUTS) { + expect(() => assertNoFlagInjection(malicious, "command")).toThrow(/must not start with "-"/); + } + }); + + it("rejects specific dangerous flag injections as command", () => { + expect(() => assertNoFlagInjection("--help", "command")).toThrow(/must not start with "-"/); + expect(() => assertNoFlagInjection("-c", "command")).toThrow(/must not start with "-"/); + expect(() => assertNoFlagInjection("--exec=rm -rf /", "command")).toThrow( + /must not start with "-"/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Zod .max() input-limit constraints — Python tool schemas +// --------------------------------------------------------------------------- + +describe("Zod .max() constraints — Python tool schemas", () => { + describe("uv-run command array (ARRAY_MAX + STRING_MAX)", () => { + const schema = z.array(z.string().max(INPUT_LIMITS.STRING_MAX)).max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects array exceeding ARRAY_MAX", () => { + const oversized = Array.from({ length: INPUT_LIMITS.ARRAY_MAX + 1 }, () => "arg"); + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("rejects command string exceeding STRING_MAX", () => { + const oversized = ["a".repeat(INPUT_LIMITS.STRING_MAX + 1)]; + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("path parameter (PATH_MAX)", () => { + const schema = z.string().max(INPUT_LIMITS.PATH_MAX); + + it("accepts a path within the limit", () => { + expect(schema.safeParse("/home/user/project").success).toBe(true); + }); + + it("rejects a path exceeding PATH_MAX", () => { + const oversized = "p".repeat(INPUT_LIMITS.PATH_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); + + describe("packages array (SHORT_STRING_MAX)", () => { + const schema = z + .array(z.string().max(INPUT_LIMITS.SHORT_STRING_MAX)) + .max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects package name exceeding SHORT_STRING_MAX", () => { + const oversized = ["x".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1)]; + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("accepts normal package names", () => { + expect(schema.safeParse(["flask", "requests", "numpy"]).success).toBe(true); + }); + }); +}); diff --git a/packages/server-test/__tests__/security.test.ts b/packages/server-test/__tests__/security.test.ts index fe9a51d1..629a5c78 100644 --- a/packages/server-test/__tests__/security.test.ts +++ b/packages/server-test/__tests__/security.test.ts @@ -8,7 +8,8 @@ * interpreted as a flag. */ import { describe, it, expect } from "vitest"; -import { assertNoFlagInjection } from "@paretools/shared"; +import { z } from "zod"; +import { assertNoFlagInjection, INPUT_LIMITS } from "@paretools/shared"; /** Malicious inputs that must be rejected by every guarded parameter. */ const MALICIOUS_INPUTS = [ @@ -61,3 +62,40 @@ describe("security: test run — args validation", () => { expect(() => assertNoFlagInjection("-x", "args")).toThrow(/-x/); }); }); + +// --------------------------------------------------------------------------- +// Zod .max() input-limit constraints — Test tool schemas +// --------------------------------------------------------------------------- + +describe("Zod .max() constraints — Test tool schemas", () => { + describe("args array (ARRAY_MAX + STRING_MAX)", () => { + const schema = z.array(z.string().max(INPUT_LIMITS.STRING_MAX)).max(INPUT_LIMITS.ARRAY_MAX); + + it("rejects array exceeding ARRAY_MAX", () => { + const oversized = Array.from({ length: INPUT_LIMITS.ARRAY_MAX + 1 }, () => "arg"); + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("rejects arg string exceeding STRING_MAX", () => { + const oversized = ["a".repeat(INPUT_LIMITS.STRING_MAX + 1)]; + expect(schema.safeParse(oversized).success).toBe(false); + }); + + it("accepts normal args", () => { + expect(schema.safeParse(["tests/", "unit"]).success).toBe(true); + }); + }); + + describe("path parameter (PATH_MAX)", () => { + const schema = z.string().max(INPUT_LIMITS.PATH_MAX); + + it("accepts a path within the limit", () => { + expect(schema.safeParse("/home/user/project").success).toBe(true); + }); + + it("rejects a path exceeding PATH_MAX", () => { + const oversized = "p".repeat(INPUT_LIMITS.PATH_MAX + 1); + expect(schema.safeParse(oversized).success).toBe(false); + }); + }); +}); diff --git a/packages/shared/__tests__/input-limits.test.ts b/packages/shared/__tests__/input-limits.test.ts new file mode 100644 index 00000000..58d26ee2 --- /dev/null +++ b/packages/shared/__tests__/input-limits.test.ts @@ -0,0 +1,177 @@ +/** + * Tests for INPUT_LIMITS Zod `.max()` constraints. + * + * Every Pare MCP server applies INPUT_LIMITS from @paretools/shared to Zod + * schemas via `.max()`. These tests verify that: + * - Values at/below the limit pass validation + * - Values above the limit are rejected + * + * This provides defense-in-depth against DoS via extremely long inputs. + */ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { INPUT_LIMITS } from "../src/limits.js"; + +describe("INPUT_LIMITS constants", () => { + it("STRING_MAX is 65,536", () => { + expect(INPUT_LIMITS.STRING_MAX).toBe(65_536); + }); + + it("ARRAY_MAX is 1,000", () => { + expect(INPUT_LIMITS.ARRAY_MAX).toBe(1_000); + }); + + it("PATH_MAX is 4,096", () => { + expect(INPUT_LIMITS.PATH_MAX).toBe(4_096); + }); + + it("MESSAGE_MAX is 72,000", () => { + expect(INPUT_LIMITS.MESSAGE_MAX).toBe(72_000); + }); + + it("SHORT_STRING_MAX is 255", () => { + expect(INPUT_LIMITS.SHORT_STRING_MAX).toBe(255); + }); +}); + +describe("Zod .max() with STRING_MAX (65,536)", () => { + const schema = z.string().max(INPUT_LIMITS.STRING_MAX); + + it("accepts a string at the limit", () => { + const value = "a".repeat(INPUT_LIMITS.STRING_MAX); + const result = schema.safeParse(value); + expect(result.success).toBe(true); + }); + + it("rejects a string exceeding the limit by 1", () => { + const value = "a".repeat(INPUT_LIMITS.STRING_MAX + 1); + const result = schema.safeParse(value); + expect(result.success).toBe(false); + }); + + it("rejects a string 2x the limit", () => { + const value = "a".repeat(INPUT_LIMITS.STRING_MAX * 2); + const result = schema.safeParse(value); + expect(result.success).toBe(false); + }); +}); + +describe("Zod .max() with SHORT_STRING_MAX (255)", () => { + const schema = z.string().max(INPUT_LIMITS.SHORT_STRING_MAX); + + it("accepts a string at the limit", () => { + const value = "x".repeat(INPUT_LIMITS.SHORT_STRING_MAX); + const result = schema.safeParse(value); + expect(result.success).toBe(true); + }); + + it("rejects a string exceeding the limit by 1", () => { + const value = "x".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1); + const result = schema.safeParse(value); + expect(result.success).toBe(false); + }); + + it("accepts typical short identifiers", () => { + expect(schema.safeParse("main").success).toBe(true); + expect(schema.safeParse("feature/auth-flow").success).toBe(true); + expect(schema.safeParse("@types/node").success).toBe(true); + expect(schema.safeParse("nginx:latest").success).toBe(true); + }); +}); + +describe("Zod .max() with PATH_MAX (4,096)", () => { + const schema = z.string().max(INPUT_LIMITS.PATH_MAX); + + it("accepts a path at the limit", () => { + const value = "/a".repeat(INPUT_LIMITS.PATH_MAX / 2); + const result = schema.safeParse(value); + expect(result.success).toBe(true); + }); + + it("rejects a path exceeding the limit by 1", () => { + const value = "p".repeat(INPUT_LIMITS.PATH_MAX + 1); + const result = schema.safeParse(value); + expect(result.success).toBe(false); + }); + + it("accepts typical file paths", () => { + expect(schema.safeParse("src/index.ts").success).toBe(true); + expect(schema.safeParse("/usr/local/bin/node").success).toBe(true); + expect(schema.safeParse("C:\\Users\\user\\project\\src\\file.ts").success).toBe(true); + }); +}); + +describe("Zod .max() with MESSAGE_MAX (72,000)", () => { + const schema = z.string().max(INPUT_LIMITS.MESSAGE_MAX); + + it("accepts a message at the limit", () => { + const value = "m".repeat(INPUT_LIMITS.MESSAGE_MAX); + const result = schema.safeParse(value); + expect(result.success).toBe(true); + }); + + it("rejects a message exceeding the limit by 1", () => { + const value = "m".repeat(INPUT_LIMITS.MESSAGE_MAX + 1); + const result = schema.safeParse(value); + expect(result.success).toBe(false); + }); + + it("accepts a normal commit message", () => { + expect(schema.safeParse("Fix the login bug in auth module").success).toBe(true); + }); +}); + +describe("Zod .max() with ARRAY_MAX (1,000)", () => { + const schema = z.array(z.string()).max(INPUT_LIMITS.ARRAY_MAX); + + it("accepts an array at the limit", () => { + const value = Array.from({ length: INPUT_LIMITS.ARRAY_MAX }, (_, i) => `item-${i}`); + const result = schema.safeParse(value); + expect(result.success).toBe(true); + }); + + it("rejects an array exceeding the limit by 1", () => { + const value = Array.from({ length: INPUT_LIMITS.ARRAY_MAX + 1 }, (_, i) => `item-${i}`); + const result = schema.safeParse(value); + expect(result.success).toBe(false); + }); + + it("accepts a typical small array", () => { + const result = schema.safeParse(["src/index.ts", "src/app.ts", "README.md"]); + expect(result.success).toBe(true); + }); +}); + +describe("Combined schema: array of short strings with limits", () => { + const schema = z.array(z.string().max(INPUT_LIMITS.SHORT_STRING_MAX)).max(INPUT_LIMITS.ARRAY_MAX); + + it("accepts valid array of short strings", () => { + const result = schema.safeParse(["serde", "tokio", "reqwest"]); + expect(result.success).toBe(true); + }); + + it("rejects when any string element exceeds SHORT_STRING_MAX", () => { + const result = schema.safeParse(["serde", "x".repeat(INPUT_LIMITS.SHORT_STRING_MAX + 1)]); + expect(result.success).toBe(false); + }); + + it("rejects when array exceeds ARRAY_MAX", () => { + const value = Array.from({ length: INPUT_LIMITS.ARRAY_MAX + 1 }, (_, i) => `pkg-${i}`); + const result = schema.safeParse(value); + expect(result.success).toBe(false); + }); +}); + +describe("Combined schema: array of path strings with limits", () => { + const schema = z.array(z.string().max(INPUT_LIMITS.PATH_MAX)).max(INPUT_LIMITS.ARRAY_MAX); + + it("accepts valid array of paths", () => { + const result = schema.safeParse(["src/index.ts", "lib/utils.ts"]); + expect(result.success).toBe(true); + }); + + it("rejects when any path element exceeds PATH_MAX", () => { + const result = schema.safeParse(["src/index.ts", "p".repeat(INPUT_LIMITS.PATH_MAX + 1)]); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/shared/__tests__/runner.test.ts b/packages/shared/__tests__/runner.test.ts index b2705b8a..5da66459 100644 --- a/packages/shared/__tests__/runner.test.ts +++ b/packages/shared/__tests__/runner.test.ts @@ -75,4 +75,42 @@ describe("escapeCmdArg", () => { it("does not escape parentheses (safe inside double quotes)", () => { expect(escapeCmdArg("(foo)")).toBe("(foo)"); }); + + it("handles strings composed entirely of metacharacters", () => { + // & -> ^& + // | -> ^| + // ^ -> ^^ (caret escaped first) + // < -> ^< + // > -> ^> + // % -> %% (percent escaped first) + expect(escapeCmdArg("&|^<>%")).toBe("^&^|^^^<^>%%"); + }); + + it("handles very long strings without error", () => { + const longArg = "a".repeat(100_000); + const result = escapeCmdArg(longArg); + expect(result).toBe(longArg); + expect(result.length).toBe(100_000); + }); + + it("handles very long strings with metacharacters", () => { + const longArg = "&".repeat(1_000); + const result = escapeCmdArg(longArg); + // Each & becomes ^& (2 chars) + expect(result.length).toBe(2_000); + expect(result).toBe("^&".repeat(1_000)); + }); + + it("handles string with only percent signs", () => { + expect(escapeCmdArg("%%%")).toBe("%%%%%%"); + }); + + it("handles string with only carets", () => { + expect(escapeCmdArg("^^^")).toBe("^^^^^^"); + }); + + it("handles newlines and tabs (not metacharacters, passed through)", () => { + expect(escapeCmdArg("line1\nline2")).toBe("line1\nline2"); + expect(escapeCmdArg("col1\tcol2")).toBe("col1\tcol2"); + }); }); diff --git a/packages/shared/__tests__/validation.test.ts b/packages/shared/__tests__/validation.test.ts index af5c21a9..47a3d8ba 100644 --- a/packages/shared/__tests__/validation.test.ts +++ b/packages/shared/__tests__/validation.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { assertNoFlagInjection, assertAllowedCommand } from "../src/validation.js"; +import { INPUT_LIMITS } from "../src/limits.js"; describe("assertNoFlagInjection", () => { it("throws for values starting with -", () => { @@ -181,3 +182,24 @@ describe("assertAllowedCommand", () => { expect(() => assertAllowedCommand("C:\\evil\\bash")).toThrow(); }); }); + +// --------------------------------------------------------------------------- +// INPUT_LIMITS — exported constant verification +// --------------------------------------------------------------------------- + +describe("INPUT_LIMITS", () => { + it("exports the expected limits", () => { + expect(INPUT_LIMITS.STRING_MAX).toBe(65_536); + expect(INPUT_LIMITS.ARRAY_MAX).toBe(1_000); + expect(INPUT_LIMITS.PATH_MAX).toBe(4_096); + expect(INPUT_LIMITS.MESSAGE_MAX).toBe(72_000); + expect(INPUT_LIMITS.SHORT_STRING_MAX).toBe(255); + }); + + it("all values are positive integers", () => { + for (const [key, value] of Object.entries(INPUT_LIMITS)) { + expect(Number.isInteger(value), `${key} should be an integer`).toBe(true); + expect(value, `${key} should be positive`).toBeGreaterThan(0); + } + }); +});