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
2 changes: 1 addition & 1 deletion docs/api-reference/veryfront/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ applySecurityHeaders(response.headers, false, generateNonce(), null);
| ---------------------- | ----------- | ---------------------------------------------------------------------------------------------------------- |
| `AuthHandler` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/auth.ts#L156) |
| `BaseHandler` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/base-handler.ts#L45) |
| `CsrfHandler` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/csrf/csrf-handler.ts#L56) |
| `CsrfHandler` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/csrf/csrf-handler.ts#L57) |
| `ResponseBuilder` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/builder.ts#L9) |
| `SecureFs` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/secure-fs.ts#L645) |
| `SecurityConfigLoader` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/config.ts#L292) |
Expand Down
71 changes: 71 additions & 0 deletions src/channels/control-plane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,77 @@ export const CONTROL_PLANE_RUN_STREAM_PATH = "/api/control-plane/runs/:runId/str
const CONTROL_PLANE_RUN_ID_PATH_SEGMENT = "[^/]+";
const CONTROL_PLANE_RUNS_REGEX_PREFIX = CONTROL_PLANE_RUNS_PATH_PREFIX.replaceAll("/", "\\/");

/** Request header the control plane carries its signed operation envelope in. */
export const CONTROL_PLANE_JWS_HEADER = "x-veryfront-control-plane-jws";

const CONTROL_PLANE_RUN_OPERATION_PATH =
/^\/api\/control-plane\/runs\/[^/]+\/(?:execute|stream|resume)$/u;
const CONTROL_PLANE_RUN_PATH = /^\/api\/control-plane\/runs\/[^/]+$/u;

/**
* True when a method and path pair addresses a registered control-plane handler.
*
* The reserved namespace is wider than the set of routes the runtime actually
* serves. Only these shapes reach a handler that authenticates a signed
* operation envelope through `verifyControlPlaneRequest`:
*
* - `POST /api/control-plane/agents/list`
* - `POST /api/control-plane/runs/{runId}/execute`
* - `POST /api/control-plane/runs/{runId}/stream`
* - `POST /api/control-plane/runs/{runId}/resume`
* - `DELETE /api/control-plane/runs/{runId}`
*
* Any other path under the prefix falls through to project code, so treating
* the prefix as proof of a control-plane request would hand a project's own
* routes whatever exemption the caller grants.
*
* Match this against `URL.pathname`, which resolves dot segments, so a path
* cannot be smuggled past the anchored patterns.
*/
export function isControlPlaneSurfaceRoute(
method: string,
pathname: string | undefined,
): boolean {
const normalizedMethod = method.toUpperCase();
const requestPath = pathname ?? "";

if (normalizedMethod === "POST") {
return requestPath === CONTROL_PLANE_AGENTS_LIST_PATH ||
CONTROL_PLANE_RUN_OPERATION_PATH.test(requestPath);
}
if (normalizedMethod === "DELETE") {
return CONTROL_PLANE_RUN_PATH.test(requestPath);
}
return false;
}

/**
* True for a request that is a control-plane dispatch rather than a browser one.
*
* Both conditions must hold. The method and path must address a registered
* control-plane handler (see {@link isControlPlaneSurfaceRoute}), and the
* request must carry a control-plane signature header. The receiving handler
* verifies that envelope against the dispatch signing key, and the signature
* covers the request method and path, so an envelope minted for one surface
* cannot be replayed against another.
*
* Callers use this to keep gates that assume a browser client, such as CSRF
* double-submit validation, from standing in front of platform dispatch. A
* browser cannot attach the signature header to a cross-origin request without
* a preflight the runtime does not grant, so a forged cross-site request never
* satisfies this predicate. Neither does a project route that merely sits at a
* look-alike path.
*
* This is not authentication. It only reports that authority for the request
* comes from a signature the handler checks, never from ambient credentials.
*/
export function isSignedControlPlaneDispatch(req: Request): boolean {
const signature = req.headers.get(CONTROL_PLANE_JWS_HEADER);
if (signature === null || signature.length === 0) return false;

return isControlPlaneSurfaceRoute(req.method, new SafeURL(req.url).pathname);
}

