feat(FN-5375): enforce manifest-gated cloudflared checksum installation

Added manifest-gated checksum verification for cloudflared remote access tunnels: a pinned manifest validator (Step 1) and enforcement logic (Step 2) wired into the settings memory routes, with aligned tests and documentation covering fail-closed install behavior and pending-manifest guidance.

Fusion-Task-Id: FN-5375
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 18:42:00 -07:00
committed by gsxdsm
parent e814ba9508
commit dbccdb1275
5 changed files with 307 additions and 13 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Harden cloudflared auto-install (dashboard remote-access opt-in flow): add a pinned release manifest and SHA-256 verification, mirroring the FN-5320 Worktrunk pattern. Auto-download fails closed in `upstream-pending-verification` mode; package-manager paths (`brew`, `winget`) are unchanged. Replaces the previous unverified `releases/latest/download` direct-curl install path.

View File

@@ -70,7 +70,7 @@ Cloudflare **Quick Tunnel** startup gates (`quickTunnel = true`):
No Cloudflare account, tunnel token, named tunnel, or pre-created ingress URL is required.
Dashboard note: in Settings → Remote Access, selecting Cloudflare now performs a proactive `cloudflared` CLI detection check and shows a one-click **Install cloudflared** action (with manual command fallback) if the binary is missing.
Dashboard note: in Settings → Remote Access, selecting Cloudflare performs a proactive `cloudflared` CLI detection check and shows a one-click **Install cloudflared** action (with manual command fallback) if the binary is missing. The direct-download path is pinned-manifest gated: until maintainers flip the shipped manifest from `upstream-pending-verification` to a verified tagged release with per-asset `.sha256` sidecars, auto-download fails closed and the UI surfaces package-manager/manual fallback commands.
Runtime command used by engine:

View File

@@ -588,7 +588,7 @@ The canonical persisted shape is a nested `remoteAccess` object.
Use **[Remote Access runbook](./remote-access.md)** for setup prerequisites (Tailscale/Cloudflare), tokenized login-link security caveats, and operational troubleshooting. Keep this section as a schema reference.
When `remoteAccess.activeProvider` is `cloudflare`, the Settings UI fetches `/api/remote/status` and surfaces `cloudflaredAvailable` to show installed/missing state plus a one-click `POST /api/remote/install-cloudflared` action.
When `remoteAccess.activeProvider` is `cloudflare`, the Settings UI fetches `/api/remote/status` and surfaces `cloudflaredAvailable` to show installed/missing state plus a one-click `POST /api/remote/install-cloudflared` action. That endpoint preserves package-manager installs (`brew`, `winget`) and gates direct binary download behind a pinned manifest: default `upstream-pending-verification` mode fails closed until maintainers populate verified tagged-release URLs and sha256 sidecars.
When `remoteAccess.activeProvider` is `tailscale` and the Fusion-managed tunnel is stopped, `/api/remote/status` also returns `externalTunnel` when a pre-existing funnel is detected. The UI exposes two actions: **Use Existing** (start Fusion tunnel lifecycle against the existing funnel) and **Start Fresh** (`POST /api/remote/tunnel/kill-external` then start).

View File

