Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions src/server/handlers/execution-surface-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import "#veryfront/schemas/_test-setup.ts";
import { assertEquals } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { fromFileUrl } from "#veryfront/compat/path";

/**
* Guards the shared-runtime execution boundary against silent drift.
*
* Every surface that refuses to run tenant project code must ask the same
* question: `requiresIsolatedProjectRuntime(ctx)`, which refuses only when the
* runtime is shared *and* the host did not grant execution. Asking the
* narrower `isSharedProjectRuntime(ctx)` instead denies hosts that were
* explicitly granted the capability.
*
* That drift has now happened twice. veryfront-code#3364 converted three
* surfaces and left three behind; the survivors were invisible because the
* adapter Proxy fixed in #3378 made the predicate answer `false` for exactly
* the remote-filesystem projects it governs, so the gate never fired anywhere
* it was wrong. Once the Proxy was corrected, markdown preview started
* returning 503 on staging (veryfront-issue-inbox#376, #366).
*
* Per-handler tests cannot catch this, because each one is individually
* consistent. Only an inventory across surfaces can, so this test is the
* inventory. It reads source rather than behaviour deliberately: a behavioural
* sweep can only cover the surfaces someone remembered to add to it, whereas
* an unlisted file here is a failure by construction.
*/

const HANDLERS_DIR = fromFileUrl(new URL(".", import.meta.url));

/** Surfaces that gate tenant code execution. These must honour the capability. */
const CAPABILITY_GATED_SURFACES = [
"preview/markdown-preview.handler.ts",
"request/api/api-handler-wrapper.ts",
"request/api/app-router-handler.ts",
"request/api/project-discovery.ts",
"request/module/module.handler.ts",
"request/snippet.handler.ts",
"request/ssr/ssr.handler.ts",
].toSorted();

/**
* Files that legitimately read the narrower predicate because they are not
* execution gates. Each needs a reason, because "it compiles" is how the
* original drift got in.
*/
const NON_GATE_USES: Record<string, string> = {
"response/cors.ts":
"Chooses which CORS methods to advertise. Degrades to defaults on a shared runtime rather than denying, so the capability does not apply.",
};

async function readHandlerSources(): Promise<Map<string, string>> {
const sources = new Map<string, string>();

async function walk(relativeDir: string): Promise<void> {
for await (const entry of Deno.readDir(`${HANDLERS_DIR}${relativeDir}`)) {
const relativePath = `${relativeDir}${entry.name}`;
if (entry.isDirectory) {
await walk(`${relativePath}/`);
continue;
}
if (!entry.name.endsWith(".ts") || entry.name.endsWith(".test.ts")) continue;
if (entry.name.endsWith(".test-helpers.ts")) continue;
sources.set(relativePath, await Deno.readTextFile(`${HANDLERS_DIR}${relativePath}`));
}
}

await walk("");
return sources;
}

/** Read the tree once so every assertion inspects the same snapshot. */
let cachedSources: Promise<Map<string, string>> | undefined;
function handlerSources(): Promise<Map<string, string>> {
cachedSources ??= readHandlerSources();
return cachedSources;
}

/** Strip imports first, so only real call sites count. */
function callsPredicate(source: string, predicate: string): boolean {
const body = source
.split("\n")
.filter((line) => !line.trim().startsWith("import "))
.join("\n");
return new RegExp(`\\b${predicate}\\s*\\(`).test(body);
}

describe("server/handlers shared-runtime execution boundary", () => {
it("gates every execution surface on the capability, not on sharedness alone", async () => {
const sources = await handlerSources();
const drifted: string[] = [];

for (const [path, source] of sources) {
if (!callsPredicate(source, "isSharedProjectRuntime")) continue;
if (path in NON_GATE_USES) continue;
drifted.push(path);
}

assertEquals(
drifted.toSorted(),
[],
`These files call isSharedProjectRuntime() directly. If a file gates tenant code ` +
`execution it must call requiresIsolatedProjectRuntime() instead, so a host that ` +
`was granted allowHostProjectCodeExecution is served. If it is not an execution ` +
`gate, add it to NON_GATE_USES with a reason.`,
);
});

it("keeps the capability-gated inventory accurate", async () => {
const sources = await handlerSources();

const missing = CAPABILITY_GATED_SURFACES.filter((path) => {
const source = sources.get(path);
return !source || !callsPredicate(source, "requiresIsolatedProjectRuntime");
});

assertEquals(
missing,
[],
`These surfaces are listed as capability-gated but no longer call ` +
`requiresIsolatedProjectRuntime(). Either restore the call or remove the entry ` +
`deliberately. Silently dropping the gate is how a surface stops being enforced.`,
);

const unlisted = [...sources.keys()]
.filter((path) => callsPredicate(sources.get(path)!, "requiresIsolatedProjectRuntime"))
.filter((path) => !CAPABILITY_GATED_SURFACES.includes(path))
.toSorted();

assertEquals(
unlisted,
[],
`New execution surfaces found. Add them to CAPABILITY_GATED_SURFACES and give each ` +
`a paired fail-closed and granted-path test. A fail-closed test alone cannot ` +
`distinguish a correct predicate from a hardcoded denial.`,
);
});

it("documents why each non-gate use of the narrow predicate is safe", async () => {
const sources = await handlerSources();
const stale = Object.keys(NON_GATE_USES).filter((path) => {
const source = sources.get(path);
return !source || !callsPredicate(source, "isSharedProjectRuntime");
});

assertEquals(
stale,
[],
"NON_GATE_USES lists files that no longer call isSharedProjectRuntime(). Remove them.",
);
});
});
63 changes: 62 additions & 1 deletion src/server/handlers/preview/markdown-preview.handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import "#veryfront/schemas/_test-setup.ts";
import { assertEquals } from "#veryfront/testing/assert.ts";
import { assertEquals, assertNotEquals } 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";
Expand Down Expand Up @@ -99,6 +99,67 @@ Deno.test("MarkdownPreviewHandler fails closed before shared source reads", asyn
assertEquals(reads, 0);
});

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.
let reads = 0;
const ctx = {
projectDir: "/remote/project",
projectSlug: "project",
proxyToken: "token",
isLocalProject: false,
requestContext: { mode: "preview" },
adapter: {
fs: {
symlinkSemantics: "none" as const,
isMultiProjectMode: () => true,
isContextualMode: () => true,
runWithContext: async (
_slug: string,
_token: string,
fn: () => Promise<unknown>,
) => await fn(),
exists: () => Promise.resolve(true),
stat: () =>
Promise.resolve({
isFile: true,
isDirectory: false,
isSymlink: false,
size: 0,
mtime: new Date(),
}),
readFile: () => {
reads++;
return Promise.resolve("# Readme\n");
},
},
},
securityConfig: null,
cspUserHeader: null,
allowHostProjectCodeExecution: true,
} as unknown as HandlerContext;

