diff --git a/scripts/build/compile-binary.test.ts b/scripts/build/compile-binary.test.ts index 2f24d80975..b8a3507eee 100644 --- a/scripts/build/compile-binary.test.ts +++ b/scripts/build/compile-binary.test.ts @@ -1,5 +1,5 @@ import { walk } from "#std/fs/walk"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { it } from "#veryfront/testing/bdd.ts"; import { FIRST_PARTY_DEFERRED_BUILTIN_EXTENSION_POLICIES } from "#veryfront/extensions/first-party-defaults.ts"; import { @@ -8,6 +8,79 @@ import { PROXY_INCLUDES, } from "./compile-binary.ts"; +interface DenoInfoDependency { + code?: { specifier: string }; + type?: { specifier: string }; +} + +interface DenoInfoModule { + dependencies?: DenoInfoDependency[] | null; + specifier: string; +} + +interface DenoInfoGraph { + roots: string[]; + modules: DenoInfoModule[]; +} + +function isResolvedDependency(value: unknown): value is DenoInfoDependency { + if (!value || typeof value !== "object") return false; + const dependency = value as Record; + if ( + typeof dependency.specifier !== "string" || + dependency.specifier.length === 0 + ) { + return false; + } + + const targets = [dependency.code, dependency.type].filter((target) => + target != null + ); + return targets.length > 0 && + targets.every((target) => + typeof target === "object" && + target !== null && + typeof (target as Record).specifier === "string" && + ((target as Record).specifier as string).length > 0 + ); +} + +function parseDenoInfoGraph(value: unknown): DenoInfoGraph { + if (!value || typeof value !== "object") { + throw new TypeError("Invalid deno info graph"); + } + const graph = value as Record; + if ( + !Array.isArray(graph.roots) || + graph.roots.length === 0 || + !graph.roots.every((root) => typeof root === "string" && root.length > 0) || + !Array.isArray(graph.modules) || + graph.modules.length === 0 + ) { + throw new TypeError("Invalid deno info graph"); + } + + for (const module of graph.modules) { + if (!module || typeof module !== "object") { + throw new TypeError("Invalid deno info module"); + } + const record = module as Record; + if (typeof record.specifier !== "string" || record.specifier.length === 0) { + throw new TypeError("Invalid deno info module specifier"); + } + if ( + record.dependencies !== undefined && + record.dependencies !== null && + (!Array.isArray(record.dependencies) || + !record.dependencies.every(isResolvedDependency)) + ) { + throw new TypeError("Invalid deno info dependency"); + } + } + + return graph as unknown as DenoInfoGraph; +} + it("compiled CLI embeds the default Node WebSocket extension for HMR", () => { const args = createCompileArgs({ entrypoint: "cli/main.ts", @@ -184,6 +257,67 @@ it("proxy binary embeds only the runtime-resolved proxy entrypoint", async () => } }); +it("proxy binary cannot reach declarative config worker modules", async () => { + const command = new Deno.Command(Deno.execPath(), { + args: ["info", "--json", "cli/proxy-main.ts"], + stdout: "piped", + stderr: "piped", + }); + const output = await command.output(); + assertEquals( + output.success, + true, + new TextDecoder().decode(output.stderr), + ); + + const info = parseDenoInfoGraph( + JSON.parse(new TextDecoder().decode(output.stdout)), + ); + const modules = new Map( + info.modules.map((module) => [module.specifier, module]), + ); + for (const root of info.roots) { + if (!modules.has(root)) { + throw new TypeError("Deno info root is missing from the module graph"); + } + } + const reachable = new Set(info.roots); + const pending = [...reachable]; + while (pending.length > 0) { + const specifier = pending.shift()!; + for (const dependency of modules.get(specifier)?.dependencies ?? []) { + const dependencySpecifier = dependency.code?.specifier; + if (!dependencySpecifier || reachable.has(dependencySpecifier)) continue; + reachable.add(dependencySpecifier); + pending.push(dependencySpecifier); + } + } + const evaluatorWorkerModules = [...reachable] + .filter((specifier) => specifier.includes("declarative-evaluator-worker")); + + assertEquals( + evaluatorWorkerModules, + [], + "the proxy must not reach project config evaluation; it only forwards requests to the production server", + ); +}); + +it("proxy graph validation rejects incomplete metadata", () => { + for ( + const graph of [ + {}, + { roots: [], modules: [] }, + { roots: ["proxy"], modules: [{}] }, + { + roots: ["proxy"], + modules: [{ specifier: "proxy", dependencies: [{}] }], + }, + ] + ) { + assertThrows(() => parseDenoInfoGraph(graph), TypeError); + } +}); + it("proxy release verifies lock freshness and publishes an exact SBOM", async () => { const workflow = await Deno.readTextFile(".github/workflows/cicd.yml"); const denoConfig = JSON.parse(await Deno.readTextFile("deno.json")) as { diff --git a/scripts/build/compile-binary.ts b/scripts/build/compile-binary.ts index 6f9dd0c7f6..da0ce91f91 100644 --- a/scripts/build/compile-binary.ts +++ b/scripts/build/compile-binary.ts @@ -69,19 +69,12 @@ export const DEFAULT_INCLUDES = [ ]; export const PROXY_INCLUDES = [ - // Deliberately omits UNTRACEABLE_WORKER_INCLUDES, on build grounds only. - // Adding them fails the compile outright: the worker entry's graph wants - // @babel/types@7.29.8 plus @babel/helper-string-parser and - // @babel/helper-validator-identifier, while proxy-deno.lock pins - // @babel/types@7.29.0, and --frozen refuses to update the lock. - // - // This is NOT evidence that the proxy is safe. The lock already carries a - // babel parse toolchain (parser, generator, traverse, types), so "the deps - // are absent, therefore the worker never runs here" does not follow -- - // the conflict is a version skew, not an absence. `cli/proxy-main.ts` does - // pull the evaluator's runner into its graph, so whether the proxy can reach - // a spawn at runtime is an open question, tracked in veryfront-issue-inbox#382. - // If it can, this list and the proxy lock have to be regenerated together. + // Deliberately omits UNTRACEABLE_WORKER_INCLUDES. The standalone proxy + // forwards project requests to the production server and never evaluates + // project config. Its entry graph must therefore remain unable to reach the + // declarative evaluator worker. compile-binary.test.ts enforces that runtime + // boundary. If project config evaluation is added to the proxy, embed every + // reachable worker here and regenerate the proxy lock in the same change. // // The proxy runtime is loaded after provider activation. Providers are // statically referenced by cli/proxy-main.ts so --include does not embed the diff --git a/src/proxy/control-plane-signature.ts b/src/proxy/control-plane-signature.ts index cda6fd38e1..690b744f3e 100644 --- a/src/proxy/control-plane-signature.ts +++ b/src/proxy/control-plane-signature.ts @@ -32,7 +32,10 @@ import { verifyControlPlaneJwsSignature, verifyDispatchJwsSignature, } from "#veryfront/channels/control-plane.ts"; -import { isRequestBodyTooLargeError, readBodyWithLimit } from "#veryfront/security/index.ts"; +import { + isRequestBodyTooLargeError, + readBodyWithLimit, +} from "#veryfront/security/input-validation/limits.ts"; import { DEFAULT_MAX_BODY_SIZE_BYTES } from "#veryfront/utils/constants/index.ts"; import { isWellFormedString } from "#veryfront/utils/is-well-formed-string.ts"; import { isCanonicalOpaqueProjectIdentifier } from "#veryfront/utils/project-identity.ts";