@@ -15,7 +15,13 @@ vi.mock("node:child_process", async (importOriginal) => {
};
});
import { writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { createApiRoutes } from "../routes.js";
import {
__setCloudflaredManifestForTesting,
validateCloudflaredManifest,
} from "../routes/register-settings-memory-routes.js";
import { request as performRequest } from "../test-request.js";
function buildRemoteAccessSettings(overrides: Record<string, unknown> = {}) {
@@ -106,6 +112,7 @@ function setProcessRuntime(platform: NodeJS.Platform, arch: string): void {
}
afterEach(() => {
__setCloudflaredManifestForTesting(null);
if (originalPlatformDescriptor) {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
@@ -308,6 +315,18 @@ describe("remote access provider/lifecycle contracts", () => {
expect(result.body).toEqual({ ok: true });
});
const makeVerifiedManifest = (assetName: string, sha256: string) => ({
source: "upstream-verified" as const,
version: "2026.5.0",
verifiedAt: "2026-05-20T00:00:00.000Z",
assets: {
[assetName]: {
url: `https://github.com/cloudflare/cloudflared/releases/download/2026.5.0/${assetName}`,
sha256,
},
},
});
it("installs cloudflared via endpoint and returns install command metadata", async () => {
setProcessRuntime("linux", "x64");
const { app } = createApp();
@@ -316,10 +335,11 @@ describe("remote access provider/lifecycle contracts", () => {
expect(result.status).toBe(200);
expect(result.body).toEqual(expect.objectContaining({
success: true,
success: false,
command: expect.stringContaining("cloudflared-linux-amd64"),
error: expect.stringContaining("upstream-pending-verification"),
}));
expect(mockExecFile.mock.calls.some(([command, args]) => command === "curl" && Array.isArray(args) && String(args[3]).includes("cloudflared-linux-amd64"))).toBe(true);
expect(mockExecFile.mock.calls.every(([command]) => command !== "curl")).toBe(true);
});
it("uses arm64 cloudflared binary on Linux arm64", async () => {
@@ -330,18 +350,25 @@ describe("remote access provider/lifecycle contracts", () => {
expect(result.status).toBe(200);
expect(result.body).toEqual(expect.objectContaining({
success: true,
success: false,
command: expect.stringContaining("cloudflared-linux-arm64"),
error: expect.stringContaining("upstream-pending-verification"),
}));
expect(mockExecFile.mock.calls.some(([command, args]) => command === "curl" && Array.isArray(args) && String(args[3]).includes("cloudflared-linux-arm64"))).toBe(true);
expect(mockExecFile.mock.calls.every(([command]) => command !== "curl")).toBe(true);
});
it("falls back to ~/.local/bin when /usr/local/bin move fails with permission error", async () => {
setProcessRuntime("linux", "x64");
const payload = Buffer.from("cloudflared-ok");
const sha256 = createHash("sha256").update(payload).digest("hex");
__setCloudflaredManifestForTesting(makeVerifiedManifest("cloudflared-linux-amd64", sha256));
mockExecFile.mockImplementation((command: string, args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
const callback = typeof optionsOrCallback === "function"
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
: maybeCallback;
if (command === "curl") {
writeFileSync("/tmp/cloudflared", payload);
}
if (command === "mv" && args[1] === "/usr/local/bin/cloudflared") {
callback?.(new Error("EPERM"), "", "EPERM");
return;
@@ -360,6 +387,9 @@ describe("remote access provider/lifecycle contracts", () => {
it("falls back to direct download on macOS when brew is unavailable", async () => {
setProcessRuntime("darwin", "arm64");
const payload = Buffer.from("cloudflared-darwin");
const sha256 = createHash("sha256").update(payload).digest("hex");
__setCloudflaredManifestForTesting(makeVerifiedManifest("cloudflared-darwin-arm64", sha256));
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
const callback = typeof optionsOrCallback === "function"
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
@@ -368,6 +398,9 @@ describe("remote access provider/lifecycle contracts", () => {
callback?.(new Error("brew not found"), "", "brew not found");
return;
}
if (command === "curl") {
writeFileSync("/tmp/cloudflared", payload);
}
callback?.(null, "", "");
});
@@ -375,16 +408,15 @@ describe("remote access provider/lifecycle contracts", () => {
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
expect(result.status).toBe(200);
expect(result.body).toEqual(expect.objectContaining({
success: true,
command: expect.stringContaining("cloudflared-darwin-arm64"),
}));
expect(result.body).toEqual(expect.objectContaining({ success: true, command: expect.stringContaining("cloudflared-darwin-arm64") }));
expect(mockExecFile.mock.calls.some(([command]) => command === "brew")).toBe(false);
expect(mockExecFile.mock.calls.some(([command, args]) => command === "curl" && Array.isArray(args) && String(args[3]).includes("cloudflared-darwin-arm64"))).toBe(true);
});
it("returns install failure details when cloudflared installation command fails", async () => {
setProcessRuntime("linux", "x64");
const sha256 = "a".repeat(64);
__setCloudflaredManifestForTesting(makeVerifiedManifest("cloudflared-linux-amd64", sha256));
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
const callback = typeof optionsOrCallback === "function"
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
@@ -406,4 +438,106 @@ describe("remote access provider/lifecycle contracts", () => {
error: expect.stringContaining("Command failed"),
}));
});
describe("cloudflared manifest verification", () => {
it("validateCloudflaredManifest accepts a pending manifest", () => {
expect(validateCloudflaredManifest({ source: "upstream-pending-verification", version: null, verifiedAt: null, assets: {} })).toEqual({ ok: true });
});
it("validateCloudflaredManifest rejects pending manifest with non-empty assets", () => {
const result = validateCloudflaredManifest({ source: "upstream-pending-verification", version: null, verifiedAt: null, assets: { a: { url: "x", sha256: "a" } } });
expect(result.ok).toBe(false);
});
it("validateCloudflaredManifest accepts a verified manifest with proper sha256 and tagged URL", () => {
expect(validateCloudflaredManifest(makeVerifiedManifest("cloudflared-linux-amd64", "a".repeat(64)))).toEqual({ ok: true });
});
it("validateCloudflaredManifest rejects verified manifest with releases/latest/download URL", () => {
const result = validateCloudflaredManifest({ source: "upstream-verified", version: "v", verifiedAt: "2026-01-01T00:00:00.000Z", assets: { a: { url: "https://github.com/cloudflare/cloudflared/releases/latest/download/a", sha256: "a".repeat(64) } } });
expect(result.ok).toBe(false);
});
it("validateCloudflaredManifest rejects verified manifest with empty sha256", () => {
const result = validateCloudflaredManifest({ source: "upstream-verified", version: "v", verifiedAt: "2026-01-01T00:00:00.000Z", assets: { a: { url: "https://github.com/cloudflare/cloudflared/releases/download/v/a", sha256: "" } } });
expect(result.ok).toBe(false);
});
it("validateCloudflaredManifest rejects verified manifest with uppercase or short sha256", () => {
expect(validateCloudflaredManifest({ source: "upstream-verified", version: "v", verifiedAt: "2026-01-01T00:00:00.000Z", assets: { a: { url: "https://github.com/cloudflare/cloudflared/releases/download/v/a", sha256: "A".repeat(64) } } }).ok).toBe(false);
expect(validateCloudflaredManifest({ source: "upstream-verified", version: "v", verifiedAt: "2026-01-01T00:00:00.000Z", assets: { a: { url: "https://github.com/cloudflare/cloudflared/releases/download/v/a", sha256: "a".repeat(63) } } }).ok).toBe(false);
});
it("validateCloudflaredManifest never throws on garbage input", () => {
for (const input of [null, undefined, 42, "string", []]) {
expect(() => validateCloudflaredManifest(input)).not.toThrow();
expect(validateCloudflaredManifest(input).ok).toBe(false);
}
});
it("installCloudflared fails closed when manifest is upstream-pending-verification", async () => {
setProcessRuntime("linux", "x64");
const { app } = createApp();
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
expect(result.body.success).toBe(false);
expect(result.body.error).toContain("upstream-pending-verification");
expect(mockExecFile.mock.calls.every(([command]) => command !== "curl")).toBe(true);
});
it("installCloudflared verifies sha256 and proceeds on verified manifest", async () => {
setProcessRuntime("linux", "x64");
const payload = Buffer.from("verified-payload");
const sha256 = createHash("sha256").update(payload).digest("hex");
__setCloudflaredManifestForTesting(makeVerifiedManifest("cloudflared-linux-amd64", sha256));
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void : maybeCallback;
if (command === "curl") writeFileSync("/tmp/cloudflared", payload);
callback?.(null, "", "");
});
const { app } = createApp();
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
expect(result.body.success).toBe(true);
expect(mockExecFile.mock.calls.some(([command]) => command === "chmod")).toBe(true);
expect(mockExecFile.mock.calls.some(([command]) => command === "mv")).toBe(true);
});
it("installCloudflared aborts when downloaded sha256 does not match", async () => {
setProcessRuntime("linux", "x64");
__setCloudflaredManifestForTesting(makeVerifiedManifest("cloudflared-linux-amd64", "a".repeat(64)));
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void : maybeCallback;
if (command === "curl") writeFileSync("/tmp/cloudflared", Buffer.from("mismatch"));
callback?.(null, "", "");
});
const { app } = createApp();
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
expect(result.body.success).toBe(false);
expect(result.body.error).toContain("sha256 mismatch");
expect(mockExecFile.mock.calls.some(([command]) => command === "rm")).toBe(true);
const firstChmodIndex = mockExecFile.mock.calls.findIndex(([command]) => command === "chmod");
const firstMvIndex = mockExecFile.mock.calls.findIndex(([command]) => command === "mv");
expect(firstChmodIndex).toBe(-1);
expect(firstMvIndex).toBe(-1);
});
it("installCloudflared on win32 (winget) is unchanged by manifest state", async () => {
setProcessRuntime("win32", "x64");
const { app } = createApp();
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
expect(result.body.success).toBe(true);
expect(mockExecFile.mock.calls.some(([command]) => command === "winget")).toBe(true);
});
it("installCloudflared on darwin uses brew when present, regardless of manifest state", async () => {
setProcessRuntime("darwin", "arm64");
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void : maybeCallback;
callback?.(null, command === "which" ? "/opt/homebrew/bin/brew" : "", "");
});
const { app } = createApp();
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
expect(result.body.success).toBe(true);
expect(mockExecFile.mock.calls.some(([command]) => command === "brew")).toBe(true);
});
});
});

View File

@@ -52,6 +52,7 @@ import {
} from "@fusion/engine";
import QRCode from "qrcode";
import crypto from "node:crypto";
import { createReadStream } from "node:fs";
import { execFile } from "node:child_process";
import { homedir } from "node:os";
import { promisify } from "node:util";
@@ -68,6 +69,115 @@ interface SettingsMemoryRouteDeps {
discoverDashboardPiExtensions: (cwd: string) => Promise<PiExtensionSettings>;
}
export interface CloudflaredReleaseAsset {
url: string;
sha256: string;
}
export interface CloudflaredReleaseManifest {
source: "upstream-pending-verification" | "upstream-verified";
version: string | null;
verifiedAt: string | null;
assets: Record<string, CloudflaredReleaseAsset>;
}
/**
* Pinned cloudflared release manifest.
* Upstream repo: https://github.com/cloudflare/cloudflared
* Docs: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
* Release assets: https://github.com/cloudflare/cloudflared/releases/download/<tag>/<asset>
*
* Maintainer promotion procedure (FN-5321 external-integration evidence):
* 1) Select a specific release tag from the upstream releases page.
* 2) For each supported asset, fetch the matching `<asset>.sha256` sidecar from that tagged release.
* 3) Set `source` to `upstream-verified`, set `version` to the tag, set `verifiedAt` to ISO8601,
* and populate `assets` with tagged URLs + 64-char lowercase sha256 values.
*
* Until that verification is completed, this remains fail-closed.
*/
export const CLOUDFLARED_PINNED_RELEASE: CloudflaredReleaseManifest = {
source: "upstream-pending-verification",
version: null,
verifiedAt: null,
assets: {},
};
let cloudflaredManifestOverrideForTesting: CloudflaredReleaseManifest | null = null;
export function __setCloudflaredManifestForTesting(manifest: CloudflaredReleaseManifest | null): void {
cloudflaredManifestOverrideForTesting = manifest;
}
function getCloudflaredManifest(): CloudflaredReleaseManifest {
return cloudflaredManifestOverrideForTesting ?? CLOUDFLARED_PINNED_RELEASE;
}
export function validateCloudflaredManifest(input: unknown): { ok: true } | { ok: false; missingFields: string[]; reason: string } {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return { ok: false, missingFields: ["manifest"], reason: "manifest must be an object" };
}
const manifest = input as Partial<CloudflaredReleaseManifest> & { assets?: unknown };
const missingFields: string[] = [];
if (manifest.source !== "upstream-pending-verification" && manifest.source !== "upstream-verified") {
missingFields.push("source");
}
if (!(typeof manifest.version === "string" || manifest.version === null)) {
missingFields.push("version");
}
if (!(typeof manifest.verifiedAt === "string" || manifest.verifiedAt === null)) {
missingFields.push("verifiedAt");
}
const assets = manifest.assets;
if (!assets || typeof assets !== "object" || Array.isArray(assets)) {
missingFields.push("assets");
}
if (missingFields.length > 0) {
return { ok: false, missingFields, reason: `missing or invalid manifest fields: ${missingFields.join(", ")}` };
}
const assetEntries = Object.entries(assets as Record<string, unknown>);
if (manifest.source === "upstream-pending-verification") {
if (assetEntries.length > 0) {
missingFields.push("assets");
}
if (manifest.version !== null) {
missingFields.push("version");
}
if (manifest.verifiedAt !== null) {
missingFields.push("verifiedAt");
}
if (missingFields.length > 0) {
return { ok: false, missingFields, reason: "fields must be empty/null when pending" };
}
return { ok: true };
}
for (const [assetName, assetRaw] of assetEntries) {
if (!assetRaw || typeof assetRaw !== "object" || Array.isArray(assetRaw)) {
missingFields.push(`assets.${assetName}`);
continue;
}
const asset = assetRaw as Partial<CloudflaredReleaseAsset>;
if (typeof asset.url !== "string" || !asset.url.startsWith("https://github.com/cloudflare/cloudflared/releases/download/")) {
missingFields.push(`assets.${assetName}.url`);
}
if (typeof asset.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(asset.sha256)) {
missingFields.push(`assets.${assetName}.sha256`);
}
}
if (missingFields.length > 0) {
return { ok: false, missingFields, reason: `missing or invalid manifest fields: ${missingFields.join(", ")}` };
}
return { ok: true };
}
export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: SettingsMemoryRouteDeps): void {
const { router, options, store, runtimeLogger, getProjectContext, rethrowAsApiError } = ctx;
const { githubToken, validateModelPresets, sanitizeOverlapIgnorePaths, discoverDashboardPiExtensions } = deps;
@@ -158,7 +268,35 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
return [error.message, stderr, stdout].filter(Boolean).join(" | ");
}
async function verifyDownloadedBinaryChecksum(tempPath: string, expectedSha256: string): Promise<void> {
const hash = crypto.createHash("sha256");
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(tempPath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", resolve);
});
const actualSha256 = hash.digest("hex").toLowerCase();
const expected = expectedSha256.toLowerCase();
if (actualSha256 !== expected) {
throw new Error(
`cloudflared sha256 mismatch (expected ${expected}, got ${actualSha256}); refusing to install possibly tampered binary`,
);
}
}
async function installCloudflared(): Promise<{ success: boolean; command: string; error?: string }> {
const manifest = getCloudflaredManifest();
const manifestValidation = validateCloudflaredManifest(manifest);
if (!manifestValidation.ok) {
return {
success: false,
command: resolveCloudflaredInstallCommand(),
error: `cloudflared manifest invalid: ${manifestValidation.reason}`,
};
}
if (process.platform === "win32") {
const command = resolveCloudflaredInstallCommand();
try {
@@ -173,12 +311,29 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
const downloadBinaryName = process.platform === "darwin" || process.platform === "linux"
? resolveCloudflaredBinaryName()
: "cloudflared-linux-amd64";
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/${downloadBinaryName}`;
const tempPath = "/tmp/cloudflared";
const installFromDirectDownload = async (): Promise<void> => {
attemptedCommands.push(`curl -L --output ${tempPath} ${downloadUrl}`);
await execFileAsync("curl", ["-L", "--output", tempPath, downloadUrl], { timeout: 120_000 });
const asset = manifest.assets[downloadBinaryName];
if (manifest.source === "upstream-pending-verification" || !asset) {
const packageManagerCommand = resolveCloudflaredInstallCommand();
attemptedCommands.push(packageManagerCommand);
const error = new Error(
`cloudflared install blocked: upstream-pending-verification manifest is not pinned for ${downloadBinaryName}; run '${packageManagerCommand}' (current platform), 'brew install cloudflared' (macOS), or 'winget install Cloudflare.cloudflared' (Windows), or pin a verified release at https://github.com/cloudflare/cloudflared/releases`,
);
throw Object.assign(error, { stage: "manifest-unverified" });
}
attemptedCommands.push(`curl -L --output ${tempPath} ${asset.url}`);
await execFileAsync("curl", ["-L", "--output", tempPath, asset.url], { timeout: 120_000 });
attemptedCommands.push(`verify sha256 of ${tempPath} against pinned manifest`);
try {
await verifyDownloadedBinaryChecksum(tempPath, asset.sha256);
} catch (error) {
attemptedCommands.push(`rm -f ${tempPath} (checksum mismatch cleanup)`);
await execFileAsync("rm", ["-f", tempPath], { timeout: 10_000 }).catch(() => {});
throw error;
}
attemptedCommands.push(`chmod +x ${tempPath}`);
await execFileAsync("chmod", ["+x", tempPath], { timeout: 30_000 });