/**
* True for control-plane run surfaces that can dispatch without project config.
*
Expand Down
19 changes: 7 additions & 12 deletions src/proxy/control-plane-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

import { getHostEnv } from "#veryfront/platform/compat/process.ts";
import {
isControlPlaneSurfaceRoute,
verifyControlPlaneJwsRequestSignature,
verifyControlPlaneJwsSignature,
verifyDispatchJwsSignature,
Expand Down Expand Up @@ -60,13 +61,14 @@ const MAX_BRANCH_NAME_CODE_UNITS = 255;

export type InternalControlPlaneRouteKind = "dispatch" | "control-plane" | "reserved" | "public";

const CONTROL_PLANE_RUN_OPERATION_PATH =
/^\/api\/control-plane\/runs\/[^/]+\/(?:execute|stream|resume)$/u;
const CONTROL_PLANE_RUN_PATH = /^\/api\/control-plane\/runs\/[^/]+$/u;

/**
* Classify the internal namespace against routes whose handlers always perform
* authoritative downstream JWS verification.
*
* `control-plane` and `reserved` differ in exactly the way that matters to a
* caller deciding what to trust: `control-plane` names a route the runtime
* serves through a verifying handler, `reserved` names the rest of the
* namespace, which a project can occupy with its own routes.
*/
export function classifyInternalControlPlaneRequest(
method: string,
Expand All @@ -76,14 +78,7 @@ export function classifyInternalControlPlaneRequest(
if (pathname === "/channels/invoke" && normalizedMethod === "POST") {
return "dispatch";
}
if (
normalizedMethod === "POST" &&
(pathname === "/api/control-plane/agents/list" ||
CONTROL_PLANE_RUN_OPERATION_PATH.test(pathname))
) {
return "control-plane";
}
if (normalizedMethod === "DELETE" && CONTROL_PLANE_RUN_PATH.test(pathname)) {
if (isControlPlaneSurfaceRoute(normalizedMethod, pathname)) {
return "control-plane";
}

Expand Down
159 changes: 159 additions & 0 deletions src/release-assets/build-dispatch-security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Regression: a project that configures `security.csrf` must still be able to
* publish a release asset manifest.
*
* The release asset manifest is built by the project runtime, not by the CLI:
* the control plane POSTs a signed operation envelope to
* `/api/control-plane/runs/{runId}/execute` with `target:
* "task:release-asset-build"`, and only that dispatch calls
* `beginReleaseAssetManifestBuild`. Until it lands, the manifest does not
* exist, and `veryfront deploy` reports
* `Release assets were not ready within 120s (last state: missing)`.
*
* That dispatch is a POST, and it carries a JWS envelope rather than a CSRF
* double-submit token. The control plane is not a browser and has no
* `__Host-vf_csrf` cookie to echo. `CsrfHandler` runs at priority 5, ahead of
* `ProjectRunExecuteHandler`, and matches every path, so a project whose
* config enables CSRF at all turns its own release asset builds into 403s.
* Nothing downstream can see it: the run fails before the manifest row is
* created, and the deploy timeout names neither CSRF nor config.
*
* @module release-assets/build-dispatch-security.test
*/

import "#veryfront/schemas/_test-setup.ts";
import { assertEquals } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { RouteRegistry } from "#veryfront/routing/registry/index.ts";
import { CsrfHandler } from "#veryfront/security/http/csrf/csrf-handler.ts";
import { deriveSecurityContext } from "#veryfront/security/http/config.ts";
import type { VeryfrontConfig } from "#veryfront/config";
import {
ProjectRunExecuteHandler,
type ProjectRunExecuteHandlerDeps,
} from "#veryfront/server/handlers/request/project-run-execute.handler.ts";
import {
createControlPlaneSignature,
createCtx,
} from "#veryfront/server/handlers/request/internal-agent-run.test-helpers.ts";

const RUN_ID = "run_release_assets_1";
const EXECUTE_PATH = `/api/control-plane/runs/${RUN_ID}/execute`;

type CsrfSetting = VeryfrontConfig["security"] extends infer S
? S extends { csrf?: infer C } ? C : never
: never;

interface DispatchOutcome {
/** Whether the release asset build executor was reached at all. */
readonly begun: boolean;
readonly status: number;
readonly body: string;
}

/**
* Drive the real handler chain the runtime uses for a control-plane run
* dispatch: the security handlers first, then the run executor.
*/
async function dispatchReleaseAssetBuild(
csrf: CsrfSetting | undefined,
): Promise<DispatchOutcome> {
const config = {
security: csrf === undefined ? {} : { csrf },
} as VeryfrontConfig;
const { securityConfig } = deriveSecurityContext(config, {
// The task rail dispatches to the project's main-branch runtime, which
// resolves as a preview environment. Production defaults are therefore off
// and only an explicit `security.csrf` can enable the gate here.
productionDefaults: false,
});

const body = {
runId: RUN_ID,
kind: "task",
target: "task:release-asset-build",
projectId: "proj-1",
config: { release_id: "rel-1", release_version: 1 },
};
const rawBody = JSON.stringify(body);
const { jws, publicKeyPem } = await createControlPlaneSignature(rawBody, {
requestId: RUN_ID,
projectId: "proj-1",
requestMethod: "POST",
requestPath: EXECUTE_PATH,
});
const request = new Request(`https://example-project.example.test${EXECUTE_PATH}`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-veryfront-control-plane-jws": jws,
},
body: rawBody,
});

let begun = false;
const deps = {
executeReleaseAssetBuild: () => {
// Stands in for `runReleaseAssetBuild`, whose first act is
// `beginReleaseAssetManifestBuild`. Reaching it is what moves the
// manifest off `missing`.
begun = true;
return Promise.resolve({
success: true,
result: { state: "ready", moduleCount: 1, cssCount: 1, routeCount: 1 },
logs: null,
duration_ms: 10,
});
},
now: () => 0,
} as unknown as ProjectRunExecuteHandlerDeps;

const ctx = createCtx(publicKeyPem);
ctx.securityConfig = securityConfig;

const registry = new RouteRegistry();
registry.registerAll([new CsrfHandler(), new ProjectRunExecuteHandler(deps)]);

const response = await registry.execute(request, ctx);
return {
begun,
status: response?.status ?? 0,
body: response ? await response.text() : "",
};
}

describe("release assets: control-plane build dispatch", () => {
it("builds a manifest when the project leaves csrf unset", async () => {
const outcome = await dispatchReleaseAssetBuild(undefined);
assertEquals(outcome.status, 200);
assertEquals(outcome.begun, true);
});

it("builds a manifest when the project disables csrf", async () => {
const outcome = await dispatchReleaseAssetBuild(false);
assertEquals(outcome.status, 200);
assertEquals(outcome.begun, true);
});

it("builds a manifest when the project enables csrf with a boolean", async () => {
const outcome = await dispatchReleaseAssetBuild(true);
assertEquals(
outcome.begun,
true,
`release asset build never started; runtime answered ${outcome.status}: ${outcome.body}`,
);
assertEquals(outcome.status, 200);
});

it("builds a manifest when the project excludes a path from csrf", async () => {
// The shape that first surfaced this: keep CSRF enforced everywhere except
// the agent endpoint the chat client posts to.
const outcome = await dispatchReleaseAssetBuild({ excludePaths: ["/api/ag-ui"] });
assertEquals(
outcome.begun,
true,
`release asset build never started; runtime answered ${outcome.status}: ${outcome.body}`,
);
assertEquals(outcome.status, 200);
});
});
Loading