const result = await new MarkdownPreviewHandler().handle(
new Request("https://tenant.example/README.md"),
ctx,
);

assertNotEquals(
result.response?.status,
503,
"a granted shared executor must not return project-execution-unavailable",
);
// 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");
});
});

Deno.test("MarkdownPreviewHandler admits and reads through a real wrapped GitHub adapter", async () => {
const originalFetch = globalThis.fetch;
let contentReads = 0;
Expand Down
4 changes: 2 additions & 2 deletions src/server/handlers/preview/markdown-preview.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { extract } from "#std/front-matter/yaml.ts";
import { tryNotFoundFallback } from "../request/ssr/not-found-fallback.ts";
import { generateMarkdownHtml } from "./markdown-html-generator.ts";
import { validateLexicalPath, validatePath, ValidationPresets } from "#veryfront/security";
import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts";
import { requiresIsolatedProjectRuntime } from "#veryfront/security/project-locality.ts";
import {
createErrorResponseFromDefinition,
PROJECT_EXECUTION_UNAVAILABLE,
Expand Down Expand Up @@ -48,7 +48,7 @@ export class MarkdownPreviewHandler extends BaseHandler {
return this.continue();
}

if (isSharedProjectRuntime(ctx)) {
if (requiresIsolatedProjectRuntime(ctx)) {
const problem = createErrorResponseFromDefinition(
PROJECT_EXECUTION_UNAVAILABLE,
{
Expand Down
43 changes: 43 additions & 0 deletions src/server/handlers/request/module/module.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,49 @@ describe("server/handlers/request/module/module.handler", () => {
assertEquals(rendererCalls, 0);
});

it("serves the endpoints once the host grants execution", async () => {
// The granted counterpart to the fail-closed test above. A handler that
// denies every shared runtime unconditionally, which is what this surface
// did before veryfront-issue-inbox#366, passes that test and fails this
// one. Without the pair, the two are indistinguishable.
const handler = new ModuleHandler();
for (
const pathname of [
"/_veryfront/modules/runtime.js",
"/_veryfront/pages/page.js",
"/_veryfront/data/page.json",
"/_veryfront/page-data/page.json",
]
) {
const result = await handler.handle(
new Request(`https://tenant.example${pathname}`),
makeCtx({
isLocalProject: false,
allowHostProjectCodeExecution: true,
} as Partial<HandlerContext>),
);
// `continue: false` matters as much as the absent 503. Without it a
// handler that fell through entirely, emitting no response at all,
// would satisfy "did not return project-execution-unavailable".
assertEquals(
result.continue,
false,
`${pathname} fell through instead of serving a granted host`,
);
const type = result.response
? await result.response.clone().json().then(
(body: { type?: string }) => body.type,
() => undefined,
)
: undefined;
assertEquals(
type === "https://veryfront.com/docs/errors/project-execution-unavailable",
false,
`${pathname} denied execution to a granted host`,
);
}
});

it("returns an empty fail-closed response for HEAD", async () => {
const result = await new ModuleHandler().handle(
new Request("https://tenant.example/_veryfront/page-data/page.json", {
Expand Down
15 changes: 10 additions & 5 deletions src/server/handlers/request/module/module.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
createErrorResponseFromDefinition,
PROJECT_EXECUTION_UNAVAILABLE,
} from "#veryfront/errors";
import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts";
import { requiresIsolatedProjectRuntime } from "#veryfront/security/project-locality.ts";

const MODULE_ENDPOINT_PREFIXES = [
"/_vf_modules/",
Expand Down Expand Up @@ -69,11 +69,16 @@ export class ModuleHandler extends BaseHandler {
}

// These endpoints delegate to the legacy renderer, whose module loader
// imports page and layout code in the host process. Remote source must not
// reach that path until rendering has a generation-owned prepared module
// graph equivalent to isolated API routes.
// imports page and layout code in the host process rather than through a
// generation-owned prepared module graph.
//
// That is a renderer-architecture concern, not a policy one, so it does not
// decide who may execute tenant code: the host-execution capability does.
// A host that grants the capability is asserting it is a suitable executor,
// and this surface honours that like every other. `rsc/endpoints/
// endpoint-router.ts` already resolved the identical tension the same way.
if (
isSharedProjectRuntime(ctx) &&
requiresIsolatedProjectRuntime(ctx) &&
HOST_RENDERER_ENDPOINT_PREFIXES.some((prefix) => pathname.startsWith(prefix))
) {
const problem = createErrorResponseFromDefinition(
Expand Down
Loading