From 0c9fa637ecc2685837a2a1f041367b0f17655062 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 25 Aug 2026 18:17:57 +0000 Subject: [PATCH 01/11] fix(security): restrict public environment export - Point the public veryfront/platform/env subpath (and extension deno.json mappings) at a facade that re-exports only project-scoped readers: getEnv, getEnvString, getEnvNumber, getEnvBoolean, EnvBooleanOptions - Contain setEnv/deleteEnv to the active project env snapshot so the public testing surface cannot mutate the shared host process environment - Refuse tenant module loading of privileged framework source (platform/compat/process*) in the SSR vf-modules resolver and the ESM module fetcher, while framework-internal transitive resolution stays intact - Move CLI serve internals to the veryfront/platform barrel for setEnv - Add regression tests for the facade surface, scoped mutators, and privileged-module refusal --- .../serve/proxy-extension-composition.ts | 2 +- cli/commands/serve/proxy-runtime.ts | 2 +- deno.json | 4 +- extensions/ext-eval-report-mlflow/deno.json | 2 +- extensions/ext-redis/deno.json | 2 +- .../compat/framework-source-resolver.test.ts | 42 +++++++++++++++ .../compat/framework-source-resolver.ts | 36 +++++++++++++ src/platform/compat/process.test.ts | 26 ++++++++++ src/platform/compat/process/env.ts | 18 +++++++ .../compat/process/scoped-process-env.ts | 24 +++++++++ src/platform/env.test.ts | 14 +++++ src/platform/env.ts | 25 +++++++++ .../module-fetcher/index.test.ts | 47 +++++++++++++++++ .../esm-module-loader/module-fetcher/index.ts | 52 +++++++++++++++++++ .../ssr-vf-modules/path-resolver.test.ts | 39 ++++++++++++++ .../stages/ssr-vf-modules/path-resolver.ts | 14 +++++ 16 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 src/platform/env.test.ts create mode 100644 src/platform/env.ts diff --git a/cli/commands/serve/proxy-extension-composition.ts b/cli/commands/serve/proxy-extension-composition.ts index eff11adb8d..2b5935cbd1 100644 --- a/cli/commands/serve/proxy-extension-composition.ts +++ b/cli/commands/serve/proxy-extension-composition.ts @@ -10,7 +10,7 @@ import { cliLogger } from "veryfront/utils/logger"; import { type ExtensionFactory, ExtensionLoader } from "veryfront/extensions"; import { importFirstPartyExtensionModule } from "veryfront/extensions/first-party-import"; -import { getEnv, setEnv } from "veryfront/platform/env"; +import { getEnv, setEnv } from "veryfront/platform"; import { createProxyShutdownAggregateError, type RegisterProxyShutdownHook, diff --git a/cli/commands/serve/proxy-runtime.ts b/cli/commands/serve/proxy-runtime.ts index f30a380b89..6ec4b01cd8 100644 --- a/cli/commands/serve/proxy-runtime.ts +++ b/cli/commands/serve/proxy-runtime.ts @@ -1,4 +1,4 @@ -import { getEnv, setEnv } from "veryfront/platform/env"; +import { getEnv, setEnv } from "veryfront/platform"; import { cliLogger } from "veryfront/utils/logger"; import denoConfig from "../../../deno.json" with { type: "json" }; import { ensureCliSchemaValidator } from "../../shared/default-contracts.ts"; diff --git a/deno.json b/deno.json index 49bc5616ab..80a753efd8 100644 --- a/deno.json +++ b/deno.json @@ -173,7 +173,7 @@ "./extensions/schema": "./src/extensions/schema/index.ts", "./extensions/observability": "./src/extensions/observability/index.ts", "./extensions/websocket": "./src/extensions/websocket/index.ts", - "./platform/env": "./src/platform/compat/process/env.ts", + "./platform/env": "./src/platform/env.ts", "./testing": "./src/testing/index.ts", "./testing/assert": "./src/testing/assert.ts", "./testing/bdd": "./src/testing/bdd.ts", @@ -244,7 +244,7 @@ "veryfront/observability/otlp-setup": "./src/observability/tracing/otlp-setup.ts", "veryfront/observability/sentry": "./src/observability/sentry.ts", "veryfront/platform/esbuild-init": "./src/platform/compat/esbuild-init.ts", - "veryfront/platform/env": "./src/platform/compat/process/env.ts", + "veryfront/platform/env": "./src/platform/env.ts", "veryfront/platform/path": "./src/platform/compat/path/index.ts", "veryfront/platform/http": "./src/platform/compat/http/index.ts", "veryfront/cli": "./cli/main.ts", diff --git a/extensions/ext-eval-report-mlflow/deno.json b/extensions/ext-eval-report-mlflow/deno.json index 45c67d95ce..38aa1d2d09 100644 --- a/extensions/ext-eval-report-mlflow/deno.json +++ b/extensions/ext-eval-report-mlflow/deno.json @@ -49,7 +49,7 @@ "veryfront/eval": "../../src/eval/index.ts", "veryfront/extensions": "../../src/extensions/index.ts", "veryfront/extensions/eval": "../../src/extensions/eval/index.ts", - "veryfront/platform/env": "../../src/platform/compat/process/env.ts" + "veryfront/platform/env": "../../src/platform/env.ts" }, "tasks": { "test": "deno test --no-check --allow-all src/" diff --git a/extensions/ext-redis/deno.json b/extensions/ext-redis/deno.json index dce7a3a608..e15261db71 100644 --- a/extensions/ext-redis/deno.json +++ b/extensions/ext-redis/deno.json @@ -28,7 +28,7 @@ "veryfront/extensions/distributed/rate-limit-support": "../../src/extensions/distributed/rate-limit-support.ts", "veryfront/extensions/distributed/routing-invalidation-support": "../../src/extensions/distributed/routing-invalidation-support.ts", "veryfront/extensions/types": "../../src/extensions/types.ts", - "veryfront/platform/env": "../../src/platform/compat/process/env.ts", + "veryfront/platform/env": "../../src/platform/env.ts", "veryfront/observability": "../../src/observability/index.ts", "veryfront/observability/otlp-setup": "../../src/observability/tracing/otlp-setup.ts", "veryfront/utils/logger": "../../src/utils/logger/index.ts", diff --git a/src/platform/compat/framework-source-resolver.test.ts b/src/platform/compat/framework-source-resolver.test.ts index 9f42b10d6c..acd2e7b266 100644 --- a/src/platform/compat/framework-source-resolver.test.ts +++ b/src/platform/compat/framework-source-resolver.test.ts @@ -5,6 +5,7 @@ import { FRAMEWORK_EMBEDDED_SRC_DIR, FRAMEWORK_SRC_DIR, getFrameworkSourceLookupDirs, + isPrivilegedFrameworkSourceKey, resolveFrameworkSourcePath, resolveRelativeFrameworkSourceImport, } from "./framework-source-resolver.ts"; @@ -309,3 +310,44 @@ describe("framework-source-resolver (VULN-FS-3) — path containment", () => { assertEquals(result?.path, target); }); }); + +describe("framework-source-resolver — privileged source keys", () => { + const privilegedKeys = [ + "platform/compat/process", + "platform/compat/process.ts", + "platform/compat/process.js", + "platform/compat/process/env", + "platform/compat/process/env.ts", + "platform/compat/process/env.js", + "platform/compat/process/env.ts.src", + "platform/compat/process/env.js?ssr=true", + "platform/compat/process/runtime-process.ts", + "platform/compat/process/scoped-process-env.ts", + "platform/compat/process/host-runtime.ts", + "platform/compat/process/lifecycle.ts", + "platform/compat/process/command.ts", + ]; + + for (const key of privilegedKeys) { + it(`marks ${key} as privileged`, () => { + assertEquals(isPrivilegedFrameworkSourceKey(key), true); + }); + } + + const publicKeys = [ + "platform/env", + "platform/env.ts", + "platform/index", + "platform/compat/fs", + "platform/compat/path/index", + "platform/compat/processor", // sibling name must not match by prefix + "testing/index", + "react/runtime/core", + ]; + + for (const key of publicKeys) { + it(`keeps ${key} resolvable`, () => { + assertEquals(isPrivilegedFrameworkSourceKey(key), false); + }); + } +}); diff --git a/src/platform/compat/framework-source-resolver.ts b/src/platform/compat/framework-source-resolver.ts index a12b218ce6..f7d30e0c3d 100644 --- a/src/platform/compat/framework-source-resolver.ts +++ b/src/platform/compat/framework-source-resolver.ts @@ -33,6 +33,42 @@ export function isSafeFrameworkSourceKey(candidate: string): boolean { return !hasDangerousSegments(candidate); } +/** + * Framework subtrees that tenant module loading must never resolve directly. + * + * `platform/compat/process` holds the host process seam: `getHostEnv()`, the + * captured host environment record, the scoped-write bookkeeping, and process + * mutators. Tenant code reaches framework source through supported package + * exports (for environment access, `veryfront/platform/env`), and the + * implementation modules stay reachable for the framework's own transform + * graph, which resolves transitive imports through separate trusted paths. + * Serving these modules as tenant-requested entry points would let a project + * import `getHostEnv` and read host-only secrets while its project + * environment overlay is active. + */ +const PRIVILEGED_FRAMEWORK_SOURCE_PREFIXES = ["platform/compat/process"] as const; + +const FRAMEWORK_SOURCE_KEY_EXT_RE = /\.(?:src|tsx|ts|jsx|js|mjs|cjs|mdx|md|json)$/; + +/** + * Return whether a tenant-supplied framework source key names a privileged + * implementation module that must not be served to tenant module loading. + * + * The key is compared after stripping any query suffix and trailing module + * extensions (including `.src` embedded-source suffixes), so + * `platform/compat/process/env`, `platform/compat/process/env.ts`, and + * `platform/compat/process/env.js?ssr=true` all match. + */ +export function isPrivilegedFrameworkSourceKey(candidate: string): boolean { + let normalized = candidate.replace(/\?.*$/, "").replace(/\/+$/, ""); + while (FRAMEWORK_SOURCE_KEY_EXT_RE.test(normalized)) { + normalized = normalized.replace(FRAMEWORK_SOURCE_KEY_EXT_RE, ""); + } + return PRIVILEGED_FRAMEWORK_SOURCE_PREFIXES.some((prefix) => + normalized === prefix || normalized.startsWith(`${prefix}/`) + ); +} + export const FRAMEWORK_ROOT = getFrameworkRootFromMeta(import.meta.url); export const FRAMEWORK_SRC_DIR = join(FRAMEWORK_ROOT, "src"); export const FRAMEWORK_EMBEDDED_SRC_DIR = join(FRAMEWORK_ROOT, "dist", "framework-src"); diff --git a/src/platform/compat/process.test.ts b/src/platform/compat/process.test.ts index f9fe011cab..ceeeacc228 100644 --- a/src/platform/compat/process.test.ts +++ b/src/platform/compat/process.test.ts @@ -128,6 +128,32 @@ describe("Process Compat", () => { assertEquals(getEnv(testKey), specialValue); }); + it("contains setEnv writes to the active project scope", () => { + setEnv(testKey, "host-value"); + + runWithProjectEnv({ [testKey]: "project-value" }, () => { + setEnv(testKey, "project-write"); + assertEquals(getEnv(testKey), "project-write"); + assertEquals(env()[testKey], "project-write"); + }); + + assertEquals(getEnv(testKey), "host-value"); + assertEquals(getHostEnv(testKey), "host-value"); + }); + + it("contains deleteEnv to the active project scope", () => { + setEnv(testKey, "host-value"); + + runWithProjectEnv({ [testKey]: "project-value" }, () => { + deleteEnv(testKey); + assertEquals(getEnv(testKey), undefined); + assertEquals(env()[testKey], undefined); + }); + + assertEquals(getEnv(testKey), "host-value"); + assertEquals(getHostEnv(testKey), "host-value"); + }); + it("keeps direct env readers aligned inside the test overlay", () => { setEnv(testKey, testValue); diff --git a/src/platform/compat/process/env.ts b/src/platform/compat/process/env.ts index 863006e7d7..e030cad499 100644 --- a/src/platform/compat/process/env.ts +++ b/src/platform/compat/process/env.ts @@ -1,9 +1,11 @@ import { getDenoRuntime, isDeno as IS_DENO } from "../runtime.ts"; import { hostProcessEnv, runtimeProcess } from "./runtime-process.ts"; import { + deleteProjectScopedEnv, installProjectScopedProcessEnv, projectScopedEnvRecord, readProjectScopedEnv, + writeProjectScopedEnv, } from "./scoped-process-env.ts"; import type { ProjectEnvSnapshot } from "./project-env-contract.ts"; @@ -233,6 +235,15 @@ export function getEnvBoolean( /** Sets env. */ export function setEnv(key: string, value: string): void { + const projectEnv = getTrustedProjectEnvSnapshot(); + if (projectEnv !== undefined) { + // Same rule as getEnv() and the process.env view: while a project scope is + // active its snapshot is the whole environment, so a write stays contained + // to that scope instead of mutating the shared host process environment. + writeProjectScopedEnv(projectEnv, key, value); + return; + } + const overlay = getEnvOverlayStore(); if (overlay) { overlay.set(key, value); @@ -253,6 +264,13 @@ export function setEnv(key: string, value: string): void { /** Delete a process environment variable. */ export function deleteEnv(key: string): void { + const projectEnv = getTrustedProjectEnvSnapshot(); + if (projectEnv !== undefined) { + // Contained to the active project scope for the same reason as setEnv(). + deleteProjectScopedEnv(projectEnv, key); + return; + } + const overlay = getEnvOverlayStore(); if (overlay) { overlay.set(key, null); diff --git a/src/platform/compat/process/scoped-process-env.ts b/src/platform/compat/process/scoped-process-env.ts index ba29ad4941..1ee7fb56ab 100644 --- a/src/platform/compat/process/scoped-process-env.ts +++ b/src/platform/compat/process/scoped-process-env.ts @@ -62,6 +62,30 @@ export function readProjectScopedEnv( return readScoped(snapshot, key); } +/** + * Record a write against the active snapshot instead of the host environment. + * + * Exported so the mutating accessors (`setEnv()`) apply writes through exactly + * the same rule as the raw `process.env` view: while a project scope is + * active, its snapshot owns the whole environment and a write must stay + * contained to that scope rather than reaching the shared host process. + */ +export function writeProjectScopedEnv( + snapshot: ProjectEnvSnapshot, + key: string, + value: string, +): void { + writesFor(snapshot).set(key, value); +} + +/** Record a deletion against the active snapshot (masks the snapshot entry). */ +export function deleteProjectScopedEnv( + snapshot: ProjectEnvSnapshot, + key: string, +): void { + writesFor(snapshot).set(key, null); +} + /** The scoped view as a plain record, for the bulk accessor. */ export function projectScopedEnvRecord( snapshot: ProjectEnvSnapshot, diff --git a/src/platform/env.test.ts b/src/platform/env.test.ts new file mode 100644 index 0000000000..a1192ee1eb --- /dev/null +++ b/src/platform/env.test.ts @@ -0,0 +1,14 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import * as publicEnv from "./env.ts"; + +describe("platform/env", () => { + it("only exports the project-scoped environment readers", () => { + assertEquals(Object.keys(publicEnv).sort(), [ + "getEnv", + "getEnvBoolean", + "getEnvNumber", + "getEnvString", + ]); + }); +}); diff --git a/src/platform/env.ts b/src/platform/env.ts new file mode 100644 index 0000000000..2d5c7a7d2d --- /dev/null +++ b/src/platform/env.ts @@ -0,0 +1,25 @@ +/** + * Public environment facade for the `veryfront/platform/env` subpath. + * + * Exposes only project-scoped readers. Privileged or mutating accessors + * (`getHostEnv`, `env`, `setEnv`, `deleteEnv`) stay internal so a tenant + * project cannot read or alter the host process environment through a + * supported package export. + * + * @module platform/env + */ + +/** Options accepted by the boolean environment reader. */ +export type { EnvBooleanOptions } from "./compat/process/env.ts"; + +/** Read an environment variable from the active project scope. */ +export { getEnv } from "./compat/process/env.ts"; + +/** Read a boolean environment variable from the active project scope. */ +export { getEnvBoolean } from "./compat/process/env.ts"; + +/** Read an integer environment variable from the active project scope. */ +export { getEnvNumber } from "./compat/process/env.ts"; + +/** Read a string environment variable with an optional fallback. */ +export { getEnvString } from "./compat/process/env.ts"; diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts index fb3062e7c0..2ea38670fc 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts @@ -373,6 +373,53 @@ describe("module-fetcher", () => { assertEquals(resolved, false); }); + describe("privileged framework modules", () => { + const untouchableAdapter = { + env: { get: (_key: string) => undefined }, + fs: { + resolveFile: (_path: string) => { + throw new Error("resolveFile must not be called for a refused privileged module"); + }, + readFile: (_path: string) => { + throw new Error("readFile must not be called for a refused privileged module"); + }, + }, + } as any; + + it("refuses a tenant entry import of the host env implementation", async () => { + const ctx = createModuleFetcherContext( + "/cache", + untouchableAdapter, + "/project", + "proj-privileged", + { strictMissingModules: true }, + ); + + const result = await fetchAndCacheModule( + "/_vf_modules/_veryfront/platform/compat/process/env.js", + ctx, + ); + assertEquals(result, null); + }); + + it("refuses a privileged module imported from a tenant module", async () => { + const ctx = createModuleFetcherContext( + "/cache", + untouchableAdapter, + "/project", + "proj-privileged", + { strictMissingModules: true }, + ); + + const result = await fetchAndCacheModule( + "/_vf_modules/_veryfront/platform/compat/process/scoped-process-env.js", + ctx, + "_vf_modules/components/page.js", + ); + assertEquals(result, null); + }); + }); + describe("strictMissingModules", () => { it("throws when module cannot be resolved", async () => { const esmCacheDir = await makeTempDir({ prefix: "vf-mdx-strict-cache-" }); diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts index 86d6362f59..a82c7fabe7 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts @@ -21,6 +21,8 @@ import type { ModuleFetcherContext } from "../types.ts"; import { getModulePathCache } from "../cache/index.ts"; import { hashString } from "../utils/hash.ts"; import { resolveModuleFile } from "../resolution/file-finder.ts"; +import { canonicalizeContainedModulePath } from "../resolution/module-path.ts"; +import { isPrivilegedFrameworkSourceKey } from "#veryfront/platform/compat/framework-source-resolver.ts"; import { getTransformCacheKey, getVersionedPathCacheKey } from "./cache-keys.ts"; import { resolveNestedImportBase, resolveNestedModuleImports } from "./nested-imports.ts"; import { readDistributedCache } from "./distributed-cache.ts"; @@ -151,6 +153,46 @@ function unwrapDependencyPinningPath( return extracted.pathname; } +/** + * Return the framework-relative source key for a `_veryfront/` module path, + * or null when the path does not address framework source. + */ +function frameworkSourceKeyOf(modulePath: string): string | null { + const withoutVfModules = modulePath.startsWith("_vf_modules/") + ? modulePath.slice("_vf_modules/".length) + : modulePath; + if (!withoutVfModules.startsWith("_veryfront/")) return null; + return withoutVfModules.slice("_veryfront/".length); +} + +/** + * Return whether a privileged framework module fetch must be refused. + * + * Privileged implementation modules (the host process env seam) may only be + * fetched as transitive dependencies of framework source — a fetch whose + * parent is itself a framework module. A fetch reached from tenant code + * (project module parent, or no parent at all) is refused before any cache + * lookup, so a copy cached for the framework graph is never handed to a + * tenant-requested import. + */ +function isRefusedPrivilegedModuleFetch( + normalizedPath: string, + parentModulePath: string | undefined, + expectedCacheKey: string | undefined, +): boolean { + const frameworkKey = frameworkSourceKeyOf(normalizedPath); + if (frameworkKey === null || !isPrivilegedFrameworkSourceKey(frameworkKey)) { + return false; + } + + if (parentModulePath === undefined) return true; + const normalizedParent = canonicalizeContainedModulePath( + unwrapDependencyPinningPath(parentModulePath, expectedCacheKey), + ); + if (normalizedParent === null) return true; + return frameworkSourceKeyOf(normalizedParent) === null; +} + /** * Fetch and cache a module. * This is the main entry point for module fetching operations. @@ -168,6 +210,16 @@ export async function fetchAndCacheModule( parentModulePath ? unwrapDependencyPinningPath(parentModulePath, expectedCacheKey) : undefined, ); const projectSlug = context.projectSlug || "unknown"; + + if (isRefusedPrivilegedModuleFetch(normalizedPath, parentModulePath, expectedCacheKey)) { + log.warn(`${LOG_PREFIX_MDX_LOADER} Refusing privileged framework module for tenant import`, { + projectSlug, + normalizedPath, + parentModulePath, + }); + return null; + } + const moduleGraph = context.moduleGraph ??= new Set(); if (!moduleGraph.has(normalizedPath)) { if (moduleGraph.size >= MAX_MDX_MODULE_GRAPH_ENTRIES) { diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts index a8fd0e4f0c..428dccb181 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts @@ -105,6 +105,45 @@ describe("resolveFrameworkFile", () => { assertEquals(result?.content, "export function usePageContext() {}"); }); + for ( + const privilegedPath of [ + "/_vf_modules/_veryfront/platform/compat/process/env.js", + "/_vf_modules/_veryfront/platform/compat/process/env.js?ssr=true", + "/_vf_modules/_veryfront/platform/compat/process/env.ts", + "/_vf_modules/_veryfront/platform/compat/process/runtime-process.js", + "/_vf_modules/_veryfront/platform/compat/process/scoped-process-env.js", + "/_vf_modules/_veryfront/platform/compat/process.js", + "file:///_vf_modules/_veryfront/platform/compat/process/env.js?ssr=true", + ] + ) { + it(`refuses privileged framework module ${privilegedPath}`, async () => { + const fs = createMockFs( + new Proxy({}, { + has: () => true, + get: () => "export function getHostEnv() {}", + }) as Record, + ); + + const result = await resolveFrameworkFile(privilegedPath, fs, async () => true); + + assertEquals(result, null); + }); + } + + it("still resolves the public platform/env facade", async () => { + const sourcePath = join(FRAMEWORK_ROOT, "src", "platform", "env.ts"); + const files: Record = { + [sourcePath]: 'export { getEnv } from "./compat/process/env.ts";', + }; + const fs = createMockFs(files); + const result = await resolveFrameworkFile( + "/_vf_modules/_veryfront/platform/env.js?ssr=true", + fs, + createExistsFn(files), + ); + assertEquals(result?.sourcePath, sourcePath); + }); + for ( const maliciousPath of [ "/_vf_modules/_veryfront/../../secret.js", diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts index 3905f69bd6..27c38297aa 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts @@ -9,6 +9,7 @@ import { createFileSystem, exists } from "#veryfront/platform/compat/fs.ts"; import { join } from "#veryfront/compat/path/index.ts"; import { rendererLogger as logger } from "#veryfront/utils"; import { + isPrivilegedFrameworkSourceKey, isSafeFrameworkSourceKey, resolveRelativeFrameworkSourceImport, } from "#veryfront/platform/compat/framework-source-resolver.ts"; @@ -66,6 +67,19 @@ export async function resolveFrameworkFile( ? pathWithoutPrefix.slice("_veryfront/".length) : pathWithoutPrefix; if (!isSafeFrameworkSourceKey(frameworkRelativePath)) return null; + // /_vf_modules/ specifiers originate from tenant module graphs (either + // written literally or rewritten from tenant `#veryfront/*` imports), so a + // privileged implementation module must not be served as an entry point. + // The framework's own transitive imports resolve through + // resolveAndTransformVeryfrontImport / resolveRelativeFrameworkImport and + // are unaffected. + if (isPrivilegedFrameworkSourceKey(frameworkRelativePath)) { + logger.warn(`${LOG_PREFIX} Refusing privileged framework module for tenant import`, { + vfModulePath, + frameworkRelativePath, + }); + return null; + } logger.debug(`${LOG_PREFIX} resolveFrameworkFile`, { input: vfModulePath, From a214b76da739316ad04f1b3e2b1728d690233a29 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 25 Aug 2026 18:43:24 +0000 Subject: [PATCH 02/11] docs(api-reference): refresh env accessor source line references The scoped setEnv/deleteEnv containment shifted declaration lines in src/platform/compat/process/env.ts. Regenerate the affected source links in the veryfront, veryfront/index.client, and veryfront/testing API reference pages so the api-reference check stays green. Claude-Session: https://claude.ai/code/session_012VprCnNBzAi9PRvzhjYNcb --- docs/api-reference/veryfront/index.client.md | 2 +- docs/api-reference/veryfront/index.md | 2 +- docs/api-reference/veryfront/testing.md | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/api-reference/veryfront/index.client.md b/docs/api-reference/veryfront/index.client.md index cfdffef9ab..b4dee9e78e 100644 --- a/docs/api-reference/veryfront/index.client.md +++ b/docs/api-reference/veryfront/index.client.md @@ -49,7 +49,7 @@ export function GET() { | `defineConfig` | Define a Veryfront project configuration object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L4) | | `defineConfigWithEnv` | Define a Veryfront project configuration from the current environment name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L8) | | `forbidden` | Create a 403 Forbidden response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L134) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L155) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L157) | | `json` | Create a JSON response with the correct content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L67) | | `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L17) | | `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L63) | diff --git a/docs/api-reference/veryfront/index.md b/docs/api-reference/veryfront/index.md index cdbe4c1aca..19c8e05b81 100644 --- a/docs/api-reference/veryfront/index.md +++ b/docs/api-reference/veryfront/index.md @@ -67,7 +67,7 @@ export function getServerData(ctx: DataContext) { | `defineConfig` | Define a Veryfront project configuration object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L4) | | `defineConfigWithEnv` | Define a Veryfront project configuration from the current environment name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L8) | | `forbidden` | Create a 403 Forbidden response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L134) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L155) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L157) | | `json` | Create a JSON response with the correct content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L67) | | `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L17) | | `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L63) | diff --git a/docs/api-reference/veryfront/testing.md b/docs/api-reference/veryfront/testing.md index 4767d088c8..f670db0599 100644 --- a/docs/api-reference/veryfront/testing.md +++ b/docs/api-reference/veryfront/testing.md @@ -60,14 +60,14 @@ describe("math", () => { | `cwd` | Return the current working directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L26) | | `deepEquals` | ********************* Shared utility functions for cross-runtime testing. ********************* | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/utils.ts#L5) | | `delay` | Wait for a duration in milliseconds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L123) | -| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L255) | +| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L266) | | `describe` | Group related BDD tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L611) | -| `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L57) | +| `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L59) | | `exists` | Return false for a missing path and propagate every other filesystem error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L522) | | `exit` | Exit the current process. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L128) | | `fail` | Fail the current assertion immediately. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L336) | | `getArgs` | Get command-line arguments (cross-runtime: Deno.args or process.argv). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L10) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L155) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L157) | | `getTestTimeScale` | Return the current test time scale. Preserved for compatibility. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L4) | | `isAlreadyExistsError` | Error shape for is already exists. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L628) | | `isNotFoundError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/not-found-error.ts#L210) | @@ -84,7 +84,7 @@ describe("math", () => { | `resetAllTestState` | Comprehensive reset of ALL test state across the application. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/isolation.ts#L64) | | `safeStringify` | Serialize unknown values safely for test output. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/utils.ts#L34) | | `scaleMs` | Scale a duration for the current test runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L9) | -| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L235) | +| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L237) | | `stat` | Read file metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L527) | | `testDelay` | Wait for a test-scaled duration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L15) | | `waitFor` | Wait until a condition succeeds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L99) | From b673cd92ba5295f11b8c6dbd606183a489625b6d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 26 Aug 2026 02:15:01 +0200 Subject: [PATCH 03/11] fix(env): contain Deno tenant environment access --- docs/api-reference/veryfront/index.client.md | 2 +- docs/api-reference/veryfront/index.md | 2 +- docs/api-reference/veryfront/testing.md | 8 +-- .../compat/framework-source-resolver.test.ts | 3 + .../compat/framework-source-resolver.ts | 5 +- src/platform/compat/process/env.ts | 21 ++++++ .../compat/process/scoped-process-env.ts | 65 +++++++++++++++++++ .../project-env/process-env-scope.test.ts | 38 +++++++++++ .../module-fetcher/index.test.ts | 16 +++++ .../ssr-vf-modules/path-resolver.test.ts | 1 + tsconfig.json | 2 +- 11 files changed, 155 insertions(+), 8 deletions(-) diff --git a/docs/api-reference/veryfront/index.client.md b/docs/api-reference/veryfront/index.client.md index b4dee9e78e..5fb2eb7ce6 100644 --- a/docs/api-reference/veryfront/index.client.md +++ b/docs/api-reference/veryfront/index.client.md @@ -49,7 +49,7 @@ export function GET() { | `defineConfig` | Define a Veryfront project configuration object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L4) | | `defineConfigWithEnv` | Define a Veryfront project configuration from the current environment name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L8) | | `forbidden` | Create a 403 Forbidden response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L134) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L157) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L178) | | `json` | Create a JSON response with the correct content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L67) | | `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L17) | | `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L63) | diff --git a/docs/api-reference/veryfront/index.md b/docs/api-reference/veryfront/index.md index 19c8e05b81..1cfe83b3e1 100644 --- a/docs/api-reference/veryfront/index.md +++ b/docs/api-reference/veryfront/index.md @@ -67,7 +67,7 @@ export function getServerData(ctx: DataContext) { | `defineConfig` | Define a Veryfront project configuration object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L4) | | `defineConfigWithEnv` | Define a Veryfront project configuration from the current environment name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L8) | | `forbidden` | Create a 403 Forbidden response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L134) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L157) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L178) | | `json` | Create a JSON response with the correct content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L67) | | `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L17) | | `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L63) | diff --git a/docs/api-reference/veryfront/testing.md b/docs/api-reference/veryfront/testing.md index f670db0599..09d20f36ef 100644 --- a/docs/api-reference/veryfront/testing.md +++ b/docs/api-reference/veryfront/testing.md @@ -60,14 +60,14 @@ describe("math", () => { | `cwd` | Return the current working directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L26) | | `deepEquals` | ********************* Shared utility functions for cross-runtime testing. ********************* | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/utils.ts#L5) | | `delay` | Wait for a duration in milliseconds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L123) | -| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L266) | +| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L287) | | `describe` | Group related BDD tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L611) | -| `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L59) | +| `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L62) | | `exists` | Return false for a missing path and propagate every other filesystem error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L522) | | `exit` | Exit the current process. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L128) | | `fail` | Fail the current assertion immediately. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L336) | | `getArgs` | Get command-line arguments (cross-runtime: Deno.args or process.argv). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L10) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L157) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L178) | | `getTestTimeScale` | Return the current test time scale. Preserved for compatibility. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L4) | | `isAlreadyExistsError` | Error shape for is already exists. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L628) | | `isNotFoundError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/not-found-error.ts#L210) | @@ -84,7 +84,7 @@ describe("math", () => { | `resetAllTestState` | Comprehensive reset of ALL test state across the application. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/isolation.ts#L64) | | `safeStringify` | Serialize unknown values safely for test output. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/utils.ts#L34) | | `scaleMs` | Scale a duration for the current test runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L9) | -| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L237) | +| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L258) | | `stat` | Read file metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L527) | | `testDelay` | Wait for a test-scaled duration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L15) | | `waitFor` | Wait until a condition succeeds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L99) | diff --git a/src/platform/compat/framework-source-resolver.test.ts b/src/platform/compat/framework-source-resolver.test.ts index acd2e7b266..fe6424ce19 100644 --- a/src/platform/compat/framework-source-resolver.test.ts +++ b/src/platform/compat/framework-source-resolver.test.ts @@ -326,6 +326,9 @@ describe("framework-source-resolver — privileged source keys", () => { "platform/compat/process/host-runtime.ts", "platform/compat/process/lifecycle.ts", "platform/compat/process/command.ts", + "platform/cloud/resolver", + "platform/cloud/resolver.ts", + "platform/cloud/resolver.js?ssr=true", ]; for (const key of privilegedKeys) { diff --git a/src/platform/compat/framework-source-resolver.ts b/src/platform/compat/framework-source-resolver.ts index f7d30e0c3d..1418bbd4c4 100644 --- a/src/platform/compat/framework-source-resolver.ts +++ b/src/platform/compat/framework-source-resolver.ts @@ -46,7 +46,10 @@ export function isSafeFrameworkSourceKey(candidate: string): boolean { * import `getHostEnv` and read host-only secrets while its project * environment overlay is active. */ -const PRIVILEGED_FRAMEWORK_SOURCE_PREFIXES = ["platform/compat/process"] as const; +const PRIVILEGED_FRAMEWORK_SOURCE_PREFIXES = [ + "platform/compat/process", + "platform/cloud/resolver", +] as const; const FRAMEWORK_SOURCE_KEY_EXT_RE = /\.(?:src|tsx|ts|jsx|js|mjs|cjs|mdx|md|json)$/; diff --git a/src/platform/compat/process/env.ts b/src/platform/compat/process/env.ts index e030cad499..1c26a0eef6 100644 --- a/src/platform/compat/process/env.ts +++ b/src/platform/compat/process/env.ts @@ -1,6 +1,7 @@ import { getDenoRuntime, isDeno as IS_DENO } from "../runtime.ts"; import { hostProcessEnv, runtimeProcess } from "./runtime-process.ts"; import { + createProjectScopedDenoEnvView, deleteProjectScopedEnv, installProjectScopedProcessEnv, projectScopedEnvRecord, @@ -13,6 +14,8 @@ type EnvOverlayValue = string | null; type EnvOverlayStore = Map; const apply = Reflect.apply; +const ObjectDefineProperty = Object.defineProperty; +const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const denoRuntime = IS_DENO ? getDenoRuntime() : undefined; const denoEnv = denoRuntime?.env; const denoEnvGet = denoEnv?.get; @@ -126,6 +129,23 @@ export function getHostEnv(key: string): string | undefined { } let _trustedProjectEnvSnapshot: (() => ProjectEnvSnapshot | undefined) | null = null; +let denoEnvViewInstalled = false; + +function installProjectScopedDenoEnv( + getSnapshot: () => ProjectEnvSnapshot | undefined, +): void { + if (denoEnvViewInstalled || !denoRuntime || !denoEnv) return; + + const descriptor = ObjectGetOwnPropertyDescriptor(denoRuntime, "env"); + const view = createProjectScopedDenoEnvView(denoEnv, getSnapshot); + ObjectDefineProperty(denoRuntime, "env", { + value: view, + writable: false, + enumerable: descriptor?.enumerable ?? true, + configurable: false, + }); + denoEnvViewInstalled = true; +} /** * Register the server-owned project environment snapshot bridge. @@ -144,6 +164,7 @@ export function registerTrustedProjectEnvSnapshot( if (_trustedProjectEnvSnapshot && _trustedProjectEnvSnapshot !== getter) { throw new Error("Project environment snapshot bridge is already registered"); } + installProjectScopedDenoEnv(getter); _trustedProjectEnvSnapshot = getter; installProjectScopedProcessEnv(getTrustedProjectEnvSnapshot); } diff --git a/src/platform/compat/process/scoped-process-env.ts b/src/platform/compat/process/scoped-process-env.ts index 1ee7fb56ab..59d4f9098e 100644 --- a/src/platform/compat/process/scoped-process-env.ts +++ b/src/platform/compat/process/scoped-process-env.ts @@ -28,6 +28,7 @@ type ScopedWrites = Map; const ObjectKeys = Object.keys; const ObjectDefineProperty = Object.defineProperty; +const ReflectApply = Reflect.apply; const ReflectDefineProperty = Reflect.defineProperty; const ReflectDeleteProperty = Reflect.deleteProperty; const ReflectGet = Reflect.get; @@ -39,6 +40,15 @@ const ReflectSet = Reflect.set; // Keyed by the snapshot object, which the scope owns for exactly its lifetime. const writesBySnapshot = new WeakMap(); +/** Minimal contract implemented by the Deno environment capability. */ +export interface DenoEnvView { + get(key: string): string | undefined; + set(key: string, value: string): void; + delete(key: string): void; + has(key: string): boolean; + toObject(): Record; +} + function writesFor(snapshot: ProjectEnvSnapshot): ScopedWrites { const existing = writesBySnapshot.get(snapshot); if (existing) return existing; @@ -86,6 +96,61 @@ export function deleteProjectScopedEnv( writesFor(snapshot).set(key, null); } +/** + * Create an ambient-scope-aware view over the Deno environment capability. + * + * The host methods are captured before tenant code runs. Project reads and + * writes use the same snapshot log as process.env, while calls outside a + * project scope preserve native Deno behavior and permission checks. + */ +export function createProjectScopedDenoEnvView( + hostEnv: DenoEnvView, + getSnapshot: ProjectEnvSnapshotGetter, +): DenoEnvView { + const hostGet = hostEnv.get; + const hostSet = hostEnv.set; + const hostDelete = hostEnv.delete; + const hostHas = hostEnv.has; + const hostToObject = hostEnv.toObject; + + return { + get(key) { + const snapshot = getSnapshot(); + return snapshot === undefined + ? ReflectApply(hostGet, hostEnv, [key]) + : readScoped(snapshot, key); + }, + set(key, value) { + const snapshot = getSnapshot(); + if (snapshot === undefined) { + ReflectApply(hostSet, hostEnv, [key, value]); + return; + } + writeProjectScopedEnv(snapshot, key, value); + }, + delete(key) { + const snapshot = getSnapshot(); + if (snapshot === undefined) { + ReflectApply(hostDelete, hostEnv, [key]); + return; + } + deleteProjectScopedEnv(snapshot, key); + }, + has(key) { + const snapshot = getSnapshot(); + return snapshot === undefined + ? ReflectApply(hostHas, hostEnv, [key]) + : readScoped(snapshot, key) !== undefined; + }, + toObject() { + const snapshot = getSnapshot(); + return snapshot === undefined + ? ReflectApply(hostToObject, hostEnv, []) + : projectScopedEnvRecord(snapshot); + }, + }; +} + /** The scoped view as a plain record, for the bulk accessor. */ export function projectScopedEnvRecord( snapshot: ProjectEnvSnapshot, diff --git a/src/server/project-env/process-env-scope.test.ts b/src/server/project-env/process-env-scope.test.ts index a61506c5c2..53ea77adf0 100644 --- a/src/server/project-env/process-env-scope.test.ts +++ b/src/server/project-env/process-env-scope.test.ts @@ -55,6 +55,44 @@ describe("process.env under an active project env snapshot", () => { }); }); + it("scopes direct Deno.env access to the active project snapshot", () => { + const deno = Reflect.get(globalThis, "Deno") as + | { + env?: { + get(key: string): string | undefined; + set(key: string, value: string): void; + delete(key: string): void; + has(key: string): boolean; + toObject(): Record; + }; + } + | undefined; + if (!deno?.env) return; + + const hostKey = "VF_SCOPE_PROBE_DENO_HOST"; + const scopedKey = "VF_SCOPE_PROBE_DENO_WRITE"; + withHostVar(hostKey, "host-value", () => { + try { + runWithProjectEnv({ PROJECT_VAR: "project-value" }, () => { + assertEquals(deno.env!.get(hostKey), undefined); + assertEquals(deno.env!.get("PROJECT_VAR"), "project-value"); + assertEquals(deno.env!.has(hostKey), false); + assertEquals(deno.env!.toObject(), { PROJECT_VAR: "project-value" }); + + deno.env!.set(scopedKey, "scoped-value"); + deno.env!.delete("PROJECT_VAR"); + assertEquals(deno.env!.get(scopedKey), "scoped-value"); + assertEquals(deno.env!.get("PROJECT_VAR"), undefined); + }); + + assertEquals(deno.env!.get(hostKey), "host-value"); + assertEquals(deno.env!.get(scopedKey), undefined); + } finally { + deno.env!.delete(scopedKey); + } + }); + }); + it("serves project snapshot values through process.env", () => { runWithProjectEnv({ PROJECT_VAR: "project-value" }, () => { assertEquals(processEnv?.["PROJECT_VAR"], "project-value"); diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts index 2ea38670fc..c7e7484124 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts @@ -418,6 +418,22 @@ describe("module-fetcher", () => { ); assertEquals(result, null); }); + + it("refuses a tenant entry import of the host cloud bootstrap", async () => { + const ctx = createModuleFetcherContext( + "/cache", + untouchableAdapter, + "/project", + "proj-privileged", + { strictMissingModules: true }, + ); + + const result = await fetchAndCacheModule( + "/_vf_modules/_veryfront/platform/cloud/resolver.js", + ctx, + ); + assertEquals(result, null); + }); }); describe("strictMissingModules", () => { diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts index 428dccb181..e44cd8a64a 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts @@ -113,6 +113,7 @@ describe("resolveFrameworkFile", () => { "/_vf_modules/_veryfront/platform/compat/process/runtime-process.js", "/_vf_modules/_veryfront/platform/compat/process/scoped-process-env.js", "/_vf_modules/_veryfront/platform/compat/process.js", + "/_vf_modules/_veryfront/platform/cloud/resolver.js", "file:///_vf_modules/_veryfront/platform/compat/process/env.js?ssr=true", ] ) { diff --git a/tsconfig.json b/tsconfig.json index e976b23bdb..cd44612981 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -86,7 +86,7 @@ "veryfront/platform/esbuild-init": [ "./src/platform/compat/esbuild-init.ts" ], - "veryfront/platform/env": ["./src/platform/compat/process/env.ts"], + "veryfront/platform/env": ["./src/platform/env.ts"], "veryfront/platform/path": ["./src/platform/compat/path/index.ts"], "veryfront/platform/http": ["./src/platform/compat/http/index.ts"], "veryfront/errors/general": ["./src/errors/error-registry/general.ts"], From f41ffe10d6a3f6e8b52eaa32d0e0b7ea1698167b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 26 Aug 2026 10:24:35 +0200 Subject: [PATCH 04/11] fix(security): close shared host environment escapes --- docs/api-reference/veryfront/index.client.md | 2 +- docs/api-reference/veryfront/index.md | 2 +- docs/api-reference/veryfront/platform.md | 20 +++---- docs/api-reference/veryfront/testing.md | 6 +- docs/architecture/20-support-matrix.md | 2 +- .../compat/framework-source-resolver.test.ts | 50 +++-------------- .../compat/framework-source-resolver.ts | 56 ++++++++----------- src/platform/compat/process/env.test.ts | 33 +++++++++++ src/platform/compat/process/env.ts | 47 ++++++++++++++++ src/security/project-locality.test.ts | 7 ++- src/security/project-locality.ts | 4 +- .../module-fetcher/index.test.ts | 16 ++++++ .../esm-module-loader/module-fetcher/index.ts | 23 ++++---- .../ssr-vf-modules/path-resolver.test.ts | 31 +++++++++- .../stages/ssr-vf-modules/path-resolver.ts | 13 +---- 15 files changed, 195 insertions(+), 117 deletions(-) diff --git a/docs/api-reference/veryfront/index.client.md b/docs/api-reference/veryfront/index.client.md index f26137ea98..900774b44c 100644 --- a/docs/api-reference/veryfront/index.client.md +++ b/docs/api-reference/veryfront/index.client.md @@ -49,7 +49,7 @@ export function GET() { | `defineConfig` | Define a Veryfront project configuration object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L4) | | `defineConfigWithEnv` | Define a Veryfront project configuration from the current environment name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L8) | | `forbidden` | Create a 403 Forbidden response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L134) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L178) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L225) | | `json` | Create a JSON response with the correct content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L67) | | `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L17) | | `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L63) | diff --git a/docs/api-reference/veryfront/index.md b/docs/api-reference/veryfront/index.md index 058f2f72c3..00bca8c9fb 100644 --- a/docs/api-reference/veryfront/index.md +++ b/docs/api-reference/veryfront/index.md @@ -67,7 +67,7 @@ export function getServerData(ctx: DataContext) { | `defineConfig` | Define a Veryfront project configuration object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L4) | | `defineConfigWithEnv` | Define a Veryfront project configuration from the current environment name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L8) | | `forbidden` | Create a 403 Forbidden response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L134) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L178) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L225) | | `json` | Create a JSON response with the correct content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L67) | | `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L17) | | `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L63) | diff --git a/docs/api-reference/veryfront/platform.md b/docs/api-reference/veryfront/platform.md index 637679a0dd..a4b32f89aa 100644 --- a/docs/api-reference/veryfront/platform.md +++ b/docs/api-reference/veryfront/platform.md @@ -31,7 +31,7 @@ import { | `createKVStore` | Create a cross-runtime KV store. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/kv/factory.ts#L82) | | `createMockAdapter` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/adapters/mock.ts#L133) | | `cwd` | Return the current working directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L26) | -| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L287) | +| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L334) | | `enhanceAdapterWithFS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/adapters/fs/integration.ts#L64) | | `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L62) | | `execPath` | Get the executable path of the current runtime | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L332) | @@ -40,7 +40,7 @@ import { | `getAdapter` | Get the runtime adapter for the current environment | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/adapters/detect.ts#L24) | | `getArgs` | Get command-line arguments (cross-runtime: Deno.args or process.argv). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L10) | | `getDenoRuntime` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/runtime.ts#L33) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L178) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L225) | | `getLocalAdapter` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/adapters/registry.ts#L230) | | `getOsType` | Get the operating system type Returns: "darwin" (macOS), "linux", "windows", or the raw platform string | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L164) | | `getRuntimeVersion` | Get runtime version string | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L150) | @@ -65,7 +65,7 @@ import { | `remove` | Remove a file or directory, rejecting when the path does not exist. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L577) | | `resolveHostAddresses` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/dns.ts#L292) | | `runCommand` | Run a command and return the result. Works across Deno, Node.js, and Bun. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/command.ts#L449) | -| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L258) | +| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L305) | | `setRawMode` | Set raw mode on stdin (enables character-by-character input) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/stdin.ts#L55) | | `writeStdout` | Write text directly to stdout (sync) No-op if stdout is not available | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L377) | | `writeStdoutAsync` | Write data to stdout asynchronously Returns a promise that resolves when the write is complete | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L385) | @@ -118,19 +118,19 @@ import { deleteEnv, env, getEnv } from "veryfront/platform/env"; | Name | Description | Source | | --------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L287) | +| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L334) | | `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L62) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L178) | -| `getEnvBoolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L232) | -| `getEnvNumber` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L218) | -| `getEnvString` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L210) | -| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L258) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L225) | +| `getEnvBoolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L279) | +| `getEnvNumber` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L265) | +| `getEnvString` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L257) | +| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L305) | #### Types | Name | Description | Source | | ------------------- | ----------- | ------------------------------------------------------------------------------------------------------- | -| `EnvBooleanOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L195) | +| `EnvBooleanOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L242) | ### `veryfront/platform/path` diff --git a/docs/api-reference/veryfront/testing.md b/docs/api-reference/veryfront/testing.md index cbdcb9f60c..1212aa7098 100644 --- a/docs/api-reference/veryfront/testing.md +++ b/docs/api-reference/veryfront/testing.md @@ -60,14 +60,14 @@ describe("math", () => { | `cwd` | Return the current working directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L26) | | `deepEquals` | ********************* Shared utility functions for cross-runtime testing. ********************* | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/utils.ts#L5) | | `delay` | Wait for a duration in milliseconds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L123) | -| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L287) | +| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L334) | | `describe` | Group related BDD tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L611) | | `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L62) | | `exists` | Return false for a missing path and propagate every other filesystem error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L522) | | `exit` | Exit the current process. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L128) | | `fail` | Fail the current assertion immediately. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L336) | | `getArgs` | Get command-line arguments (cross-runtime: Deno.args or process.argv). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L10) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L178) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L225) | | `getTestTimeScale` | Return the current test time scale. Preserved for compatibility. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L4) | | `isAlreadyExistsError` | Error shape for is already exists. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L628) | | `isNotFoundError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/not-found-error.ts#L210) | @@ -84,7 +84,7 @@ describe("math", () => { | `resetAllTestState` | Comprehensive reset of ALL test state across the application. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/isolation.ts#L64) | | `safeStringify` | Serialize unknown values safely for test output. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/utils.ts#L34) | | `scaleMs` | Scale a duration for the current test runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L9) | -| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L258) | +| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L305) | | `stat` | Read file metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L527) | | `testDelay` | Wait for a test-scaled duration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L15) | | `waitFor` | Wait until a condition succeeds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L99) | diff --git a/docs/architecture/20-support-matrix.md b/docs/architecture/20-support-matrix.md index 2d2d4d52a4..4bf98739a2 100644 --- a/docs/architecture/20-support-matrix.md +++ b/docs/architecture/20-support-matrix.md @@ -23,7 +23,7 @@ These are the runtime capability profiles modeled by the framework. | ------------------ | ---------- | ---------- | ----------------------------- | ------------------------------------------------------------------------------------- | | Deno | Yes | Yes | Yes | Primary local/runtime target in this repo. | | Node.js | Yes | Yes | Yes | Full runtime profile. | -| Bun | Yes | Yes | Yes | Full runtime profile. | +| Bun | Yes | Yes | Yes | Full runtime profile. Shared tenant execution uses isolated runtimes. | | Cloudflare Workers | No | No | Limited | Streaming is recommended; the runtime uses conservative step, CPU, and memory limits. | | Unknown runtime | No | No | Limited | Falls back to a constrained compatibility profile. | diff --git a/src/platform/compat/framework-source-resolver.test.ts b/src/platform/compat/framework-source-resolver.test.ts index fe6424ce19..58b84f61a6 100644 --- a/src/platform/compat/framework-source-resolver.test.ts +++ b/src/platform/compat/framework-source-resolver.test.ts @@ -5,7 +5,7 @@ import { FRAMEWORK_EMBEDDED_SRC_DIR, FRAMEWORK_SRC_DIR, getFrameworkSourceLookupDirs, - isPrivilegedFrameworkSourceKey, + isPublicFrameworkSourceKey, resolveFrameworkSourcePath, resolveRelativeFrameworkSourceImport, } from "./framework-source-resolver.ts"; @@ -311,46 +311,10 @@ describe("framework-source-resolver (VULN-FS-3) — path containment", () => { }); }); -describe("framework-source-resolver — privileged source keys", () => { - const privilegedKeys = [ - "platform/compat/process", - "platform/compat/process.ts", - "platform/compat/process.js", - "platform/compat/process/env", - "platform/compat/process/env.ts", - "platform/compat/process/env.js", - "platform/compat/process/env.ts.src", - "platform/compat/process/env.js?ssr=true", - "platform/compat/process/runtime-process.ts", - "platform/compat/process/scoped-process-env.ts", - "platform/compat/process/host-runtime.ts", - "platform/compat/process/lifecycle.ts", - "platform/compat/process/command.ts", - "platform/cloud/resolver", - "platform/cloud/resolver.ts", - "platform/cloud/resolver.js?ssr=true", - ]; - - for (const key of privilegedKeys) { - it(`marks ${key} as privileged`, () => { - assertEquals(isPrivilegedFrameworkSourceKey(key), true); - }); - } - - const publicKeys = [ - "platform/env", - "platform/env.ts", - "platform/index", - "platform/compat/fs", - "platform/compat/path/index", - "platform/compat/processor", // sibling name must not match by prefix - "testing/index", - "react/runtime/core", - ]; - - for (const key of publicKeys) { - it(`keeps ${key} resolvable`, () => { - assertEquals(isPrivilegedFrameworkSourceKey(key), false); - }); - } +describe("framework-source-resolver public entry keys", () => { + it("accepts public export targets and rejects internal wrappers", () => { + assertEquals(isPublicFrameworkSourceKey("platform/compat/process/env-public.js"), true); + assertEquals(isPublicFrameworkSourceKey("observability/tracing/telemetry-env.ts"), false); + assertEquals(isPublicFrameworkSourceKey("platform/cloud/resolver.ts"), false); + }); }); diff --git a/src/platform/compat/framework-source-resolver.ts b/src/platform/compat/framework-source-resolver.ts index 1418bbd4c4..a6b9769750 100644 --- a/src/platform/compat/framework-source-resolver.ts +++ b/src/platform/compat/framework-source-resolver.ts @@ -5,6 +5,7 @@ import { isCompiledBinary } from "#veryfront/utils/platform.ts"; import { createFileSystem, isNotFoundError } from "./fs.ts"; import { PUBLISHED_RUNTIME_HELPERS } from "./published-runtime-helpers.ts"; import { getFrameworkRoot, getFrameworkRootFromMeta } from "./vfs-paths.ts"; +import denoConfig from "#deno-config" with { type: "json" }; /** * Reject candidate paths that contain traversal indicators — plain `..`, @@ -33,43 +34,34 @@ export function isSafeFrameworkSourceKey(candidate: string): boolean { return !hasDangerousSegments(candidate); } -/** - * Framework subtrees that tenant module loading must never resolve directly. - * - * `platform/compat/process` holds the host process seam: `getHostEnv()`, the - * captured host environment record, the scoped-write bookkeeping, and process - * mutators. Tenant code reaches framework source through supported package - * exports (for environment access, `veryfront/platform/env`), and the - * implementation modules stay reachable for the framework's own transform - * graph, which resolves transitive imports through separate trusted paths. - * Serving these modules as tenant-requested entry points would let a project - * import `getHostEnv` and read host-only secrets while its project - * environment overlay is active. - */ -const PRIVILEGED_FRAMEWORK_SOURCE_PREFIXES = [ - "platform/compat/process", - "platform/cloud/resolver", -] as const; - const FRAMEWORK_SOURCE_KEY_EXT_RE = /\.(?:src|tsx|ts|jsx|js|mjs|cjs|mdx|md|json)$/; -/** - * Return whether a tenant-supplied framework source key names a privileged - * implementation module that must not be served to tenant module loading. - * - * The key is compared after stripping any query suffix and trailing module - * extensions (including `.src` embedded-source suffixes), so - * `platform/compat/process/env`, `platform/compat/process/env.ts`, and - * `platform/compat/process/env.js?ssr=true` all match. - */ -export function isPrivilegedFrameworkSourceKey(candidate: string): boolean { - let normalized = candidate.replace(/\?.*$/, "").replace(/\/+$/, ""); +function normalizeFrameworkSourceKey(candidate: string): string { + let normalized = candidate.replace(/\?.*$/, "").replace(/^\.\/src\//, "").replace(/\/+$/, ""); while (FRAMEWORK_SOURCE_KEY_EXT_RE.test(normalized)) { normalized = normalized.replace(FRAMEWORK_SOURCE_KEY_EXT_RE, ""); } - return PRIVILEGED_FRAMEWORK_SOURCE_PREFIXES.some((prefix) => - normalized === prefix || normalized.startsWith(`${prefix}/`) - ); + return normalized; +} + +const publicConfig = denoConfig as { + exports?: Record; + imports?: Record; +}; +const PUBLIC_FRAMEWORK_SOURCE_KEYS = new Set( + [ + ...Object.values(publicConfig.exports ?? {}), + ...Object.entries(publicConfig.imports ?? {}) + .filter(([specifier]) => specifier === "veryfront" || specifier.startsWith("veryfront/")) + .map(([, target]) => target), + ] + .filter((target) => target.startsWith("./src/")) + .map(normalizeFrameworkSourceKey), +); + +/** Return whether a framework source key is the target of a public export. */ +export function isPublicFrameworkSourceKey(candidate: string): boolean { + return PUBLIC_FRAMEWORK_SOURCE_KEYS.has(normalizeFrameworkSourceKey(candidate)); } export const FRAMEWORK_ROOT = getFrameworkRootFromMeta(import.meta.url); diff --git a/src/platform/compat/process/env.test.ts b/src/platform/compat/process/env.test.ts index 3b1e41b0a3..82a368bdef 100644 --- a/src/platform/compat/process/env.test.ts +++ b/src/platform/compat/process/env.test.ts @@ -2,10 +2,43 @@ import { assert, assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { isDeno } from "#veryfront/platform/compat/runtime.ts"; import { fromFileUrl } from "#std/path"; +import { registerTrustedProjectEnvSnapshot } from "./env.ts"; const denoOnlyIt = isDeno ? it : it.skip; describe("host environment access", () => { + denoOnlyIt("passes only the active project environment to direct subprocesses", async () => { + const hostKey = "VF_SCOPE_SUBPROCESS_HOST_ONLY"; + const projectKey = "VF_SCOPE_SUBPROCESS_PROJECT_ONLY"; + const previousHost = Deno.env.get(hostKey); + Deno.env.set(hostKey, "host-secret"); + let snapshot: Readonly> | undefined; + registerTrustedProjectEnvSnapshot(() => snapshot); + snapshot = { [projectKey]: "project-value" }; + + try { + const output = await new Deno.Command(Deno.execPath(), { + args: [ + "eval", + `console.log(JSON.stringify({ host: Deno.env.get(${ + JSON.stringify(hostKey) + }) ?? null, project: Deno.env.get(${JSON.stringify(projectKey)}) ?? null }))`, + ], + stdout: "piped", + stderr: "piped", + }).output(); + assert(output.success, new TextDecoder().decode(output.stderr)); + assertEquals( + JSON.parse(new TextDecoder().decode(output.stdout).trim()), + { host: null, project: "project-value" }, + ); + } finally { + snapshot = undefined; + if (previousHost === undefined) Deno.env.delete(hostKey); + else Deno.env.set(hostKey, previousHost); + } + }); + denoOnlyIt("ignores forged test overlays when env permission is granted", async () => { const moduleUrl = new URL("./env.ts", import.meta.url).href; const source = ` diff --git a/src/platform/compat/process/env.ts b/src/platform/compat/process/env.ts index 1c26a0eef6..cd5b5340d9 100644 --- a/src/platform/compat/process/env.ts +++ b/src/platform/compat/process/env.ts @@ -130,6 +130,52 @@ export function getHostEnv(key: string): string | undefined { let _trustedProjectEnvSnapshot: (() => ProjectEnvSnapshot | undefined) | null = null; let denoEnvViewInstalled = false; +let denoCommandViewInstalled = false; + +function installProjectScopedDenoCommand( + getSnapshot: () => ProjectEnvSnapshot | undefined, +): void { + if (denoCommandViewInstalled || !denoRuntime) return; + + const HostCommand = denoRuntime.Command; + const hostOutput = HostCommand.prototype.output; + const hostOutputSync = HostCommand.prototype.outputSync; + const hostSpawn = HostCommand.prototype.spawn; + + class ProjectScopedCommand { + readonly #command: Deno.Command; + + constructor(command: string | URL, options: Deno.CommandOptions = {}) { + const snapshot = getSnapshot(); + const scopedOptions = snapshot === undefined ? options : { + ...options, + clearEnv: true, + env: { ...projectScopedEnvRecord(snapshot), ...options.env }, + }; + this.#command = new HostCommand(command, scopedOptions); + } + + output(): Promise { + return apply(hostOutput, this.#command, []); + } + + outputSync(): Deno.CommandOutput { + return apply(hostOutputSync, this.#command, []); + } + + spawn(): Deno.ChildProcess { + return apply(hostSpawn, this.#command, []); + } + } + + ObjectDefineProperty(denoRuntime, "Command", { + value: ProjectScopedCommand, + writable: false, + enumerable: true, + configurable: false, + }); + denoCommandViewInstalled = true; +} function installProjectScopedDenoEnv( getSnapshot: () => ProjectEnvSnapshot | undefined, @@ -165,6 +211,7 @@ export function registerTrustedProjectEnvSnapshot( throw new Error("Project environment snapshot bridge is already registered"); } installProjectScopedDenoEnv(getter); + installProjectScopedDenoCommand(getter); _trustedProjectEnvSnapshot = getter; installProjectScopedProcessEnv(getTrustedProjectEnvSnapshot); } diff --git a/src/security/project-locality.test.ts b/src/security/project-locality.test.ts index a71626400b..700587ab03 100644 --- a/src/security/project-locality.test.ts +++ b/src/security/project-locality.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { isBun } from "#veryfront/platform/compat/runtime.ts"; import { isSharedProjectRuntime, requiresIsolatedProjectRuntime } from "./project-locality.ts"; describe("security/project-locality shared runtime topology", () => { @@ -98,8 +99,10 @@ describe("security/project-locality isolated runtime requirement", () => { it("allows execution once the host-owned entrypoint grants the capability", () => { assertEquals( requiresIsolatedProjectRuntime({ ...sharedRuntime, allowHostProjectCodeExecution: true }), - false, - "an operator-granted shared executor may run project code", + isBun, + isBun + ? "Bun.env cannot be scoped, so a shared Bun host must remain isolated" + : "an operator-granted shared executor may run project code", ); }); diff --git a/src/security/project-locality.ts b/src/security/project-locality.ts index 4c7a2a3293..3e96bcf465 100644 --- a/src/security/project-locality.ts +++ b/src/security/project-locality.ts @@ -1,3 +1,5 @@ +import { isBun as IS_BUN } from "#veryfront/platform/compat/runtime.ts"; + const apply = Reflect.apply; const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const getPrototypeOf = Object.getPrototypeOf; @@ -115,7 +117,7 @@ export function isHostProjectCodeExecutionAllowed(value: unknown): boolean { export function isExplicitHostProjectCodeExecutionAllowed( value: unknown, ): boolean { - return readOwnDataProperty(value, "allowHostProjectCodeExecution") === true; + return !IS_BUN && readOwnDataProperty(value, "allowHostProjectCodeExecution") === true; } /** diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts index c7e7484124..a135452546 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts @@ -434,6 +434,22 @@ describe("module-fetcher", () => { ); assertEquals(result, null); }); + + it("refuses an unexported host-environment wrapper as a tenant entry", async () => { + const ctx = createModuleFetcherContext( + "/cache", + untouchableAdapter, + "/project", + "proj-privileged", + { strictMissingModules: true }, + ); + + const result = await fetchAndCacheModule( + "/_vf_modules/_veryfront/observability/tracing/telemetry-env.js", + ctx, + ); + assertEquals(result, null); + }); }); describe("strictMissingModules", () => { diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts index a82c7fabe7..5bef332587 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts @@ -22,7 +22,9 @@ import { getModulePathCache } from "../cache/index.ts"; import { hashString } from "../utils/hash.ts"; import { resolveModuleFile } from "../resolution/file-finder.ts"; import { canonicalizeContainedModulePath } from "../resolution/module-path.ts"; -import { isPrivilegedFrameworkSourceKey } from "#veryfront/platform/compat/framework-source-resolver.ts"; +import { + isPublicFrameworkSourceKey, +} from "#veryfront/platform/compat/framework-source-resolver.ts"; import { getTransformCacheKey, getVersionedPathCacheKey } from "./cache-keys.ts"; import { resolveNestedImportBase, resolveNestedModuleImports } from "./nested-imports.ts"; import { readDistributedCache } from "./distributed-cache.ts"; @@ -166,7 +168,7 @@ function frameworkSourceKeyOf(modulePath: string): string | null { } /** - * Return whether a privileged framework module fetch must be refused. + * Return whether a tenant requested a framework module outside public exports. * * Privileged implementation modules (the host process env seam) may only be * fetched as transitive dependencies of framework source — a fetch whose @@ -175,22 +177,23 @@ function frameworkSourceKeyOf(modulePath: string): string | null { * lookup, so a copy cached for the framework graph is never handed to a * tenant-requested import. */ -function isRefusedPrivilegedModuleFetch( +function isRefusedTenantFrameworkModuleFetch( normalizedPath: string, parentModulePath: string | undefined, expectedCacheKey: string | undefined, ): boolean { const frameworkKey = frameworkSourceKeyOf(normalizedPath); - if (frameworkKey === null || !isPrivilegedFrameworkSourceKey(frameworkKey)) { - return false; - } + if (frameworkKey === null) return false; - if (parentModulePath === undefined) return true; + if (parentModulePath === undefined) { + return !isPublicFrameworkSourceKey(frameworkKey); + } const normalizedParent = canonicalizeContainedModulePath( unwrapDependencyPinningPath(parentModulePath, expectedCacheKey), ); if (normalizedParent === null) return true; - return frameworkSourceKeyOf(normalizedParent) === null; + if (frameworkSourceKeyOf(normalizedParent) !== null) return false; + return !isPublicFrameworkSourceKey(frameworkKey); } /** @@ -211,8 +214,8 @@ export async function fetchAndCacheModule( ); const projectSlug = context.projectSlug || "unknown"; - if (isRefusedPrivilegedModuleFetch(normalizedPath, parentModulePath, expectedCacheKey)) { - log.warn(`${LOG_PREFIX_MDX_LOADER} Refusing privileged framework module for tenant import`, { + if (isRefusedTenantFrameworkModuleFetch(normalizedPath, parentModulePath, expectedCacheKey)) { + log.warn(`${LOG_PREFIX_MDX_LOADER} Refusing non-public framework module for tenant import`, { projectSlug, normalizedPath, parentModulePath, diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts index e44cd8a64a..c2d9ee12c3 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts @@ -131,14 +131,39 @@ describe("resolveFrameworkFile", () => { }); } + it("refuses an unexported host-environment wrapper as a tenant entry", async () => { + const fs = createMockFs( + new Proxy({}, { + has: () => true, + get: () => "export function getHostTelemetryEnv() {}", + }) as Record, + ); + + assertEquals( + await resolveFrameworkFile( + "/_vf_modules/_veryfront/observability/tracing/telemetry-env.js", + fs, + () => Promise.resolve(true), + ), + null, + ); + }); + it("still resolves the public platform/env facade", async () => { - const sourcePath = join(FRAMEWORK_ROOT, "src", "platform", "env.ts"); + const sourcePath = join( + FRAMEWORK_ROOT, + "src", + "platform", + "compat", + "process", + "env-public.ts", + ); const files: Record = { - [sourcePath]: 'export { getEnv } from "./compat/process/env.ts";', + [sourcePath]: 'export { getEnv } from "./env.ts";', }; const fs = createMockFs(files); const result = await resolveFrameworkFile( - "/_vf_modules/_veryfront/platform/env.js?ssr=true", + "/_vf_modules/_veryfront/platform/compat/process/env-public.js?ssr=true", fs, createExistsFn(files), ); diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts index 27c38297aa..bea75825fa 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts @@ -9,7 +9,7 @@ import { createFileSystem, exists } from "#veryfront/platform/compat/fs.ts"; import { join } from "#veryfront/compat/path/index.ts"; import { rendererLogger as logger } from "#veryfront/utils"; import { - isPrivilegedFrameworkSourceKey, + isPublicFrameworkSourceKey, isSafeFrameworkSourceKey, resolveRelativeFrameworkSourceImport, } from "#veryfront/platform/compat/framework-source-resolver.ts"; @@ -67,20 +67,13 @@ export async function resolveFrameworkFile( ? pathWithoutPrefix.slice("_veryfront/".length) : pathWithoutPrefix; if (!isSafeFrameworkSourceKey(frameworkRelativePath)) return null; - // /_vf_modules/ specifiers originate from tenant module graphs (either - // written literally or rewritten from tenant `#veryfront/*` imports), so a - // privileged implementation module must not be served as an entry point. - // The framework's own transitive imports resolve through - // resolveAndTransformVeryfrontImport / resolveRelativeFrameworkImport and - // are unaffected. - if (isPrivilegedFrameworkSourceKey(frameworkRelativePath)) { - logger.warn(`${LOG_PREFIX} Refusing privileged framework module for tenant import`, { + if (!isPublicFrameworkSourceKey(frameworkRelativePath)) { + logger.warn(`${LOG_PREFIX} Refusing non-public framework module for tenant import`, { vfModulePath, frameworkRelativePath, }); return null; } - logger.debug(`${LOG_PREFIX} resolveFrameworkFile`, { input: vfModulePath, normalizedVfModulePath, From 8256b79f216dd65f120b083081ebc56ccf9d9f06 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 26 Aug 2026 10:59:10 +0200 Subject: [PATCH 05/11] fix(security): close remaining shared env paths --- deno.json | 4 ++-- docs/api-reference/veryfront/platform.md | 7 ++----- scripts/build/npm-package-metadata.test.ts | 2 +- .../compat/framework-source-resolver.test.ts | 5 ++++- .../compat/framework-source-resolver.ts | 7 ++++++- src/platform/compat/process/env.test.ts | 21 ++++++++++++++++++- .../compat/process/scoped-process-env.ts | 4 +++- src/platform/env.test.ts | 10 +++++++++ src/security/project-locality.test.ts | 9 +++----- src/security/project-locality.ts | 17 ++++++++------- .../ssr-vf-modules/path-resolver.test.ts | 6 ++---- tsconfig.json | 2 +- 12 files changed, 63 insertions(+), 31 deletions(-) diff --git a/deno.json b/deno.json index cc2af10f25..2ec1c9e05d 100644 --- a/deno.json +++ b/deno.json @@ -173,7 +173,7 @@ "./extensions/observability": "./src/extensions/observability/index.ts", "./extensions/websocket": "./src/extensions/websocket/index.ts", "./platform": "./src/platform/index.ts", - "./platform/env": "./src/platform/compat/process/env-public.ts", + "./platform/env": "./src/platform/env.ts", "./platform/path": "./src/platform/compat/path/index.ts", "./testing": "./src/testing/index.ts", "./testing/assert": "./src/testing/assert.ts", @@ -244,7 +244,7 @@ "veryfront/transforms/mdx-cache": "./src/transforms/mdx/esm-module-loader/cache/index.ts", "veryfront/observability/otlp-setup": "./src/observability/tracing/otlp-setup.ts", "veryfront/platform/esbuild-init": "./src/platform/compat/esbuild-init.ts", - "veryfront/platform/env": "./src/platform/compat/process/env-public.ts", + "veryfront/platform/env": "./src/platform/env.ts", "veryfront/platform/path": "./src/platform/compat/path/index.ts", "veryfront/platform/http": "./src/platform/compat/http/index.ts", "veryfront/cli": "./cli/main.ts", diff --git a/docs/api-reference/veryfront/platform.md b/docs/api-reference/veryfront/platform.md index a4b32f89aa..87065a2e38 100644 --- a/docs/api-reference/veryfront/platform.md +++ b/docs/api-reference/veryfront/platform.md @@ -108,23 +108,20 @@ These import paths group focused functionality under this module. Each is a sepa ### `veryfront/platform/env` -Project-scoped environment helpers for cross-runtime applications. Host environment access and the trusted project-snapshot bridge remain internal framework controls and are not exported from this module. +Public environment facade for the `veryfront/platform/env` subpath. Exposes only project-scoped readers. Privileged or mutating accessors (`getHostEnv`, `env`, `setEnv`, `deleteEnv`) stay internal so a tenant project cannot read or alter the host process environment through a supported package export. ```ts -import { deleteEnv, env, getEnv } from "veryfront/platform/env"; +import { getEnv, getEnvBoolean, getEnvNumber } from "veryfront/platform/env"; ``` #### Functions | Name | Description | Source | | --------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L334) | -| `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L62) | | `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L225) | | `getEnvBoolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L279) | | `getEnvNumber` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L265) | | `getEnvString` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L257) | -| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L305) | #### Types diff --git a/scripts/build/npm-package-metadata.test.ts b/scripts/build/npm-package-metadata.test.ts index 9878f6eeeb..2d54a1d992 100644 --- a/scripts/build/npm-package-metadata.test.ts +++ b/scripts/build/npm-package-metadata.test.ts @@ -83,7 +83,7 @@ it("keeps host environment controls outside public platform exports", async () = const exports = denoConfig.exports as Record; const imports = denoConfig.imports as Record; const paths = tsconfig.compilerOptions.paths as Record; - const publicEnvModule = "./src/platform/compat/process/env-public.ts"; + const publicEnvModule = "./src/platform/env.ts"; assertEquals(exports["./platform/env"], publicEnvModule); assertEquals(imports["veryfront/platform/env"], publicEnvModule); diff --git a/src/platform/compat/framework-source-resolver.test.ts b/src/platform/compat/framework-source-resolver.test.ts index 58b84f61a6..9e14a6cce0 100644 --- a/src/platform/compat/framework-source-resolver.test.ts +++ b/src/platform/compat/framework-source-resolver.test.ts @@ -313,7 +313,10 @@ describe("framework-source-resolver (VULN-FS-3) — path containment", () => { describe("framework-source-resolver public entry keys", () => { it("accepts public export targets and rejects internal wrappers", () => { - assertEquals(isPublicFrameworkSourceKey("platform/compat/process/env-public.js"), true); + assertEquals(isPublicFrameworkSourceKey("platform/env.js"), true); + assertEquals(isPublicFrameworkSourceKey("react/runtime/core.ts"), true); + assertEquals(isPublicFrameworkSourceKey("config/index.ts"), false); + assertEquals(isPublicFrameworkSourceKey("platform/compat/process/env-public.js"), false); assertEquals(isPublicFrameworkSourceKey("observability/tracing/telemetry-env.ts"), false); assertEquals(isPublicFrameworkSourceKey("platform/cloud/resolver.ts"), false); }); diff --git a/src/platform/compat/framework-source-resolver.ts b/src/platform/compat/framework-source-resolver.ts index a6b9769750..898de15e5d 100644 --- a/src/platform/compat/framework-source-resolver.ts +++ b/src/platform/compat/framework-source-resolver.ts @@ -48,11 +48,16 @@ const publicConfig = denoConfig as { exports?: Record; imports?: Record; }; +const publicExportSpecifiers = new Set( + Object.keys(publicConfig.exports ?? {}).map((specifier) => + specifier === "." ? "veryfront" : `veryfront/${specifier.replace(/^\.\//, "")}` + ), +); const PUBLIC_FRAMEWORK_SOURCE_KEYS = new Set( [ ...Object.values(publicConfig.exports ?? {}), ...Object.entries(publicConfig.imports ?? {}) - .filter(([specifier]) => specifier === "veryfront" || specifier.startsWith("veryfront/")) + .filter(([specifier]) => publicExportSpecifiers.has(specifier)) .map(([, target]) => target), ] .filter((target) => target.startsWith("./src/")) diff --git a/src/platform/compat/process/env.test.ts b/src/platform/compat/process/env.test.ts index 82a368bdef..69518c291d 100644 --- a/src/platform/compat/process/env.test.ts +++ b/src/platform/compat/process/env.test.ts @@ -1,12 +1,31 @@ -import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { isDeno } from "#veryfront/platform/compat/runtime.ts"; import { fromFileUrl } from "#std/path"; import { registerTrustedProjectEnvSnapshot } from "./env.ts"; +import { createProjectScopedDenoEnvView } from "./scoped-process-env.ts"; const denoOnlyIt = isDeno ? it : it.skip; describe("host environment access", () => { + it("creates an immutable scoped Deno environment facade", () => { + const view = createProjectScopedDenoEnvView({ + get: () => undefined, + set: () => {}, + delete: () => {}, + has: () => false, + toObject: () => ({}), + }, () => undefined); + + assertEquals(Object.isFrozen(view), true); + for (const method of ["get", "set", "delete", "has", "toObject"] as const) { + const descriptor = Object.getOwnPropertyDescriptor(view, method); + assertEquals(descriptor?.writable, false); + assertEquals(descriptor?.configurable, false); + } + assertThrows(() => Object.defineProperty(view, "get", { value: () => "intercepted" })); + }); + denoOnlyIt("passes only the active project environment to direct subprocesses", async () => { const hostKey = "VF_SCOPE_SUBPROCESS_HOST_ONLY"; const projectKey = "VF_SCOPE_SUBPROCESS_PROJECT_ONLY"; diff --git a/src/platform/compat/process/scoped-process-env.ts b/src/platform/compat/process/scoped-process-env.ts index 9acf326199..0d59bc8541 100644 --- a/src/platform/compat/process/scoped-process-env.ts +++ b/src/platform/compat/process/scoped-process-env.ts @@ -49,6 +49,7 @@ type ScopedWrites = Map; const ObjectKeys = Object.keys; const ObjectCreate = Object.create; const ObjectDefineProperty = Object.defineProperty; +const ObjectFreeze = Object.freeze; const ReflectApply = Reflect.apply; const ObjectGetPrototypeOf = Object.getPrototypeOf; const ReflectDefineProperty = Reflect.defineProperty; @@ -151,7 +152,7 @@ export function createProjectScopedDenoEnvView( const hostHas = hostEnv.has; const hostToObject = hostEnv.toObject; - return { + const view: DenoEnvView = { get(key) { const snapshot = getSnapshot(); return snapshot === undefined @@ -187,6 +188,7 @@ export function createProjectScopedDenoEnvView( : projectScopedEnvRecord(snapshot); }, }; + return ObjectFreeze(view); } /** The scoped view as a plain record, for the bulk accessor. */ diff --git a/src/platform/env.test.ts b/src/platform/env.test.ts index a1192ee1eb..87bfe131ee 100644 --- a/src/platform/env.test.ts +++ b/src/platform/env.test.ts @@ -1,6 +1,7 @@ import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import * as publicEnv from "./env.ts"; +import denoConfig from "#deno-config" with { type: "json" }; describe("platform/env", () => { it("only exports the project-scoped environment readers", () => { @@ -11,4 +12,13 @@ describe("platform/env", () => { "getEnvString", ]); }); + + it("backs both package maps with the restricted facade", () => { + const config = denoConfig as { + exports: Record; + imports: Record; + }; + assertEquals(config.exports["./platform/env"], "./src/platform/env.ts"); + assertEquals(config.imports["veryfront/platform/env"], "./src/platform/env.ts"); + }); }); diff --git a/src/security/project-locality.test.ts b/src/security/project-locality.test.ts index 700587ab03..83c5943c15 100644 --- a/src/security/project-locality.test.ts +++ b/src/security/project-locality.test.ts @@ -1,7 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { isBun } from "#veryfront/platform/compat/runtime.ts"; import { isSharedProjectRuntime, requiresIsolatedProjectRuntime } from "./project-locality.ts"; describe("security/project-locality shared runtime topology", () => { @@ -96,13 +95,11 @@ describe("security/project-locality isolated runtime requirement", () => { ); }); - it("allows execution once the host-owned entrypoint grants the capability", () => { + it("denies shared host execution even when the entrypoint grants the capability", () => { assertEquals( requiresIsolatedProjectRuntime({ ...sharedRuntime, allowHostProjectCodeExecution: true }), - isBun, - isBun - ? "Bun.env cannot be scoped, so a shared Bun host must remain isolated" - : "an operator-granted shared executor may run project code", + true, + "ambient runtime channels keep shared host execution unsafe", ); }); diff --git a/src/security/project-locality.ts b/src/security/project-locality.ts index 3e96bcf465..0650fb6027 100644 --- a/src/security/project-locality.ts +++ b/src/security/project-locality.ts @@ -97,16 +97,18 @@ export function isExplicitlyLocalProject(value: unknown): boolean { } /** - * Host-realm project-code execution requires an explicit runtime capability. + * Host-realm project-code execution requires a dedicated runtime capability. * * An explicitly local development project retains the historical capability. - * Standalone production runtimes can grant the narrower capability without + * A standalone production runtime can grant the narrower capability without * enabling development-only rendering, caching, diagnostics, or HTTP policy. - * Every ambiguous value fails closed. + * A shared runtime cannot grant it because ambient runtime channels can expose + * host state outside the scoped environment facade. Every ambiguous value + * fails closed. */ export function isHostProjectCodeExecutionAllowed(value: unknown): boolean { return isExplicitlyLocalProject(value) || - isExplicitHostProjectCodeExecutionAllowed(value); + (!isSharedProjectRuntime(value) && isExplicitHostProjectCodeExecutionAllowed(value)); } /** @@ -123,10 +125,9 @@ export function isExplicitHostProjectCodeExecutionAllowed( /** * Decide whether a surface must refuse to execute tenant project code. * - * Execution is denied only when the runtime is shared *and* its host-owned - * entrypoint did not grant the host-execution capability. Local development, - * dedicated single-project runtimes, and operator-granted shared executors all - * carry the capability. Every ambiguous value fails closed. + * Execution is denied when the runtime is shared and the project is not an + * explicit local-development project. Dedicated single-project runtimes can + * use the host-owned capability. Every ambiguous value fails closed. * * Every execution surface shares this single predicate so their boundaries * cannot drift apart. diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts index c2d9ee12c3..579691df97 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts @@ -154,16 +154,14 @@ describe("resolveFrameworkFile", () => { FRAMEWORK_ROOT, "src", "platform", - "compat", - "process", - "env-public.ts", + "env.ts", ); const files: Record = { [sourcePath]: 'export { getEnv } from "./env.ts";', }; const fs = createMockFs(files); const result = await resolveFrameworkFile( - "/_vf_modules/_veryfront/platform/compat/process/env-public.js?ssr=true", + "/_vf_modules/_veryfront/platform/env.js?ssr=true", fs, createExistsFn(files), ); diff --git a/tsconfig.json b/tsconfig.json index 68d974ffe4..cd44612981 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -86,7 +86,7 @@ "veryfront/platform/esbuild-init": [ "./src/platform/compat/esbuild-init.ts" ], - "veryfront/platform/env": ["./src/platform/compat/process/env-public.ts"], + "veryfront/platform/env": ["./src/platform/env.ts"], "veryfront/platform/path": ["./src/platform/compat/path/index.ts"], "veryfront/platform/http": ["./src/platform/compat/http/index.ts"], "veryfront/errors/general": ["./src/errors/error-registry/general.ts"], From f1a38442f1c9f84fd59bf0ce132221d086877003 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 26 Aug 2026 12:45:00 +0200 Subject: [PATCH 06/11] fix(security): preserve supported host module paths --- .../compat/framework-source-resolver.test.ts | 5 ++++ .../compat/framework-source-resolver.ts | 10 +++++++ src/security/project-locality.test.ts | 26 ++++++++++++++++++- src/security/project-locality.ts | 4 +-- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/platform/compat/framework-source-resolver.test.ts b/src/platform/compat/framework-source-resolver.test.ts index 9e14a6cce0..3b748a3e3b 100644 --- a/src/platform/compat/framework-source-resolver.test.ts +++ b/src/platform/compat/framework-source-resolver.test.ts @@ -315,6 +315,11 @@ describe("framework-source-resolver public entry keys", () => { it("accepts public export targets and rejects internal wrappers", () => { assertEquals(isPublicFrameworkSourceKey("platform/env.js"), true); assertEquals(isPublicFrameworkSourceKey("react/runtime/core.ts"), true); + assertEquals( + isPublicFrameworkSourceKey("workflow/react/index.js"), + true, + "the supported veryfront/workflow SSR override must remain loadable", + ); assertEquals(isPublicFrameworkSourceKey("config/index.ts"), false); assertEquals(isPublicFrameworkSourceKey("platform/compat/process/env-public.js"), false); assertEquals(isPublicFrameworkSourceKey("observability/tracing/telemetry-env.ts"), false); diff --git a/src/platform/compat/framework-source-resolver.ts b/src/platform/compat/framework-source-resolver.ts index 898de15e5d..6c948235dd 100644 --- a/src/platform/compat/framework-source-resolver.ts +++ b/src/platform/compat/framework-source-resolver.ts @@ -53,12 +53,22 @@ const publicExportSpecifiers = new Set( specifier === "." ? "veryfront" : `veryfront/${specifier.replace(/^\.\//, "")}` ), ); +const supportedFrameworkOverrideSpecifiers = [ + ["veryfront", "veryfront/index.client"], + ["veryfront/workflow", "veryfront/workflow/react"], +] as const; const PUBLIC_FRAMEWORK_SOURCE_KEYS = new Set( [ ...Object.values(publicConfig.exports ?? {}), ...Object.entries(publicConfig.imports ?? {}) .filter(([specifier]) => publicExportSpecifiers.has(specifier)) .map(([, target]) => target), + ...supportedFrameworkOverrideSpecifiers + .filter(([specifier]) => publicExportSpecifiers.has(specifier)) + .flatMap(([, targetSpecifier]) => { + const target = publicConfig.imports?.[targetSpecifier]; + return target === undefined ? [] : [target]; + }), ] .filter((target) => target.startsWith("./src/")) .map(normalizeFrameworkSourceKey), diff --git a/src/security/project-locality.test.ts b/src/security/project-locality.test.ts index 83c5943c15..324a454a36 100644 --- a/src/security/project-locality.test.ts +++ b/src/security/project-locality.test.ts @@ -1,7 +1,13 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { isSharedProjectRuntime, requiresIsolatedProjectRuntime } from "./project-locality.ts"; +import { isBun } from "#veryfront/platform/compat/runtime.ts"; +import { + isExplicitHostProjectCodeExecutionAllowed, + isHostProjectCodeExecutionAllowed, + isSharedProjectRuntime, + requiresIsolatedProjectRuntime, +} from "./project-locality.ts"; describe("security/project-locality shared runtime topology", () => { it("recognizes hosted-config and multi-project runtime boundaries", () => { @@ -119,6 +125,24 @@ describe("security/project-locality isolated runtime requirement", () => { ); }); + it("keeps the host capability usable in dedicated Bun runtimes", () => { + if (!isBun) return; + const capableDedicatedRuntime = { + ...dedicatedRuntime, + allowHostProjectCodeExecution: true, + }; + assertEquals(isExplicitHostProjectCodeExecutionAllowed(capableDedicatedRuntime), true); + assertEquals(isHostProjectCodeExecutionAllowed(capableDedicatedRuntime), true); + assertEquals( + isHostProjectCodeExecutionAllowed({ + ...sharedRuntime, + allowHostProjectCodeExecution: true, + }), + false, + "the topology-aware boundary must still deny shared Bun execution", + ); + }); + it("fails closed when the topology signal is ambiguous", () => { assertEquals( requiresIsolatedProjectRuntime({ diff --git a/src/security/project-locality.ts b/src/security/project-locality.ts index 0650fb6027..b0ff4873ad 100644 --- a/src/security/project-locality.ts +++ b/src/security/project-locality.ts @@ -1,5 +1,3 @@ -import { isBun as IS_BUN } from "#veryfront/platform/compat/runtime.ts"; - const apply = Reflect.apply; const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const getPrototypeOf = Object.getPrototypeOf; @@ -119,7 +117,7 @@ export function isHostProjectCodeExecutionAllowed(value: unknown): boolean { export function isExplicitHostProjectCodeExecutionAllowed( value: unknown, ): boolean { - return !IS_BUN && readOwnDataProperty(value, "allowHostProjectCodeExecution") === true; + return readOwnDataProperty(value, "allowHostProjectCodeExecution") === true; } /** From 46c22a92e0ad4e6e7928be7a942075f77c0f06ab Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 26 Aug 2026 13:09:36 +0200 Subject: [PATCH 07/11] fix(security): snapshot project topology once --- src/platform/compat/process/env-public.ts | 19 ------------------- src/security/project-locality.test.ts | 19 +++++++++++++++++++ src/security/project-locality.ts | 12 ++++++++++-- 3 files changed, 29 insertions(+), 21 deletions(-) delete mode 100644 src/platform/compat/process/env-public.ts diff --git a/src/platform/compat/process/env-public.ts b/src/platform/compat/process/env-public.ts deleted file mode 100644 index c456d38a6f..0000000000 --- a/src/platform/compat/process/env-public.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Project-scoped environment helpers for cross-runtime applications. - * - * Host environment access and the trusted project-snapshot bridge remain - * internal framework controls and are not exported from this module. - * - * @module platform/env - */ - -export { - deleteEnv, - env, - type EnvBooleanOptions, - getEnv, - getEnvBoolean, - getEnvNumber, - getEnvString, - setEnv, -} from "./env.ts"; diff --git a/src/security/project-locality.test.ts b/src/security/project-locality.test.ts index 324a454a36..bf377a0829 100644 --- a/src/security/project-locality.test.ts +++ b/src/security/project-locality.test.ts @@ -164,6 +164,25 @@ describe("security/project-locality isolated runtime requirement", () => { ); }); + it("uses one topology snapshot for the host-execution decision", () => { + let calls = 0; + const runtime = { + allowHostProjectCodeExecution: true, + adapter: { + fs: { + isMultiProjectMode: () => calls++ === 0, + }, + }, + }; + + assertEquals( + requiresIsolatedProjectRuntime(runtime), + true, + "a shared topology snapshot must stay authoritative for the whole decision", + ); + assertEquals(calls, 1, "the topology provider must be sampled once"); + }); + it("rejects capabilities that are not own boolean data properties", () => { // Defined on the object itself: spreading an accessor would invoke the // getter and silently produce the data property this test must reject. diff --git a/src/security/project-locality.ts b/src/security/project-locality.ts index b0ff4873ad..7295a131c6 100644 --- a/src/security/project-locality.ts +++ b/src/security/project-locality.ts @@ -105,8 +105,15 @@ export function isExplicitlyLocalProject(value: unknown): boolean { * fails closed. */ export function isHostProjectCodeExecutionAllowed(value: unknown): boolean { + return isHostProjectCodeExecutionAllowedForTopology(value, isSharedProjectRuntime(value)); +} + +function isHostProjectCodeExecutionAllowedForTopology( + value: unknown, + sharedRuntime: boolean, +): boolean { return isExplicitlyLocalProject(value) || - (!isSharedProjectRuntime(value) && isExplicitHostProjectCodeExecutionAllowed(value)); + (!sharedRuntime && isExplicitHostProjectCodeExecutionAllowed(value)); } /** @@ -131,7 +138,8 @@ export function isExplicitHostProjectCodeExecutionAllowed( * cannot drift apart. */ export function requiresIsolatedProjectRuntime(value: unknown): boolean { - return !isHostProjectCodeExecutionAllowed(value) && isSharedProjectRuntime(value); + const sharedRuntime = isSharedProjectRuntime(value); + return !isHostProjectCodeExecutionAllowedForTopology(value, sharedRuntime) && sharedRuntime; } /** From 169a7d8cd1eb3bd6705e9b09705563b4ba3aaa29 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 26 Aug 2026 13:44:11 +0200 Subject: [PATCH 08/11] fix(security): close public host env access --- docs/api-reference/veryfront/observability.md | 1 - src/observability/index.test.ts | 2 +- src/observability/index.ts | 5 +---- .../compat/framework-source-resolver.test.ts | 5 +++++ src/platform/compat/framework-source-resolver.ts | 14 ++++++++------ src/proxy/tracing.ts | 2 +- 6 files changed, 16 insertions(+), 13 deletions(-) diff --git a/docs/api-reference/veryfront/observability.md b/docs/api-reference/veryfront/observability.md index c8bb98cb3b..ccdbcd3abd 100644 --- a/docs/api-reference/veryfront/observability.md +++ b/docs/api-reference/veryfront/observability.md @@ -53,7 +53,6 @@ const result = await withSpan("load-data", async () => { | `getActiveContext` | Context for get active. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L98) | | `getErrorCollector` | Return error collector. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/error-collector.ts#L406) | | `getGlobalMetricsAPI` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/api-shim.ts#L684) | -| `getHostTelemetryEnv` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/telemetry-env.ts#L7) | | `getLogBuffer` | Return log buffer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/log-buffer.ts#L231) | | `getMetricsState` | State for get metrics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/metrics/index.ts#L38) | | `getTraceContext` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/otlp-setup.ts#L651) | diff --git a/src/observability/index.test.ts b/src/observability/index.test.ts index c32aad3253..323938f180 100644 --- a/src/observability/index.test.ts +++ b/src/observability/index.test.ts @@ -22,7 +22,6 @@ const expectedRuntimeExports = [ "getActiveContext", "getErrorCollector", "getGlobalMetricsAPI", - "getHostTelemetryEnv", "getLogBuffer", "getMetricsState", "getTraceContext", @@ -108,5 +107,6 @@ describe("veryfront/observability public export surface", () => { it("does not expose process-wide error-reporter mutators", () => { assertEquals("initializeApplicationErrorReporter" in observability, false); assertEquals("setApplicationErrorReporter" in observability, false); + assertEquals("getHostTelemetryEnv" in observability, false); }); }); diff --git a/src/observability/index.ts b/src/observability/index.ts index d4fb8b0b28..8bfe7e9d37 100644 --- a/src/observability/index.ts +++ b/src/observability/index.ts @@ -96,10 +96,7 @@ export type { } from "./tracing/api-shim.ts"; // Shared-runtime telemetry environment helpers -export { - getHostTelemetryEnv, - isReservedSharedRuntimeTelemetryEnvKey, -} from "./tracing/telemetry-env.ts"; +export { isReservedSharedRuntimeTelemetryEnvKey } from "./tracing/telemetry-env.ts"; // Per-request profiling export { diff --git a/src/platform/compat/framework-source-resolver.test.ts b/src/platform/compat/framework-source-resolver.test.ts index 3b748a3e3b..6dc78165b1 100644 --- a/src/platform/compat/framework-source-resolver.test.ts +++ b/src/platform/compat/framework-source-resolver.test.ts @@ -315,6 +315,11 @@ describe("framework-source-resolver public entry keys", () => { it("accepts public export targets and rejects internal wrappers", () => { assertEquals(isPublicFrameworkSourceKey("platform/env.js"), true); assertEquals(isPublicFrameworkSourceKey("react/runtime/core.ts"), true); + assertEquals( + isPublicFrameworkSourceKey("react/public.js"), + true, + "the supported veryfront/react SSR override must remain loadable", + ); assertEquals( isPublicFrameworkSourceKey("workflow/react/index.js"), true, diff --git a/src/platform/compat/framework-source-resolver.ts b/src/platform/compat/framework-source-resolver.ts index 6c948235dd..5eb1a872e6 100644 --- a/src/platform/compat/framework-source-resolver.ts +++ b/src/platform/compat/framework-source-resolver.ts @@ -53,9 +53,9 @@ const publicExportSpecifiers = new Set( specifier === "." ? "veryfront" : `veryfront/${specifier.replace(/^\.\//, "")}` ), ); -const supportedFrameworkOverrideSpecifiers = [ - ["veryfront", "veryfront/index.client"], - ["veryfront/workflow", "veryfront/workflow/react"], +const supportedFrameworkOverrideTargetSpecifiers = [ + "veryfront/index.client", + "veryfront/workflow/react", ] as const; const PUBLIC_FRAMEWORK_SOURCE_KEYS = new Set( [ @@ -63,12 +63,14 @@ const PUBLIC_FRAMEWORK_SOURCE_KEYS = new Set( ...Object.entries(publicConfig.imports ?? {}) .filter(([specifier]) => publicExportSpecifiers.has(specifier)) .map(([, target]) => target), - ...supportedFrameworkOverrideSpecifiers - .filter(([specifier]) => publicExportSpecifiers.has(specifier)) - .flatMap(([, targetSpecifier]) => { + ...supportedFrameworkOverrideTargetSpecifiers + .flatMap((targetSpecifier) => { const target = publicConfig.imports?.[targetSpecifier]; return target === undefined ? [] : [target]; }), + // The SSR import map intentionally serves the browser-safe React barrel + // instead of the server-capable internal import target. + "./src/react/public.ts", ] .filter((target) => target.startsWith("./src/")) .map(normalizeFrameworkSourceKey), diff --git a/src/proxy/tracing.ts b/src/proxy/tracing.ts index 941cf991e0..c47e979af3 100644 --- a/src/proxy/tracing.ts +++ b/src/proxy/tracing.ts @@ -28,7 +28,7 @@ import { trace as shimTrace, type Tracer, } from "#veryfront/observability/tracing/api-shim.ts"; -import { getHostTelemetryEnv } from "#veryfront/observability"; +import { getHostTelemetryEnv } from "#veryfront/observability/tracing/telemetry-env.ts"; import type { TracingExporter } from "#veryfront/extensions/observability/tracing-exporter.ts"; import { importFirstPartyExtensionModule, From 46d96e0aea463a7314b07cc8f2f4e1c5dfb82aa9 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 26 Aug 2026 14:21:48 +0200 Subject: [PATCH 09/11] fix(security): enforce topology across execution paths --- src/platform/compat/process.test.ts | 28 +++++++++-------- .../preview/markdown-preview.handler.test.ts | 30 +++---------------- .../request/api/api-handler-wrapper.test.ts | 18 +++-------- .../request/api/app-router-handler.test.ts | 19 +++--------- .../request/api/project-discovery.test.ts | 26 ++++------------ .../handlers/request/snippet.handler.test.ts | 12 +++----- .../handlers/request/ssr/ssr.handler.test.ts | 11 +++---- src/server/production-server.ts | 1 + src/server/startup-discovery.test.ts | 29 +++++++++++++++++- src/server/startup-discovery.ts | 10 +++++-- .../pipeline/stages/ssr-vf-modules.test.ts | 7 +++++ .../pipeline/stages/ssr-vf-modules/index.ts | 5 +++- .../ssr-vf-modules/path-resolver.test.ts | 22 ++++++++++++++ .../stages/ssr-vf-modules/path-resolver.ts | 3 +- 14 files changed, 114 insertions(+), 107 deletions(-) diff --git a/src/platform/compat/process.test.ts b/src/platform/compat/process.test.ts index ceeeacc228..d7b9e28395 100644 --- a/src/platform/compat/process.test.ts +++ b/src/platform/compat/process.test.ts @@ -191,21 +191,25 @@ describe("Process Compat", () => { }); }); - it("returns undefined instead of throwing when an env read is denied", () => { + it("returns undefined instead of throwing when an env read is denied", async () => { // Under a tightened env permission allowlist (project isolation workers), // Deno.env.get throws NotCapable for a non-allowlisted key. getHostEnv must // degrade to undefined rather than propagating the throw and crashing the - // request. Simulate the denial by stubbing Deno.env.get. - if (typeof Deno === "undefined") return; - const original = Deno.env.get; - try { - Deno.env.get = () => { - throw new Error("Requires env access, run again with the --allow-env flag"); - }; - assertEquals(getHostEnv("__DENIED_BY_ALLOWLIST__"), undefined); - } finally { - Deno.env.get = original; - } + // request. Exercise the real permission boundary in a child process because + // the installed project-scoped Deno.env view is intentionally immutable. + if (!isDeno) return; + const envModuleUrl = new URL("./process/env.ts", import.meta.url).href; + const configPath = new URL("../../../deno.json", import.meta.url).pathname; + const script = `const { getHostEnv } = await import(${JSON.stringify(envModuleUrl)});` + + `console.log(String(getHostEnv("__DENIED_BY_ALLOWLIST__")));`; + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "--config", configPath, script], + stdout: "piped", + stderr: "piped", + }).output(); + + assertEquals(output.success, true, new TextDecoder().decode(output.stderr)); + assertEquals(new TextDecoder().decode(output.stdout).trim(), "undefined"); }); }); diff --git a/src/server/handlers/preview/markdown-preview.handler.test.ts b/src/server/handlers/preview/markdown-preview.handler.test.ts index ec5bf5a372..43f913a499 100644 --- a/src/server/handlers/preview/markdown-preview.handler.test.ts +++ b/src/server/handlers/preview/markdown-preview.handler.test.ts @@ -1,6 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; import "#veryfront/transforms/mdx/compiler/__tests__/content-processor-setup.ts"; -import { assertEquals, assertNotEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; import type { HandlerContext } from "../types.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { MarkdownPreviewHandler } from "./markdown-preview.handler.ts"; @@ -100,13 +100,7 @@ Deno.test("MarkdownPreviewHandler fails closed before shared source reads", asyn }); describe("MarkdownPreviewHandler host-execution capability", () => { - it("renders once the host grants execution", async () => { - // The granted counterpart of the shared-runtime denial above. #3364 - // collapsed the execution surfaces onto requiresIsolatedProjectRuntime so - // they could not drift apart, but markdown preview kept a bare - // isSharedProjectRuntime check and denied unconditionally. Without this - // case, an unconditional denial here is indistinguishable from a correct - // fail-closed guard. + it("keeps shared preview execution denied despite a host grant", async () => { let reads = 0; const ctx = { projectDir: "/remote/project", @@ -148,24 +142,8 @@ describe("MarkdownPreviewHandler host-execution capability", () => { ctx, ); - assertEquals( - result.response?.status, - 200, - "a granted shared executor must serve the rendered preview", - ); - assertEquals( - result.response?.headers.get("content-type"), - "text/html; charset=utf-8", - "the granted preview must be served as HTML", - ); - assertStringIncludes( - await result.response!.text(), - "Readme", - "the rendered HTML must carry the markdown heading", - ); - // Not merely "did not 503": the granted request has to actually reach the - // shared filesystem, otherwise a fallthrough returning no response passes. - assertNotEquals(reads, 0, "the granted path must reach the project source read"); + assertEquals(result.response?.status, 503); + assertEquals(reads, 0); }); }); diff --git a/src/server/handlers/request/api/api-handler-wrapper.test.ts b/src/server/handlers/request/api/api-handler-wrapper.test.ts index 20a0821343..5682c600fa 100644 --- a/src/server/handlers/request/api/api-handler-wrapper.test.ts +++ b/src/server/handlers/request/api/api-handler-wrapper.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertNotEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import type { HandlerContext } from "#veryfront/types"; import { ApiHandlerWrapper } from "./api-handler-wrapper.ts"; @@ -337,9 +337,7 @@ describe("ApiHandlerWrapper", () => { ); }); - it("starts shared-runtime API discovery once the host grants execution", async () => { - // The granted counterpart of the fail-closed case above. Without this, - // nothing pins that the operator grant actually reaches this surface. + it("keeps shared-runtime API discovery denied despite a host grant", async () => { let projectContextEntries = 0; let filesystemReads = 0; const ctx = createCtx({}); @@ -372,17 +370,9 @@ describe("ApiHandlerWrapper", () => { ctx, ); - assertNotEquals( - result.response?.status, - 503, - "a granted shared executor must not return project-execution-unavailable", - ); + assertEquals(result.response?.status, 503); assertEquals(projectContextEntries, 1); - assertEquals( - filesystemReads > 0, - true, - "the request must reach source resolution instead of failing at the guard", - ); + assertEquals(filesystemReads, 0); }); it("forwards environmentName into multi-project request context", async () => { diff --git a/src/server/handlers/request/api/app-router-handler.test.ts b/src/server/handlers/request/api/app-router-handler.test.ts index 8cf973ae62..2f1c45c6b0 100644 --- a/src/server/handlers/request/api/app-router-handler.test.ts +++ b/src/server/handlers/request/api/app-router-handler.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertNotEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import type { HandlerContext } from "../../types.ts"; import { handleAppRouter } from "./app-router-handler.ts"; @@ -33,10 +33,7 @@ describe("server API app-router compatibility handler", () => { assertEquals(filesystemCalls, 0); }); - it("reaches route discovery once the host grants execution", async () => { - // The granted counterpart of the case above. Without it, nothing pins that - // the operator grant actually reaches this surface, and a hardcoded denial - // here would look identical to a correct fail-closed guard. + it("keeps shared route discovery denied despite a host grant", async () => { let filesystemCalls = 0; const ctx = { projectDir: "/remote/project", @@ -59,15 +56,7 @@ describe("server API app-router compatibility handler", () => { ctx, ); - assertNotEquals( - response?.status, - 503, - "a granted shared executor must not return project-execution-unavailable", - ); - assertEquals( - filesystemCalls > 0, - true, - "a granted shared executor must reach route discovery", - ); + assertEquals(response?.status, 503); + assertEquals(filesystemCalls, 0); }); }); diff --git a/src/server/handlers/request/api/project-discovery.test.ts b/src/server/handlers/request/api/project-discovery.test.ts index e5e78af35d..faf1ced0d0 100644 --- a/src/server/handlers/request/api/project-discovery.test.ts +++ b/src/server/handlers/request/api/project-discovery.test.ts @@ -151,10 +151,7 @@ describe( assertEquals((globalThis as Record)[marker], undefined); }); - it("discovers primitives in a shared runtime once the host grants execution", async () => { - // Regression for issue-inbox#356: agent chat 500'd on every hosted project - // with agents/*.ts because this guard ignored the host-execution - // capability that the rest of the execution surfaces already honor. + it("keeps shared primitive discovery denied despite a host grant", async () => { const ctx = createHandlerContext("/granted-project", "granted", "preview"); ctx.isLocalProject = false; // Present marks a shared multi-project runtime, as veryfront-server is. @@ -184,23 +181,12 @@ describe( return readFile(path); }; - const result = await ensureProjectDiscovery(ctx); - - assertExists(result, "discovery must return a result instead of throwing"); - assertEquals( - reads > 0, - true, - "an operator-granted shared executor must actually read project source", - ); - // `reads > 0` alone is satisfied by any incidental probe, so pin the - // primitive itself: discovery must have found the granted project's tool. - assertEquals( - result.tools.has("granted_tool"), - true, - `granted discovery must surface the project tool, got ${ - JSON.stringify([...result.tools.keys()]) - }`, + await assertRejects( + () => ensureProjectDiscovery(ctx), + Error, + "isolated project runtime", ); + assertEquals(reads, 0); }); afterAll(async () => { diff --git a/src/server/handlers/request/snippet.handler.test.ts b/src/server/handlers/request/snippet.handler.test.ts index 1db068cb18..399f5f92eb 100644 --- a/src/server/handlers/request/snippet.handler.test.ts +++ b/src/server/handlers/request/snippet.handler.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertNotEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { validateLexicalPath } from "#veryfront/security"; import { SnippetHandler } from "./snippet.handler.ts"; @@ -114,7 +114,7 @@ Deno.test("SnippetHandler rejects shared rendering before proxy context or sourc }); describe("SnippetHandler host-execution capability", () => { - it("serves a shared runtime the host granted execution", async () => { + it("keeps shared snippet execution denied despite a host grant", async () => { // The granted counterpart to the test above. Without it, a handler that // simply denies every shared runtime, which is the pre-#366 behaviour, // passes the whole suite. @@ -156,12 +156,8 @@ describe("SnippetHandler host-execution capability", () => { ctx, ); - assertNotEquals( - result.response?.status, - 503, - "a granted shared executor must not return project-execution-unavailable", - ); - assertNotEquals(readPath, undefined, "the granted path must reach the source read"); + assertEquals(result.response?.status, 503); + assertEquals(readPath, undefined); }); it("passes the canonical enriched release identity to snippet rendering", async () => { diff --git a/src/server/handlers/request/ssr/ssr.handler.test.ts b/src/server/handlers/request/ssr/ssr.handler.test.ts index d4d2f8acbb..38d057ea0d 100644 --- a/src/server/handlers/request/ssr/ssr.handler.test.ts +++ b/src/server/handlers/request/ssr/ssr.handler.test.ts @@ -486,11 +486,7 @@ describe("server/handlers/request/ssr/ssr.handler", () => { assertEquals(renderCalls, 0); }); - it("renders once the host grants execution", async () => { - // The granted counterpart to the fail-closed test above. veryfront-code - // #3364 shipped a hardcoded `true` on a sibling surface that survived - // review because a fail-closed test cannot tell a correct predicate from - // a literal denial. Only this direction can. + it("keeps shared rendering denied despite a host grant", async () => { let renderCalls = 0; const handler = new SSRHandler(createMockSSRService({ renderPage: () => { @@ -513,8 +509,8 @@ describe("server/handlers/request/ssr/ssr.handler", () => { } as Partial), ); - assertEquals(result.response?.status, 200); - assertEquals(renderCalls, 1); + assertEquals(result.response?.status, 503); + assertEquals(renderCalls, 0); }); it("returns response from renderPage result", async () => { @@ -845,6 +841,7 @@ describe("server/handlers/request/ssr/ssr.handler", () => { }); const handler = new SSRHandler(mockService); const { ctx } = makeExtendedCtx({}, { + isLocalProject: true, allowHostProjectCodeExecution: true, projectSlug: "preview-project", projectId: "project-1", diff --git a/src/server/production-server.ts b/src/server/production-server.ts index 3025bfdbb0..93d988cde4 100644 --- a/src/server/production-server.ts +++ b/src/server/production-server.ts @@ -269,6 +269,7 @@ export function startProductionServer( const outcome = await runStartupDiscovery({ config: discoveryConfig, + runtimeAdapter: adapter, allowHostProjectCodeExecution, discoverAll, isExtendedFSAdapter, diff --git a/src/server/startup-discovery.test.ts b/src/server/startup-discovery.test.ts index 4728788ef7..1310fa4927 100644 --- a/src/server/startup-discovery.test.ts +++ b/src/server/startup-discovery.test.ts @@ -2,7 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertStrictEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import type { DiscoveryConfig, DiscoveryResult } from "#veryfront/discovery/types.ts"; -import type { FileSystemAdapter } from "#veryfront/platform/adapters/base.ts"; +import type { FileSystemAdapter, RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { ExtendedFileSystemAdapter } from "#veryfront/platform/adapters/fs/wrapper.ts"; import { runStartupDiscovery } from "./startup-discovery.ts"; @@ -35,6 +35,7 @@ function recorder() { /** Placeholder base dir: recorder() never touches the filesystem. */ const PROJECT_DIR = ""; +const dedicatedRuntimeAdapter = {} as RuntimeAdapter; /** No adapter is extended, so discovery takes the unscoped branch. */ const noExtendedAdapters = (_fs: FileSystemAdapter): _fs is ExtendedFileSystemAdapter => false; @@ -48,6 +49,7 @@ describe("server/startup-discovery", () => { await runStartupDiscovery({ config: { baseDir: PROJECT_DIR }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: false, discoverAll, isExtendedFSAdapter: noExtendedAdapters, @@ -68,6 +70,7 @@ describe("server/startup-discovery", () => { await runStartupDiscovery({ config: { baseDir: PROJECT_DIR, fsAdapter, verbose: true }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: false, discoverAll, isExtendedFSAdapter: noExtendedAdapters, @@ -91,6 +94,7 @@ describe("server/startup-discovery", () => { await runStartupDiscovery({ config: { baseDir: PROJECT_DIR }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: false, discoverAll, isExtendedFSAdapter: noExtendedAdapters, @@ -108,6 +112,7 @@ describe("server/startup-discovery", () => { const outcome = await runStartupDiscovery({ config: { baseDir: PROJECT_DIR, projectSlug: "p", apiToken: "t", fsAdapter }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: true, discoverAll, isExtendedFSAdapter: allExtendedAdapters, @@ -131,6 +136,7 @@ describe("server/startup-discovery", () => { await runStartupDiscovery({ config: { baseDir: PROJECT_DIR }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: true, discoverAll, isExtendedFSAdapter: noExtendedAdapters, @@ -139,6 +145,24 @@ describe("server/startup-discovery", () => { assertEquals(calls[0]?.allowHostProjectCodeExecution, true); }); + it("denies host execution for unscoped discovery in a shared runtime", async () => { + const { calls, discoverAll } = recorder(); + const runtimeAdapter = { + fs: { isMultiProjectMode: () => true }, + } as unknown as RuntimeAdapter; + + await runStartupDiscovery({ + config: { baseDir: PROJECT_DIR }, + runtimeAdapter, + allowHostProjectCodeExecution: true, + discoverAll, + isExtendedFSAdapter: noExtendedAdapters, + }); + + assertEquals(calls.length, 1); + assertEquals(calls[0]?.allowHostProjectCodeExecution, false); + }); + it("skips the scoped multi-project path rather than calling discovery ungranted", async () => { const { calls, discoverAll } = recorder(); const fsAdapter = { @@ -150,6 +174,7 @@ describe("server/startup-discovery", () => { // Even with the deployment granting, the scoped branch must not pass it: // that path evaluates tenant source under a project context. config: { baseDir: PROJECT_DIR, projectSlug: "p", apiToken: "t", fsAdapter }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: true, discoverAll, isExtendedFSAdapter: allExtendedAdapters, @@ -182,6 +207,7 @@ describe("server/startup-discovery", () => { assertEquals( await runStartupDiscovery({ config: { baseDir: PROJECT_DIR, projectSlug: "p", apiToken: "t", fsAdapter }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: true, discoverAll: enforcing, isExtendedFSAdapter: allExtendedAdapters, @@ -193,6 +219,7 @@ describe("server/startup-discovery", () => { assertEquals( await runStartupDiscovery({ config: { baseDir: PROJECT_DIR }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: true, discoverAll: enforcing, isExtendedFSAdapter: noExtendedAdapters, diff --git a/src/server/startup-discovery.ts b/src/server/startup-discovery.ts index 3fbf4888a3..ce58ffe41c 100644 --- a/src/server/startup-discovery.ts +++ b/src/server/startup-discovery.ts @@ -13,12 +13,15 @@ */ import type { DiscoveryConfig, DiscoveryResult } from "#veryfront/discovery/types.ts"; -import type { FileSystemAdapter } from "#veryfront/platform/adapters/base.ts"; +import type { FileSystemAdapter, RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { ExtendedFileSystemAdapter } from "#veryfront/platform/adapters/fs/wrapper.ts"; +import { isHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts"; import type { DiscoveryOptions } from "./production-server.ts"; export interface RunStartupDiscoveryInput { config: DiscoveryOptions; + /** Host-owned runtime topology used to constrain the deployment grant. */ + runtimeAdapter: RuntimeAdapter; /** * The deployment's posture, computed once by the host-owned entrypoint and * shared with the request handler. Never hardcoded here. @@ -68,7 +71,10 @@ export async function runStartupDiscovery( baseDir: config.baseDir, fsAdapter: config.fsAdapter, verbose: config.verbose ?? false, - allowHostProjectCodeExecution: input.allowHostProjectCodeExecution, + allowHostProjectCodeExecution: isHostProjectCodeExecutionAllowed({ + adapter: input.runtimeAdapter, + allowHostProjectCodeExecution: input.allowHostProjectCodeExecution, + }), }); return { ran: true }; } diff --git a/src/transforms/pipeline/stages/ssr-vf-modules.test.ts b/src/transforms/pipeline/stages/ssr-vf-modules.test.ts index 0c45a3bd60..62962e9ef3 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules.test.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules.test.ts @@ -11,6 +11,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { join } from "#veryfront/compat/path/index.ts"; import { REACT_DEFAULT_VERSION } from "#veryfront/utils/constants/cdn.ts"; import { buildReactUrl, getReactImportMap } from "../../import-rewriter/url-builder.ts"; import { @@ -547,6 +548,9 @@ describe("ssr-vf-modules relative import resolution", { code, target: "ssr", projectDir: "/tmp/test-project", + // components/Head is an internal dependency reached from framework + // source, not a tenant-selected entry point. + filePath: join(FRAMEWORK_ROOT, "src", "react", "public.ts"), reactVersion: REACT_DEFAULT_VERSION, } as TransformContext; @@ -570,6 +574,9 @@ describe("ssr-vf-modules relative import resolution", { code, target: "ssr", projectDir: "/tmp/test-project", + // components/index is an internal dependency reached from framework + // source, not a tenant-selected entry point. + filePath: join(FRAMEWORK_ROOT, "src", "react", "public.ts"), reactVersion: REACT_DEFAULT_VERSION, } as TransformContext; diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/index.ts b/src/transforms/pipeline/stages/ssr-vf-modules/index.ts index 1b6d06c73b..d2120c6b0c 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/index.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/index.ts @@ -22,6 +22,7 @@ import { TransformStage } from "../../types.ts"; import { rendererLogger as logger } from "#veryfront/utils"; import { replaceSpecifiers } from "../../../esm/lexer.ts"; import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { isFrameworkSourcePath } from "#veryfront/platform/compat/framework-source-resolver.ts"; import { REACT_DEFAULT_VERSION } from "#veryfront/utils/constants/cdn.ts"; import { findRelativeImports, findVfModuleImports } from "./import-finder.ts"; import { @@ -240,7 +241,9 @@ export const ssrVfModulesPlugin: TransformPlugin = { embeddedSrcDir: EMBEDDED_SRC_DIR, }); - const resolved = await resolveFrameworkFile(vfModulePath, fs); + const resolved = await resolveFrameworkFile(vfModulePath, fs, undefined, { + trustedFrameworkParent: ctx.filePath !== undefined && isFrameworkSourcePath(ctx.filePath), + }); if (!resolved) { logger.warn(`${LOG_PREFIX} Could not resolve ${vfModulePath}`, { frameworkRoot: FRAMEWORK_ROOT, diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts index 579691df97..df79975e69 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts @@ -149,6 +149,28 @@ describe("resolveFrameworkFile", () => { ); }); + it("resolves a non-public dependency for a trusted framework parent", async () => { + const sourcePath = join( + FRAMEWORK_ROOT, + "src", + "html", + "managed-head-protocol.ts", + ); + const files: Record = { + [sourcePath]: "export const MANAGED_HEAD_ATTRIBUTE = 'data-vf-head';", + }; + const fs = createMockFs(files); + + const result = await resolveFrameworkFile( + "/_vf_modules/_veryfront/html/managed-head-protocol.js?ssr=true", + fs, + createExistsFn(files), + { trustedFrameworkParent: true }, + ); + + assertEquals(result?.sourcePath, sourcePath); + }); + it("still resolves the public platform/env facade", async () => { const sourcePath = join( FRAMEWORK_ROOT, diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts index bea75825fa..49ed0410e5 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts @@ -55,6 +55,7 @@ export async function resolveFrameworkFile( vfModulePath: string, fs: ReturnType, existsFn: (path: string) => Promise = exists, + options: { trustedFrameworkParent?: boolean } = {}, ): Promise<{ sourcePath: string; content: string } | null> { const normalizedVfModulePath = vfModulePath.replace(/^file:\/\/(?=\/_vf_modules\/)/, ""); @@ -67,7 +68,7 @@ export async function resolveFrameworkFile( ? pathWithoutPrefix.slice("_veryfront/".length) : pathWithoutPrefix; if (!isSafeFrameworkSourceKey(frameworkRelativePath)) return null; - if (!isPublicFrameworkSourceKey(frameworkRelativePath)) { + if (!options.trustedFrameworkParent && !isPublicFrameworkSourceKey(frameworkRelativePath)) { logger.warn(`${LOG_PREFIX} Refusing non-public framework module for tenant import`, { vfModulePath, frameworkRelativePath, From eaec412a1c31a283de15d7bf5e0cad0b8517074a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 26 Aug 2026 14:34:21 +0200 Subject: [PATCH 10/11] test(security): clarify shared runtime denial --- src/server/handlers/request/api/project-discovery.test.ts | 2 +- src/server/handlers/request/snippet.handler.test.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/server/handlers/request/api/project-discovery.test.ts b/src/server/handlers/request/api/project-discovery.test.ts index faf1ced0d0..db1b11e36a 100644 --- a/src/server/handlers/request/api/project-discovery.test.ts +++ b/src/server/handlers/request/api/project-discovery.test.ts @@ -155,7 +155,7 @@ describe( const ctx = createHandlerContext("/granted-project", "granted", "preview"); ctx.isLocalProject = false; // Present marks a shared multi-project runtime, as veryfront-server is. - // Rejecting also asserts discovery never invokes it on the granted path. + // Rejecting also asserts discovery never invokes this tenant boundary. ctx.prepareHostedConfigContext = () => Promise.reject(new Error("must not be called")); ctx.allowHostProjectCodeExecution = true; await ctx.adapter.fs.writeFile( diff --git a/src/server/handlers/request/snippet.handler.test.ts b/src/server/handlers/request/snippet.handler.test.ts index 399f5f92eb..653e43e42d 100644 --- a/src/server/handlers/request/snippet.handler.test.ts +++ b/src/server/handlers/request/snippet.handler.test.ts @@ -115,9 +115,8 @@ Deno.test("SnippetHandler rejects shared rendering before proxy context or sourc describe("SnippetHandler host-execution capability", () => { it("keeps shared snippet execution denied despite a host grant", async () => { - // The granted counterpart to the test above. Without it, a handler that - // simply denies every shared runtime, which is the pre-#366 behaviour, - // passes the whole suite. + // An entrypoint grant cannot override shared-runtime topology. Pin the + // source-read boundary so this does not regress to capability-only checks. let readPath: string | undefined; const fs = { symlinkSemantics: "none" as const, From cb566b1e828ad1accbfce0062976ff4eb872e333 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Wed, 26 Aug 2026 15:00:25 +0200 Subject: [PATCH 11/11] fix(security): align host execution posture --- docs/api-reference/veryfront/index.client.md | 2 +- docs/api-reference/veryfront/index.md | 2 +- docs/api-reference/veryfront/platform.md | 16 +-- docs/api-reference/veryfront/server.md | 8 +- docs/api-reference/veryfront/testing.md | 44 +++--- src/platform/compat/process/env.test.ts | 54 ++++++++ src/platform/compat/process/env.ts | 8 +- .../compat/process/scoped-process-env.ts | 130 +++++++++++++++--- src/security/README.md | 42 +++--- src/security/host-execution-policy.test.ts | 48 ++++--- src/security/host-execution-policy.ts | 42 +++--- .../sandbox/isolation-posture.test.ts | 13 +- src/security/sandbox/worker-pool.test.ts | 2 +- src/security/sandbox/worker-pool.ts | 24 ++-- .../handlers/request/api/project-discovery.ts | 4 +- src/server/production-server.ts | 27 ++-- src/testing/bdd.ts | 6 + 17 files changed, 329 insertions(+), 143 deletions(-) diff --git a/docs/api-reference/veryfront/index.client.md b/docs/api-reference/veryfront/index.client.md index 900774b44c..3d6ba4add5 100644 --- a/docs/api-reference/veryfront/index.client.md +++ b/docs/api-reference/veryfront/index.client.md @@ -49,7 +49,7 @@ export function GET() { | `defineConfig` | Define a Veryfront project configuration object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L4) | | `defineConfigWithEnv` | Define a Veryfront project configuration from the current environment name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L8) | | `forbidden` | Create a 403 Forbidden response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L134) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L225) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L229) | | `json` | Create a JSON response with the correct content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L67) | | `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L17) | | `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L63) | diff --git a/docs/api-reference/veryfront/index.md b/docs/api-reference/veryfront/index.md index 00bca8c9fb..3003064151 100644 --- a/docs/api-reference/veryfront/index.md +++ b/docs/api-reference/veryfront/index.md @@ -67,7 +67,7 @@ export function getServerData(ctx: DataContext) { | `defineConfig` | Define a Veryfront project configuration object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L4) | | `defineConfigWithEnv` | Define a Veryfront project configuration from the current environment name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L8) | | `forbidden` | Create a 403 Forbidden response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L134) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L225) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L229) | | `json` | Create a JSON response with the correct content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L67) | | `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L17) | | `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L63) | diff --git a/docs/api-reference/veryfront/platform.md b/docs/api-reference/veryfront/platform.md index 3c77c4aa36..be3cb21d61 100644 --- a/docs/api-reference/veryfront/platform.md +++ b/docs/api-reference/veryfront/platform.md @@ -31,7 +31,7 @@ import { | `createKVStore` | Create a cross-runtime KV store. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/kv/factory.ts#L82) | | `createMockAdapter` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/adapters/mock.ts#L133) | | `cwd` | Return the current working directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L26) | -| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L334) | +| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L338) | | `enhanceAdapterWithFS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/adapters/fs/integration.ts#L64) | | `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L62) | | `execPath` | Get the executable path of the current runtime | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L332) | @@ -40,7 +40,7 @@ import { | `getAdapter` | Get the runtime adapter for the current environment | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/adapters/detect.ts#L24) | | `getArgs` | Get command-line arguments (cross-runtime: Deno.args or process.argv). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L10) | | `getDenoRuntime` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/runtime.ts#L33) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L225) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L229) | | `getLocalAdapter` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/adapters/registry.ts#L230) | | `getOsType` | Get the operating system type Returns: "darwin" (macOS), "linux", "windows", or the raw platform string | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L164) | | `getRuntimeVersion` | Get runtime version string | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L150) | @@ -65,7 +65,7 @@ import { | `remove` | Remove a file or directory, rejecting when the path does not exist. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L577) | | `resolveHostAddresses` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/dns.ts#L292) | | `runCommand` | Run a command and return the result. Works across Deno, Node.js, and Bun. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/command.ts#L449) | -| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L305) | +| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L309) | | `setRawMode` | Set raw mode on stdin (enables character-by-character input) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/stdin.ts#L55) | | `writeStdout` | Write text directly to stdout (sync) No-op if stdout is not available | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L377) | | `writeStdoutAsync` | Write data to stdout asynchronously Returns a promise that resolves when the write is complete | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L385) | @@ -118,16 +118,16 @@ import { getEnv, getEnvBoolean, getEnvNumber } from "veryfront/platform/env"; | Name | Description | Source | | --------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L225) | -| `getEnvBoolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L279) | -| `getEnvNumber` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L265) | -| `getEnvString` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L257) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L229) | +| `getEnvBoolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L283) | +| `getEnvNumber` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L269) | +| `getEnvString` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L261) | #### Types | Name | Description | Source | | ------------------- | ----------- | ------------------------------------------------------------------------------------------------------- | -| `EnvBooleanOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L242) | +| `EnvBooleanOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L246) | ### `veryfront/platform/path` diff --git a/docs/api-reference/veryfront/server.md b/docs/api-reference/veryfront/server.md index ecd6e64f47..4e81983217 100644 --- a/docs/api-reference/veryfront/server.md +++ b/docs/api-reference/veryfront/server.md @@ -54,7 +54,7 @@ await server.fetch(new Request("https://example.com/health")); | `parseProjectDomain` | Extract project slug and branch from domain/host header | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/utils/domain-parser.ts#L125) | | `startDevServer` | Starts dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/index.ts#L20) | | `startNodeVeryfrontServer` | Starts node veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L575) | -| `startProductionServer` | Starts production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L182) | +| `startProductionServer` | Starts production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L183) | | `startServer` | Start a Veryfront server in development or production mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L531) | | `startVeryfrontServer` | Starts veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L555) | | `toNodeHandler` | Convert a Web API request handler into a Node.js HTTP listener. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/node-handler.ts#L5) | @@ -75,17 +75,17 @@ await server.fetch(new Request("https://example.com/health")); | `CreateVeryfrontServerOptions` | Options accepted by create veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L32) | | `DevServerHandler` | Public handler returned by a handler-only dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L2) | | `DevServerOptions` | Options accepted by dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L5) | -| `DiscoveryOptions` | Configuration for AI primitives discovery during server startup | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L120) | +| `DiscoveryOptions` | Configuration for AI primitives discovery during server startup | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L121) | | `FileWatcherMetrics` | Public API contract for file watcher metrics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L40) | | `GracefulProductionShutdownOptions` | Inputs required to drain and stop a production server process. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/graceful-shutdown.ts#L25) | | `HostedEnvironmentName` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/utils/domain-parser.ts#L49) | | `NodeVeryfrontServiceServer` | Public API contract for node veryfront service server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L83) | | `RouteDirectory` | Public API contract for route directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L34) | -| `ServerHandle` | Public API contract for server handle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L157) | +| `ServerHandle` | Public API contract for server handle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L158) | | `StartDevModeOptions` | Options accepted by start dev mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L120) | | `StartNodeVeryfrontServerOptions` | Options accepted by start node veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L47) | | `StartProductionModeOptions` | Options accepted by start production mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L129) | -| `StartProductionServerOptions` | Options accepted by start production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L163) | +| `StartProductionServerOptions` | Options accepted by start production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L164) | | `StartServerOptions` | Server options. Defaults to development mode with HMR. Set `mode: "production"` for a production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L145) | | `StartVeryfrontServerOptions` | Options accepted by start veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L59) | | `VeryfrontHandler` | Web API request handler with WebSocket upgrade and HMR helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L160) | diff --git a/docs/api-reference/veryfront/testing.md b/docs/api-reference/veryfront/testing.md index 1212aa7098..8395f85d5a 100644 --- a/docs/api-reference/veryfront/testing.md +++ b/docs/api-reference/veryfront/testing.md @@ -35,8 +35,8 @@ describe("math", () => { | Name | Description | Source | | ------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `afterAll` | Register a hook after all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L759) | -| `afterEach` | Register a hook after each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L734) | +| `afterAll` | Register a hook after all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L765) | +| `afterEach` | Register a hook after each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L740) | | `assert` | Assert that a value is truthy. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L286) | | `assertEquals` | Assert that two values are deeply equal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L271) | | `assertExists` | Assert that a value is not null or undefined. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L291) | @@ -53,25 +53,25 @@ describe("math", () => { | `assertStrictEquals` | Assert that two values are strictly equal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L281) | | `assertStringIncludes` | Assert that a string contains another string. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L316) | | `assertThrows` | Assert that a synchronous function throws. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L296) | -| `beforeAll` | Register a hook before all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L744) | -| `beforeEach` | Register a hook before each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L725) | +| `beforeAll` | Register a hook before all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L750) | +| `beforeEach` | Register a hook before each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L731) | | `chmod` | Change file permissions, rejecting operational failures. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L599) | | `createFileSystem` | Create the runtime-native filesystem implementation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L449) | | `cwd` | Return the current working directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L26) | | `deepEquals` | ********************* Shared utility functions for cross-runtime testing. ********************* | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/utils.ts#L5) | | `delay` | Wait for a duration in milliseconds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L123) | -| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L334) | -| `describe` | Group related BDD tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L611) | +| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L338) | +| `describe` | Group related BDD tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L617) | | `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L62) | | `exists` | Return false for a missing path and propagate every other filesystem error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L522) | | `exit` | Exit the current process. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L128) | | `fail` | Fail the current assertion immediately. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L336) | | `getArgs` | Get command-line arguments (cross-runtime: Deno.args or process.argv). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L10) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L225) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L229) | | `getTestTimeScale` | Return the current test time scale. Preserved for compatibility. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L4) | | `isAlreadyExistsError` | Error shape for is already exists. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L628) | | `isNotFoundError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/not-found-error.ts#L210) | -| `it` | Define a BDD test case. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L667) | +| `it` | Define a BDD test case. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L673) | | `makeTempDir` | Atomically create a unique directory beneath the operating-system temp root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L594) | | `makeTempDirWithOptions` | Options accepted by make temp dir with. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L73) | | `makeTempFile` | Create temp file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L48) | @@ -84,7 +84,7 @@ describe("math", () => { | `resetAllTestState` | Comprehensive reset of ALL test state across the application. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/isolation.ts#L64) | | `safeStringify` | Serialize unknown values safely for test output. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/utils.ts#L34) | | `scaleMs` | Scale a duration for the current test runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L9) | -| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L305) | +| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L309) | | `stat` | Read file metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L527) | | `testDelay` | Wait for a test-scaled duration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L15) | | `waitFor` | Wait until a condition succeeds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L99) | @@ -98,8 +98,8 @@ describe("math", () => { | Name | Description | Source | | ---------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------- | -| `BddTestContext` | Context passed to hooks and tests (BDD-specific) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L33) | -| `TestOptions` | Test options for Deno sanitizers (ignored in Node/Bun) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L22) | +| `BddTestContext` | Context passed to hooks and tests (BDD-specific) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L34) | +| `TestOptions` | Test options for Deno sanitizers (ignored in Node/Bun) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L23) | ### Constants @@ -108,7 +108,7 @@ describe("math", () => { | `isBun` | True if running in Bun runtime (Bun also exposes process.versions.node). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/runtime.ts#L94) | | `isDeno` | True if running in the real Deno runtime rather than a dnt shim. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/runtime.ts#L103) | | `isNode` | True if running in Node.js rather than a more specific compatible host. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/runtime.ts#L100) | -| `test` | Shared test value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L774) | +| `test` | Shared test value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L780) | ## Deep imports @@ -154,23 +154,23 @@ import { afterAll, afterEach, beforeAll } from "veryfront/testing/bdd"; | Name | Description | Source | | ------------ | ------------------------------------------------ | --------------------------------------------------------------------------------------- | -| `afterAll` | Register a hook after all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L759) | -| `afterEach` | Register a hook after each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L734) | -| `beforeAll` | Register a hook before all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L744) | -| `beforeEach` | Register a hook before each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L725) | -| `describe` | Group related BDD tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L611) | -| `initBdd` | Initialize the BDD test adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L777) | -| `it` | Define a BDD test case. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L667) | +| `afterAll` | Register a hook after all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L765) | +| `afterEach` | Register a hook after each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L740) | +| `beforeAll` | Register a hook before all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L750) | +| `beforeEach` | Register a hook before each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L731) | +| `describe` | Group related BDD tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L617) | +| `initBdd` | Initialize the BDD test adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L783) | +| `it` | Define a BDD test case. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L673) | #### Types | Name | Description | Source | | ---------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------- | -| `BddTestContext` | Context passed to hooks and tests (BDD-specific) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L33) | -| `TestOptions` | Test options for Deno sanitizers (ignored in Node/Bun) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L22) | +| `BddTestContext` | Context passed to hooks and tests (BDD-specific) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L34) | +| `TestOptions` | Test options for Deno sanitizers (ignored in Node/Bun) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L23) | #### Constants | Name | Description | Source | | ------ | ------------------ | --------------------------------------------------------------------------------------- | -| `test` | Shared test value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L774) | +| `test` | Shared test value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L780) | diff --git a/src/platform/compat/process/env.test.ts b/src/platform/compat/process/env.test.ts index 69518c291d..364bf5379e 100644 --- a/src/platform/compat/process/env.test.ts +++ b/src/platform/compat/process/env.test.ts @@ -58,6 +58,60 @@ describe("host environment access", () => { } }); + denoOnlyIt("loads the testing overlay after the project environment facade", async () => { + const storageUrl = new URL("../../../server/project-env/storage.ts", import.meta.url).href; + const bddUrl = new URL("../../../testing/bdd.ts", import.meta.url).href; + const source = ` + const { runWithProjectEnv } = await import(${JSON.stringify(storageUrl)}); + await import(${JSON.stringify(bddUrl)}); + Deno.env.set("VF_LATE_TEST_ROOT", "root-value"); + const scoped = runWithProjectEnv({ VF_LATE_TEST_PROJECT: "project-value" }, () => ({ + root: Deno.env.get("VF_LATE_TEST_ROOT") ?? null, + project: Deno.env.get("VF_LATE_TEST_PROJECT") ?? null, + processRoot: process.env.VF_LATE_TEST_ROOT ?? null, + processProject: process.env.VF_LATE_TEST_PROJECT ?? null, + })); + console.log(JSON.stringify({ + root: Deno.env.get("VF_LATE_TEST_ROOT") ?? null, + processRoot: process.env.VF_LATE_TEST_ROOT ?? null, + scoped, + })); + `; + const child = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-env", + `--allow-read=${fromFileUrl(new URL("../../../../", import.meta.url))}`, + "-", + ], + clearEnv: true, + env: { DENO_TESTING: "1" }, + stdin: "piped", + stdout: "piped", + stderr: "piped", + }).spawn(); + const writer = child.stdin.getWriter(); + await writer.write(new TextEncoder().encode(source)); + await writer.close(); + const output = await child.output(); + const stderr = new TextDecoder().decode(output.stderr); + + assert(output.success, stderr); + assertEquals( + JSON.parse(new TextDecoder().decode(output.stdout).trim()), + { + root: "root-value", + processRoot: "root-value", + scoped: { + root: null, + project: "project-value", + processRoot: null, + processProject: "project-value", + }, + }, + ); + }); + denoOnlyIt("ignores forged test overlays when env permission is granted", async () => { const moduleUrl = new URL("./env.ts", import.meta.url).href; const source = ` diff --git a/src/platform/compat/process/env.ts b/src/platform/compat/process/env.ts index cd5b5340d9..c173d95f46 100644 --- a/src/platform/compat/process/env.ts +++ b/src/platform/compat/process/env.ts @@ -183,7 +183,8 @@ function installProjectScopedDenoEnv( if (denoEnvViewInstalled || !denoRuntime || !denoEnv) return; const descriptor = ObjectGetOwnPropertyDescriptor(denoRuntime, "env"); - const view = createProjectScopedDenoEnvView(denoEnv, getSnapshot); + const getOverlay = allowHostEnvTestOverlay ? getEnvOverlayStore : undefined; + const view = createProjectScopedDenoEnvView(denoEnv, getSnapshot, getOverlay); ObjectDefineProperty(denoRuntime, "env", { value: view, writable: false, @@ -213,7 +214,10 @@ export function registerTrustedProjectEnvSnapshot( installProjectScopedDenoEnv(getter); installProjectScopedDenoCommand(getter); _trustedProjectEnvSnapshot = getter; - installProjectScopedProcessEnv(getTrustedProjectEnvSnapshot); + installProjectScopedProcessEnv( + getTrustedProjectEnvSnapshot, + allowHostEnvTestOverlay ? getEnvOverlayStore : undefined, + ); } /** Return the active server-owned project env snapshot, if registered. */ diff --git a/src/platform/compat/process/scoped-process-env.ts b/src/platform/compat/process/scoped-process-env.ts index 0d59bc8541..047c907df2 100644 --- a/src/platform/compat/process/scoped-process-env.ts +++ b/src/platform/compat/process/scoped-process-env.ts @@ -39,6 +39,10 @@ import type { ProjectEnvSnapshot } from "./project-env-contract.ts"; type EnvRecord = Record; +type EnvOverlayStore = Map; + +/** Returns the active host-only test overlay, or null outside test execution. */ +export type EnvOverlayGetter = () => EnvOverlayStore | null; /** Returns the active project environment snapshot, or undefined outside one. */ export type ProjectEnvSnapshotGetter = () => ProjectEnvSnapshot | undefined; @@ -61,6 +65,7 @@ const ReflectOwnKeys = Reflect.ownKeys; const ReflectSet = Reflect.set; const ReflectSetPrototypeOf = Reflect.setPrototypeOf; const INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom"); +const testOverlayAwareDenoEnvViews = new WeakSet(); // Keyed by the snapshot object, which the scope owns for exactly its lifetime. const writesBySnapshot = new WeakMap(); @@ -74,6 +79,44 @@ export interface DenoEnvView { toObject(): Record; } +/** Whether a Deno environment view already composes with the supported test overlay. */ +export function isTestOverlayAwareDenoEnvView(value: unknown): boolean { + return typeof value === "object" && value !== null && testOverlayAwareDenoEnvViews.has(value); +} + +function readOverlay( + getOverlay: EnvOverlayGetter | undefined, + key: string, +): { found: boolean; value: string | undefined } { + const overlay = getOverlay?.(); + if (!overlay?.has(key)) return { found: false, value: undefined }; + return { found: true, value: overlay.get(key) ?? undefined }; +} + +function recordOverlay( + getOverlay: EnvOverlayGetter | undefined, + key: string, + value: string | null, +): boolean { + const overlay = getOverlay?.(); + if (!overlay) return false; + overlay.set(key, value); + return true; +} + +function applyOverlay( + record: Record, + getOverlay: EnvOverlayGetter | undefined, +): Record { + const overlay = getOverlay?.(); + if (!overlay) return record; + for (const [key, value] of overlay) { + if (value === null) delete record[key]; + else record[key] = value; + } + return record; +} + function writesFor(snapshot: ProjectEnvSnapshot): ScopedWrites { const existing = writesBySnapshot.get(snapshot); if (existing) return existing; @@ -145,6 +188,7 @@ export function deleteProjectScopedEnv( export function createProjectScopedDenoEnvView( hostEnv: DenoEnvView, getSnapshot: ProjectEnvSnapshotGetter, + getOverlay?: EnvOverlayGetter, ): DenoEnvView { const hostGet = hostEnv.get; const hostSet = hostEnv.set; @@ -155,13 +199,14 @@ export function createProjectScopedDenoEnvView( const view: DenoEnvView = { get(key) { const snapshot = getSnapshot(); - return snapshot === undefined - ? ReflectApply(hostGet, hostEnv, [key]) - : readScoped(snapshot, key); + if (snapshot !== undefined) return readScoped(snapshot, key); + const overlay = readOverlay(getOverlay, key); + return overlay.found ? overlay.value : ReflectApply(hostGet, hostEnv, [key]); }, set(key, value) { const snapshot = getSnapshot(); if (snapshot === undefined) { + if (recordOverlay(getOverlay, key, value)) return; ReflectApply(hostSet, hostEnv, [key, value]); return; } @@ -170,6 +215,7 @@ export function createProjectScopedDenoEnvView( delete(key) { const snapshot = getSnapshot(); if (snapshot === undefined) { + if (recordOverlay(getOverlay, key, null)) return; ReflectApply(hostDelete, hostEnv, [key]); return; } @@ -177,17 +223,18 @@ export function createProjectScopedDenoEnvView( }, has(key) { const snapshot = getSnapshot(); - return snapshot === undefined - ? ReflectApply(hostHas, hostEnv, [key]) - : readScoped(snapshot, key) !== undefined; + if (snapshot !== undefined) return readScoped(snapshot, key) !== undefined; + const overlay = readOverlay(getOverlay, key); + return overlay.found ? overlay.value !== undefined : ReflectApply(hostHas, hostEnv, [key]); }, toObject() { const snapshot = getSnapshot(); return snapshot === undefined - ? ReflectApply(hostToObject, hostEnv, []) + ? applyOverlay(ReflectApply(hostToObject, hostEnv, []), getOverlay) : projectScopedEnvRecord(snapshot); }, }; + if (getOverlay) testOverlayAwareDenoEnvViews.add(view); return ObjectFreeze(view); } @@ -270,6 +317,7 @@ function assertEnvDataDescriptor( function createHostViewHandler( hostEnv: EnvRecord, getSnapshot: ProjectEnvSnapshotGetter, + getOverlay?: EnvOverlayGetter, ): ProxyHandler { /** Resolve the snapshot for a string key, or undefined to defer to the host record. */ const scopeFor = (prop: string | symbol): ProjectEnvSnapshot | undefined => @@ -279,43 +327,87 @@ function createHostViewHandler( get(target, prop) { if (prop === INSPECT_CUSTOM) return ReflectGet(target, prop); const snapshot = scopeFor(prop); - if (!snapshot) return ReflectGet(hostEnv, prop); + if (!snapshot) { + if (typeof prop === "string") { + const overlay = readOverlay(getOverlay, prop); + if (overlay.found) return overlay.value; + } + return ReflectGet(hostEnv, prop); + } return readScoped(snapshot, prop as string); }, set(_target, prop, value) { const snapshot = scopeFor(prop); - if (!snapshot) return ReflectSet(hostEnv, prop, value); + if (!snapshot) { + if (typeof prop === "string" && recordOverlay(getOverlay, prop, String(value))) return true; + return ReflectSet(hostEnv, prop, value); + } recordScopedWrite(snapshot, prop as string, String(value)); return true; }, deleteProperty(_target, prop) { const snapshot = scopeFor(prop); - if (!snapshot) return ReflectDeleteProperty(hostEnv, prop); + if (!snapshot) { + if (typeof prop === "string" && recordOverlay(getOverlay, prop, null)) return true; + return ReflectDeleteProperty(hostEnv, prop); + } recordScopedWrite(snapshot, prop as string, null); return true; }, has(target, prop) { if (prop === INSPECT_CUSTOM) return ReflectHas(target, prop); const snapshot = scopeFor(prop); - if (!snapshot) return ReflectHas(hostEnv, prop); + if (!snapshot) { + if (typeof prop === "string") { + const overlay = readOverlay(getOverlay, prop); + if (overlay.found) return overlay.value !== undefined; + } + return ReflectHas(hostEnv, prop); + } return readScoped(snapshot, prop as string) !== undefined; }, ownKeys(_target) { const snapshot = getSnapshot(); - if (!snapshot) return ReflectOwnKeys(hostEnv); + if (!snapshot) { + const overlay = getOverlay?.(); + if (!overlay) return ReflectOwnKeys(hostEnv); + const keys = new Set(ReflectOwnKeys(hostEnv)); + for (const [key, value] of overlay) { + if (value === null) keys.delete(key); + else keys.add(key); + } + return [...keys]; + } return scopedKeys(snapshot); }, getOwnPropertyDescriptor(target, prop) { if (prop === INSPECT_CUSTOM) return ReflectGetOwnPropertyDescriptor(target, prop); const snapshot = scopeFor(prop); - if (!snapshot) return ReflectGetOwnPropertyDescriptor(hostEnv, prop); + if (!snapshot) { + if (typeof prop === "string") { + const overlay = readOverlay(getOverlay, prop); + if (overlay.found) { + return overlay.value === undefined + ? undefined + : { value: overlay.value, writable: true, enumerable: true, configurable: true }; + } + } + return ReflectGetOwnPropertyDescriptor(hostEnv, prop); + } const value = readScoped(snapshot, prop as string); if (value === undefined) return undefined; return { value, writable: true, enumerable: true, configurable: true }; }, defineProperty(_target, prop, descriptor) { const snapshot = scopeFor(prop); - if (!snapshot) return ReflectDefineProperty(hostEnv, prop, descriptor); + if (!snapshot) { + if (typeof prop === "string" && getOverlay?.()) { + assertEnvDataDescriptor(descriptor); + recordOverlay(getOverlay, prop, String(descriptor.value)); + return true; + } + return ReflectDefineProperty(hostEnv, prop, descriptor); + } assertEnvDataDescriptor(descriptor); recordScopedWrite(snapshot, prop as string, String(descriptor.value)); return true; @@ -344,6 +436,7 @@ function createHostViewHandler( export function createProjectScopedProcessEnvView( hostEnv: EnvRecord, getSnapshot: ProjectEnvSnapshotGetter, + getOverlay?: EnvOverlayGetter, ): EnvRecord { const target = ObjectCreate(ObjectGetPrototypeOf(hostEnv)) as EnvRecord; ObjectDefineProperty(target, "", { @@ -355,11 +448,13 @@ export function createProjectScopedProcessEnvView( ObjectDefineProperty(target, INSPECT_CUSTOM, { value: () => { const snapshot = getSnapshot(); - return snapshot ? projectScopedEnvRecord(snapshot) : { ...hostEnv }; + return snapshot + ? projectScopedEnvRecord(snapshot) + : applyOverlay({ ...hostEnv } as Record, getOverlay); }, configurable: true, }); - return new Proxy(target, createHostViewHandler(hostEnv, getSnapshot)); + return new Proxy(target, createHostViewHandler(hostEnv, getSnapshot, getOverlay)); } /** @@ -411,6 +506,7 @@ let installed = false; */ export function installProjectScopedProcessEnv( getSnapshot: ProjectEnvSnapshotGetter, + getOverlay?: EnvOverlayGetter, ): void { if (installed) return; @@ -419,7 +515,7 @@ export function installProjectScopedProcessEnv( if (!processLike || !hostEnv) return; installed = true; - const hostView = createProjectScopedProcessEnvView(hostEnv, getSnapshot); + const hostView = createProjectScopedProcessEnvView(hostEnv, getSnapshot, getOverlay); try { ObjectDefineProperty(processLike, "env", { get: () => hostView, diff --git a/src/security/README.md b/src/security/README.md index a27a352f75..10e0e8cdfb 100644 --- a/src/security/README.md +++ b/src/security/README.md @@ -419,8 +419,8 @@ A runtime that cannot honour a configured isolation flag never fakes it. A compiled binary cannot prepare isolated API route source (`security/sandbox/isolation-capability.ts`), so `WORKER_ISOLATION_API=1` in a compiled deployment keeps the requested isolation posture and API ownership -returns the typed `project-execution-unavailable` 503 naming it. The broad -`VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION` grant does not override the +returns the typed `project-execution-unavailable` 503 naming it. The deprecated +`VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION` setting does not override the API-specific isolation flag. OpenAPI metadata is currently attached to handler functions. Because reading @@ -435,29 +435,21 @@ dedicated single-project runtimes grant the capability at their host-owned entrypoints. Shared proxy runtimes reject these operations before reading or evaluating tenant modules. -## Operator-granted shared execution - -`VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION=1` grants the host-execution capability -to a shared runtime whose deployment intends that runtime to _be_ the project -executor. Absent and unrecognized values fail closed, matching -`VERYFRONT_HOST_ALLOW_INTERNAL_EGRESS`. - -The override is read exactly once, at server startup in -`server/production-server.ts`, and the resulting capability is fixed into the -handler for the process lifetime. It is read through `getHostEnv`, which -bypasses the project env overlay, so a project environment variable of the -same name cannot grant execution. Reading once at startup keeps a deployment's -posture fixed and declared in a single place. - -This is a deliberate posture, not a bypass. With the override set, tenant -project code is evaluated in the shared host process. Per-request separation is -the `runWithContext` source scope and the project-scoped registry transaction, -not a process, memory, or CPU boundary between tenants. Deno Workers do not -change that; they share the host process. - -Operators who need a genuine tenant boundary must leave the override unset and -route execution to an external or dedicated isolated project runtime. Unsetting -it re-arms every surface above with no code change. +## Deprecated shared execution override + +`VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION` no longer grants host execution. A +shared runtime always rejects same-process tenant execution and routes it to a +dedicated isolated project runtime. A dedicated single-project runtime already +has the required capability, so the setting changes no supported topology. + +Veryfront still reads an affirmative value at server startup so it can report +stale deployment configuration. The startup warning states that the setting is +ignored and explains whether the runtime is shared or already dedicated. The +read uses `getHostEnv`, so a project environment variable cannot manufacture +the operator diagnostic. + +Remove the deprecated setting. Shared multi-project execution must use a +separately limited external process or container. `WORKER_ISOLATION_SSR=1` additionally requires explicit registration of an `IsolatedSsrRendererProvider`. The provider supplies a local, offline renderer diff --git a/src/security/host-execution-policy.test.ts b/src/security/host-execution-policy.test.ts index 565e485244..09e9fd3892 100644 --- a/src/security/host-execution-policy.test.ts +++ b/src/security/host-execution-policy.test.ts @@ -5,10 +5,26 @@ import { withEnv } from "#veryfront/testing"; import { runWithProjectEnv } from "#veryfront/server/project-env/storage.ts"; import { HOST_PROJECT_EXECUTION_OVERRIDE_ENV, - isHostProjectExecutionOverrideEnabled, + isHostProjectExecutionOverrideConfigured, + resolveHostProjectExecutionPosture, } from "./host-execution-policy.ts"; describe("security/host-execution-policy operator override", () => { + it("treats the deprecated override as ignored in every runtime topology", () => { + assertEquals( + resolveHostProjectExecutionPosture({ sharedRuntime: true, overrideConfigured: true }), + { allowHostProjectCodeExecution: false, overrideIgnored: true }, + ); + assertEquals( + resolveHostProjectExecutionPosture({ sharedRuntime: false, overrideConfigured: true }), + { allowHostProjectCodeExecution: true, overrideIgnored: true }, + ); + assertEquals( + resolveHostProjectExecutionPosture({ sharedRuntime: false, overrideConfigured: false }), + { allowHostProjectCodeExecution: true, overrideIgnored: false }, + ); + }); + it("names the operator-owned environment variable", () => { assertEquals( HOST_PROJECT_EXECUTION_OVERRIDE_ENV, @@ -17,20 +33,20 @@ describe("security/host-execution-policy operator override", () => { ); }); - it("denies execution when the override is absent", () => { + it("reports the override as unconfigured when it is absent", () => { assertEquals( - isHostProjectExecutionOverrideEnabled(undefined), + isHostProjectExecutionOverrideConfigured(undefined), false, - "an unset override must leave the shared runtime denied", + "an unset override must be reported as unconfigured", ); }); - it("grants execution for accepted affirmative values", () => { + it("recognizes accepted affirmative values", () => { for (const value of ["1", "true", "yes", "on", " TRUE ", "On"]) { assertEquals( - isHostProjectExecutionOverrideEnabled(value), + isHostProjectExecutionOverrideConfigured(value), true, - `"${value}" should grant host project execution`, + `"${value}" should identify the deprecated override`, ); } }); @@ -38,9 +54,9 @@ describe("security/host-execution-policy operator override", () => { it("fails closed for negative, empty, and unrecognized values", () => { for (const value of ["", " ", "0", "false", "no", "off", "maybe", "2", "enabled"]) { assertEquals( - isHostProjectExecutionOverrideEnabled(value), + isHostProjectExecutionOverrideConfigured(value), false, - `"${value}" must not grant host project execution`, + `"${value}" must not identify an active override`, ); } }); @@ -51,7 +67,7 @@ describe("security/host-execution-policy operator override", () => { // never executed and the wiring could silently break. await withEnv({ [HOST_PROJECT_EXECUTION_OVERRIDE_ENV]: "1" }, () => { assertEquals( - isHostProjectExecutionOverrideEnabled(), + isHostProjectExecutionOverrideConfigured(), true, "the override must be readable from the host environment", ); @@ -60,7 +76,7 @@ describe("security/host-execution-policy operator override", () => { await withEnv({ [HOST_PROJECT_EXECUTION_OVERRIDE_ENV]: "0" }, () => { assertEquals( - isHostProjectExecutionOverrideEnabled(), + isHostProjectExecutionOverrideConfigured(), false, "a negative host value must fail closed", ); @@ -70,14 +86,14 @@ describe("security/host-execution-policy operator override", () => { it("uses the host environment rather than project env", async () => { // getHostEnv deliberately bypasses the project env overlay, so a project - // environment variable of the same name cannot grant host execution. A + // environment variable of the same name cannot configure the host. A // competing project scope has to be registered for that to mean anything. await withEnv({ [HOST_PROJECT_EXECUTION_OVERRIDE_ENV]: "0" }, () => { runWithProjectEnv({ [HOST_PROJECT_EXECUTION_OVERRIDE_ENV]: "1" }, () => { assertEquals( - isHostProjectExecutionOverrideEnabled(), + isHostProjectExecutionOverrideConfigured(), false, - "a project env value must never grant host-realm project execution", + "a project env value must never configure the host-owned override", ); }); return Promise.resolve(); @@ -86,9 +102,9 @@ describe("security/host-execution-policy operator override", () => { await withEnv({ [HOST_PROJECT_EXECUTION_OVERRIDE_ENV]: "1" }, () => { runWithProjectEnv({ [HOST_PROJECT_EXECUTION_OVERRIDE_ENV]: "0" }, () => { assertEquals( - isHostProjectExecutionOverrideEnabled(), + isHostProjectExecutionOverrideConfigured(), true, - "the host grant must still win while a project overlay is active", + "the host configuration must remain visible while a project overlay is active", ); }); return Promise.resolve(); diff --git a/src/security/host-execution-policy.ts b/src/security/host-execution-policy.ts index 215ff4e6fd..a2a0f467e2 100644 --- a/src/security/host-execution-policy.ts +++ b/src/security/host-execution-policy.ts @@ -1,21 +1,14 @@ /** - * Operator-owned host execution posture. + * Deprecated operator-owned host execution override. * * A shared multi-project runtime denies tenant code execution by default, and - * routes the request to a dedicated isolated project runtime instead. Some - * deployments intend the shared runtime to *be* the executor. Those operators - * grant the capability explicitly through this override at the host-owned - * entrypoint, where it is logged at startup. + * routes the request to a dedicated isolated project runtime instead. Dedicated + * single-project runtimes already carry the host execution capability. The + * former override therefore grants no additional topology and is retained only + * so startup can identify and report stale operator configuration. * - * The override does not weaken any other boundary. Worker permissions, outbound - * egress policy, and credential binding are unaffected; it only supplies the - * `allowHostProjectCodeExecution` capability that every execution surface - * already consults. - * - * Read this once at startup rather than per request. `getHostEnv` already - * bypasses the project env overlay, so a project variable of the same name - * cannot grant execution; reading once keeps the deployment's posture fixed - * for the process lifetime and visible in one place. + * `getHostEnv` bypasses the project env overlay, so a project variable of the + * same name cannot manufacture the diagnostic. */ import { getHostEnv } from "#veryfront/platform/compat/process.ts"; @@ -23,11 +16,10 @@ import { getHostEnv } from "#veryfront/platform/compat/process.ts"; export const HOST_PROJECT_EXECUTION_OVERRIDE_ENV = "VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION"; /** - * Read the operator override. Absent and unrecognized values fail closed, which - * matches `isInternalEgressOverrideEnabled` for - * `VERYFRONT_HOST_ALLOW_INTERNAL_EGRESS`. + * Detect the deprecated operator override. Absent and unrecognized values are + * treated as unconfigured. */ -export function isHostProjectExecutionOverrideEnabled( +export function isHostProjectExecutionOverrideConfigured( value: string | undefined = getHostEnv(HOST_PROJECT_EXECUTION_OVERRIDE_ENV), ): boolean { if (value === undefined) return false; @@ -41,3 +33,17 @@ export function isHostProjectExecutionOverrideEnabled( return false; } } + +/** @deprecated The override no longer enables host execution. */ +export const isHostProjectExecutionOverrideEnabled = isHostProjectExecutionOverrideConfigured; + +/** Resolve the only two supported production execution topologies. */ +export function resolveHostProjectExecutionPosture(options: { + sharedRuntime: boolean; + overrideConfigured: boolean; +}): { allowHostProjectCodeExecution: boolean; overrideIgnored: boolean } { + return { + allowHostProjectCodeExecution: !options.sharedRuntime, + overrideIgnored: options.overrideConfigured, + }; +} diff --git a/src/security/sandbox/isolation-posture.test.ts b/src/security/sandbox/isolation-posture.test.ts index ff79f5b684..87ff85f740 100644 --- a/src/security/sandbox/isolation-posture.test.ts +++ b/src/security/sandbox/isolation-posture.test.ts @@ -112,10 +112,11 @@ describe("security/sandbox isolation posture reporting", () => { assertEquals(posture.ssr.requested, false); assertEquals(posture.ssr.effective, false); assertEquals(posture.hostExecutionGranted, false); + assertEquals(posture.hostExecutionOverrideConfigured, false); assertEquals(posture.inForce, true); }); - it("keeps API isolation in force when preparation is unsupported and no grant exists", async () => { + it("keeps API isolation in force when preparation is unsupported", async () => { setEnv("WORKER_ISOLATION_ENABLED", "1"); setEnv("WORKER_ISOLATION_API", "1"); __setCompiledBinaryForTests(true); @@ -123,16 +124,17 @@ describe("security/sandbox isolation posture reporting", () => { const posture = getIsolationPosture(); - // Without an operator grant the flag stands and API ownership fails closed - // with a typed 503, so the surface stays effective rather than downgrading. + // The flag stands and API ownership fails closed with a typed 503, so the + // surface stays effective rather than downgrading. assertEquals(posture.api.requested, true); assertEquals(posture.api.effective, true); assertEquals(posture.apiPreparationSupported, false); assertEquals(posture.hostExecutionGranted, false); + assertEquals(posture.hostExecutionOverrideConfigured, false); assertEquals(posture.inForce, true); }); - it("keeps API isolation in force when preparation is unsupported under a grant", async () => { + it("keeps API isolation in force when the deprecated override is configured", async () => { setEnv("WORKER_ISOLATION_ENABLED", "1"); setEnv("WORKER_ISOLATION_API", "1"); setEnv(HOST_PROJECT_EXECUTION_OVERRIDE_ENV, "1"); @@ -144,7 +146,8 @@ describe("security/sandbox isolation posture reporting", () => { assertEquals(posture.api.requested, true); assertEquals(posture.api.effective, true); assertEquals(posture.apiPreparationSupported, false); - assertEquals(posture.hostExecutionGranted, true); + assertEquals(posture.hostExecutionGranted, false); + assertEquals(posture.hostExecutionOverrideConfigured, true); assertEquals(posture.inForce, true); }); diff --git a/src/security/sandbox/worker-pool.test.ts b/src/security/sandbox/worker-pool.test.ts index b01371e03a..103b1493b0 100644 --- a/src/security/sandbox/worker-pool.test.ts +++ b/src/security/sandbox/worker-pool.test.ts @@ -1629,7 +1629,7 @@ describe("Feature flag caching", () => { }); describe("when the runtime cannot prepare an isolated API module", () => { - it("keeps WORKER_ISOLATION_API set under a broad host-execution grant", async () => { + it("keeps WORKER_ISOLATION_API set with the deprecated override configured", async () => { Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); Deno.env.set(HOST_PROJECT_EXECUTION_OVERRIDE_ENV, "1"); diff --git a/src/security/sandbox/worker-pool.ts b/src/security/sandbox/worker-pool.ts index d9f1228bee..551ac2a27c 100644 --- a/src/security/sandbox/worker-pool.ts +++ b/src/security/sandbox/worker-pool.ts @@ -23,7 +23,7 @@ import { SECURITY_VIOLATION, SERVICE_OVERLOADED } from "#veryfront/errors"; import { basename, dirname, resolve as resolvePath } from "#veryfront/compat/path"; import { fromFileUrl, toFileUrl } from "#veryfront/compat/path"; import { isWithinDirectory } from "#veryfront/security/path-validation.ts"; -import { isHostProjectExecutionOverrideEnabled } from "#veryfront/security/host-execution-policy.ts"; +import { isHostProjectExecutionOverrideConfigured } from "#veryfront/security/host-execution-policy.ts"; import { resolve as resolveExtensionContract } from "#veryfront/extensions/contracts.ts"; import { IsolatedSsrRendererProviderName, @@ -1256,9 +1256,9 @@ export interface IsolationSurfacePosture { * The resolved isolation configuration, as an operator would need to read it. * * `requested` and `effective` are separate fields so posture remains explicit - * if a runtime capability changes. A host-execution grant never makes requested - * API isolation ineffective: unsupported runtimes keep the gate enabled and - * fail closed. `inForce` answers whether any surface is isolated at all. + * if a runtime capability changes. The deprecated host-execution override never + * makes requested API isolation ineffective: unsupported runtimes keep the gate + * enabled and fail closed. `inForce` answers whether any surface is isolated. */ export interface IsolationPosture { /** WORKER_ISOLATION_ENABLED. On its own it enables no surface. */ @@ -1272,8 +1272,10 @@ export interface IsolationPosture { * `api.effective` true, API routes fail closed rather than execute. */ apiPreparationSupported: boolean; - /** VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION. */ - hostExecutionGranted: boolean; + /** @deprecated Always false. The former override no longer grants execution. */ + hostExecutionGranted: false; + /** Whether the deprecated VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION setting is present. */ + hostExecutionOverrideConfigured: boolean; /** True when at least one surface actually resolved to isolated execution. */ inForce: boolean; } @@ -1281,8 +1283,8 @@ export interface IsolationPosture { /** * Resolve the host-owned isolation flags once per process. * - * A build that cannot honour `WORKER_ISOLATION_API` must fail closed. A broad - * host-execution grant does not override an API-specific isolation posture. + * A build that cannot honour `WORKER_ISOLATION_API` must fail closed. The + * deprecated host-execution override does not alter an API-specific posture. */ function resolveFlags(): void { if (_flagsResolved) return; @@ -1297,7 +1299,7 @@ function resolveFlags(): void { _ssrIsolation = master && ssrFlag; const preparationSupported = isIsolatedApiPreparationSupported(); - const hostExecutionGranted = isHostProjectExecutionOverrideEnabled(); + const hostExecutionOverrideConfigured = isHostProjectExecutionOverrideConfigured(); _apiIsolation = apiRequested; _flagsResolved = true; @@ -1309,7 +1311,8 @@ function resolveFlags(): void { data: { requested: _dataIsolation, effective: _dataIsolation }, ssr: { requested: _ssrIsolation, effective: _ssrIsolation }, apiPreparationSupported: preparationSupported, - hostExecutionGranted, + hostExecutionGranted: false, + hostExecutionOverrideConfigured, inForce: effectiveSurfaces > 0, }; @@ -1387,6 +1390,7 @@ export function getIsolationPosture(): IsolationPosture { ssr: { requested: false, effective: false }, apiPreparationSupported: isIsolatedApiPreparationSupported(), hostExecutionGranted: false, + hostExecutionOverrideConfigured: false, inForce: false, }; } diff --git a/src/server/handlers/request/api/project-discovery.ts b/src/server/handlers/request/api/project-discovery.ts index 2f63a5850b..d2ff976cc3 100644 --- a/src/server/handlers/request/api/project-discovery.ts +++ b/src/server/handlers/request/api/project-discovery.ts @@ -6,7 +6,6 @@ import { clearTrackedAgents, createProjectDiscoveryConfig } from "#veryfront/dis import { tryGetRegistryScopeContext } from "#veryfront/cache/cache-key-builder.ts"; import { runWithRegistryTransaction } from "#veryfront/registry/project-scoped-registry-manager.ts"; import { sanitizeUrlCredentials } from "#veryfront/utils/logger/redact.ts"; -import { HOST_PROJECT_EXECUTION_OVERRIDE_ENV } from "#veryfront/security/host-execution-policy.ts"; import { requiresIsolatedProjectRuntime } from "#veryfront/security/project-locality.ts"; import type { HandlerContext } from "../../types.ts"; @@ -145,8 +144,7 @@ export async function ensureProjectDiscovery(ctx: HandlerContext): Promise void | Promise; @@ -95,6 +96,11 @@ function installDenoEnvOverlayFacade(): void { if (globalAny["__vfTestDenoEnvOverlayFacadeInstalled"]) return; globalAny["__vfTestDenoEnvOverlayFacadeInstalled"] = true; + // The project environment facade composes this overlay itself. It is frozen + // so tenant code cannot replace its methods, and assigning here would make + // the public testing module depend on whether the server loaded first. + if (isTestOverlayAwareDenoEnvView(Deno.env)) return; + const originalDenoEnv = { get: Deno.env.get.bind(Deno.env), set: Deno.env.set.bind(Deno.env),