From 1c424928a243bb9e1dff89030ba3a64a0836faa7 Mon Sep 17 00:00:00 2001 From: feitianxiang Date: Sun, 18 Oct 2026 19:49:40 +0800 Subject: [PATCH] fix(cli): trust GitHub release asset redirects (#54) Co-authored-by: alvinfei Co-authored-by: Cursor --- .../cli/scripts/prepare-publish-manifest.mjs | 11 ++--- memhub/apps/cli/src/cli.ts | 11 ++--- memhub/apps/cli/src/publish-manifest.test.ts | 7 ++- memhub/apps/cli/src/version.ts | 21 ++++++--- memhub/apps/cli/tsup.config.ts | 16 ++++++- memhub/apps/cli/vitest.config.ts | 1 + .../src/release-download.test.ts | 43 ++++++++++++++++++- .../runtime-control/src/release-download.ts | 10 ++++- memhub/scripts/publish-cli-after-release.mjs | 18 +++++--- .../publish-cli-after-release.test.mjs | 35 ++++++++++----- 10 files changed, 137 insertions(+), 36 deletions(-) diff --git a/memhub/apps/cli/scripts/prepare-publish-manifest.mjs b/memhub/apps/cli/scripts/prepare-publish-manifest.mjs index b4bc4ef..f8d45c5 100644 --- a/memhub/apps/cli/scripts/prepare-publish-manifest.mjs +++ b/memhub/apps/cli/scripts/prepare-publish-manifest.mjs @@ -12,7 +12,6 @@ * It is generated rather than hand-written so the two cannot drift: this reads * the real package.json and only strips what is provably not needed at runtime. */ -import { execFileSync } from "node:child_process"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; @@ -20,12 +19,14 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, ".."); -const repositoryRoot = resolve(root, "../.."); const require = createRequire(import.meta.url); -const { resolveBuildVersion } = require("../../../scripts/release-version.cjs"); -const releaseVersion = resolveBuildVersion(process.env, repositoryRoot); +const { assertSemver } = require("../../../scripts/release-version.cjs"); const pkgPath = resolve(root, "package.json"); const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); +const publishVersion = assertSemver( + process.env.MEMHUB_NPM_VERSION?.trim() || pkg.version, + "MEMHUB_NPM_VERSION", +); const publishName = process.env.MEMHUB_NPM_PACKAGE?.trim(); if (!publishName || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)) { throw new Error( @@ -53,7 +54,7 @@ mkdirSync(outDir, { recursive: true }); const published = { ...pkg }; published.name = publishName; -published.version = releaseVersion; +published.version = publishVersion; for (const section of ["dependencies", "devDependencies", "peerDependencies"]) { if (!published[section]) continue; published[section] = Object.fromEntries( diff --git a/memhub/apps/cli/src/cli.ts b/memhub/apps/cli/src/cli.ts index c6740c8..261a078 100644 --- a/memhub/apps/cli/src/cli.ts +++ b/memhub/apps/cli/src/cli.ts @@ -331,7 +331,7 @@ import { renderVersion, renderVersionDocument, } from "./usage.js"; -import { CLI_VERSION, PRODUCT } from "./version.js"; +import { CLI_VERSION, PRODUCT, RUNTIME_RELEASE_VERSION } from "./version.js"; import { runWatchLoop, type WatchEvent, type WatchRead } from "./watch-loop.js"; export interface RunControls { @@ -417,7 +417,8 @@ async function selectInstallSource( throw new Error("release_manifest_url_unavailable"); } const manifestUrl = - explicitManifestUrl ?? template.replaceAll("{version}", CLI_VERSION); + explicitManifestUrl ?? + template.replaceAll("{version}", RUNTIME_RELEASE_VERSION); const allowedHosts = parseAllowedReleaseHosts( environment.MEMHUB_RELEASE_ALLOWED_HOSTS, ); @@ -462,7 +463,7 @@ async function resolveRemoteInstallPreview( ): Promise { return (controls.resolveRuntimeRelease ?? resolveRuntimeRelease)({ manifestUrl: selection.manifestUrl, - expectedVersion: CLI_VERSION, + expectedVersion: RUNTIME_RELEASE_VERSION, channel: selection.channel, platform: process.platform, arch: process.arch, @@ -476,7 +477,7 @@ async function acquireRemoteInstallSource( ): Promise { return (controls.acquireRuntimeDistribution ?? acquireRuntimeDistribution)({ manifestUrl: selection.manifestUrl, - expectedVersion: CLI_VERSION, + expectedVersion: RUNTIME_RELEASE_VERSION, channel: selection.channel, platform: process.platform, arch: process.arch, @@ -6520,7 +6521,7 @@ function parseInstallOptions( ...(releaseManifestUrl ? { releaseManifestUrl } : {}), releaseChannel: releaseChannel ?? - (CLI_VERSION.includes("-") ? "prerelease" : "stable"), + (RUNTIME_RELEASE_VERSION.includes("-") ? "prerelease" : "stable"), installRoot: resolvedInstallRoot, home: resolvedHome, uid: uid ?? process.getuid?.(), diff --git a/memhub/apps/cli/src/publish-manifest.test.ts b/memhub/apps/cli/src/publish-manifest.test.ts index 35f012a..468d23a 100644 --- a/memhub/apps/cli/src/publish-manifest.test.ts +++ b/memhub/apps/cli/src/publish-manifest.test.ts @@ -48,7 +48,11 @@ describe("publish manifest is production-safe", () => { execFileSync("node", ["scripts/prepare-publish-manifest.mjs"], { cwd: root, stdio: "pipe", - env: { ...process.env, MEMHUB_NPM_PACKAGE: "@example/memhub-cli" }, + env: { + ...process.env, + MEMHUB_NPM_PACKAGE: "@example/memhub-cli", + MEMHUB_NPM_VERSION: "1.0.5", + }, }); const published = JSON.parse( readFileSync(path.join(root, "dist-publish", "package.json"), "utf8"), @@ -56,6 +60,7 @@ describe("publish manifest is production-safe", () => { expect(published.dependencies).toBeUndefined(); expect(published.private).toBe(false); expect(published.name).toBe("@example/memhub-cli"); + expect(published.version).toBe("1.0.5"); expect(published.bin).toMatchObject({ memhub: "dist/one-memory.cjs", "memhub-cli": "dist/one-memory.cjs", diff --git a/memhub/apps/cli/src/version.ts b/memhub/apps/cli/src/version.ts index c5ccaca..824bad6 100644 --- a/memhub/apps/cli/src/version.ts +++ b/memhub/apps/cli/src/version.ts @@ -1,5 +1,9 @@ declare const __ONE_MEMORY_VERSION__: string; declare const __ONE_MEMORY_BUILD_NUMBER__: string; +declare const __MEMHUB_DEFAULT_RUNTIME_VERSION__: string; + +const SEMVER = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; const embeddedVersion = typeof __ONE_MEMORY_VERSION__ === "string" @@ -9,13 +13,18 @@ const embeddedBuildNumber = typeof __ONE_MEMORY_BUILD_NUMBER__ === "string" ? __ONE_MEMORY_BUILD_NUMBER__.trim() : ""; +const embeddedRuntimeVersion = + typeof __MEMHUB_DEFAULT_RUNTIME_VERSION__ === "string" + ? __MEMHUB_DEFAULT_RUNTIME_VERSION__.trim() + : ""; -export const CLI_VERSION = - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test( - embeddedVersion, - ) - ? embeddedVersion - : "development"; +export const CLI_VERSION = SEMVER.test(embeddedVersion) + ? embeddedVersion + : "development"; + +export const RUNTIME_RELEASE_VERSION = SEMVER.test(embeddedRuntimeVersion) + ? embeddedRuntimeVersion + : CLI_VERSION; export const CLI_BUILD_NUMBER = /^\d+$/.test(embeddedBuildNumber) ? Number(embeddedBuildNumber) diff --git a/memhub/apps/cli/tsup.config.ts b/memhub/apps/cli/tsup.config.ts index 4d39638..3d43043 100644 --- a/memhub/apps/cli/tsup.config.ts +++ b/memhub/apps/cli/tsup.config.ts @@ -2,11 +2,22 @@ import { createRequire } from "node:module"; import { defineConfig } from "tsup"; const require = createRequire(import.meta.url); -const { resolveBuildVersion } = +const { assertSemver, resolveBuildVersion } = require("../../scripts/release-version.cjs") as { + assertSemver(value: string, label?: string): string; resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string; }; const releaseVersion = resolveBuildVersion(process.env); +const packageVersion = (require("./package.json") as { version: string }) + .version; +const cliVersion = assertSemver( + process.env.MEMHUB_NPM_VERSION?.trim() || packageVersion, + "MEMHUB_NPM_VERSION", +); +const runtimeReleaseVersion = assertSemver( + process.env.MEMHUB_DEFAULT_RUNTIME_VERSION?.trim() || releaseVersion, + "MEMHUB_DEFAULT_RUNTIME_VERSION", +); export default defineConfig({ entry: { "one-memory": "src/entry.ts" }, @@ -15,7 +26,7 @@ export default defineConfig({ outDir: "dist", clean: true, define: { - __ONE_MEMORY_VERSION__: JSON.stringify(releaseVersion), + __ONE_MEMORY_VERSION__: JSON.stringify(cliVersion), __ONE_MEMORY_BUILD_NUMBER__: JSON.stringify( process.env.ONE_MEMORY_BUILD_NUMBER ?? "unknown", ), @@ -25,6 +36,7 @@ export default defineConfig({ __MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify( process.env.MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE ?? "", ), + __MEMHUB_DEFAULT_RUNTIME_VERSION__: JSON.stringify(runtimeReleaseVersion), }, // The shared operations package ships TypeScript sources, so it must be // inlined into the CJS bundle the same way runtime-control is; otherwise the diff --git a/memhub/apps/cli/vitest.config.ts b/memhub/apps/cli/vitest.config.ts index 51cde87..dd2a0e8 100644 --- a/memhub/apps/cli/vitest.config.ts +++ b/memhub/apps/cli/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ __ONE_MEMORY_BUILD_NUMBER__: JSON.stringify("unknown"), __ONE_MEMORY_COMMIT__: JSON.stringify("unknown"), __MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify(""), + __MEMHUB_DEFAULT_RUNTIME_VERSION__: JSON.stringify(version), }, test: { testTimeout: 120_000, diff --git a/memhub/packages/runtime-control/src/release-download.test.ts b/memhub/packages/runtime-control/src/release-download.test.ts index 5d52f3f..66e83d8 100644 --- a/memhub/packages/runtime-control/src/release-download.test.ts +++ b/memhub/packages/runtime-control/src/release-download.test.ts @@ -312,6 +312,48 @@ describe("remote Runtime acquisition", () => { }); }); + it("allows the exact GitHub Release asset redirect hosts by default", async () => { + const value = await fixture(); + const manifestUrl = + "https://github.com/example/project/releases/download/v1.2.3/release-manifest.json"; + const artifactUrl = + "https://github.com/example/project/releases/download/v1.2.3/runtime.tar.gz"; + const manifestAssetUrl = + "https://release-assets.githubusercontent.com/manifest-download"; + const runtimeAssetUrl = + "https://release-assets.githubusercontent.com/runtime-download"; + const release = { + ...value.release, + artifacts: [{ ...value.artifact, url: artifactUrl }], + }; + const temporaryParent = await mkdtemp( + path.join(os.tmpdir(), "memhub-github-release-parent-"), + ); + roots.push(temporaryParent); + + const acquired = await acquireRuntimeDistribution({ + manifestUrl, + expectedVersion: "1.2.3", + channel: "stable", + platform: "linux", + arch: "x64", + temporaryParent, + httpGet: httpFixture({ + [manifestUrl]: response(new Uint8Array(), 302, { + location: manifestAssetUrl, + }), + [manifestAssetUrl]: response(Buffer.from(JSON.stringify(release))), + [artifactUrl]: response(new Uint8Array(), 302, { + location: runtimeAssetUrl, + }), + [runtimeAssetUrl]: response(value.archiveBytes), + }), + }); + + expect(acquired.release.version).toBe("1.2.3"); + await acquired.cleanup(); + }); + it("rejects an archive whose internal commit drifts from the Release", async () => { const value = await fixture(); const release = { ...value.release, git_commit: "b".repeat(40) }; @@ -368,7 +410,6 @@ describe("remote Runtime acquisition", () => { ); it("rejects a redirect to an untrusted host", async () => { - const value = await fixture(); await expect( acquireRuntimeDistribution({ manifestUrl: "https://downloads.example.test/release-manifest.json", diff --git a/memhub/packages/runtime-control/src/release-download.ts b/memhub/packages/runtime-control/src/release-download.ts index 1a01b62..7a1a87c 100644 --- a/memhub/packages/runtime-control/src/release-download.ts +++ b/memhub/packages/runtime-control/src/release-download.ts @@ -38,6 +38,10 @@ const SHA256 = /^[a-f0-9]{64}$/; const COMMIT = /^[0-9a-f]{40}$/; const SAFE_FILE_NAME = /^[0-9A-Za-z][0-9A-Za-z._-]{0,255}$/; const REDIRECTS = new Set([301, 302, 303, 307, 308]); +const GITHUB_RELEASE_REDIRECT_HOSTS = [ + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", +] as const; export type ReleaseChannel = "stable" | "prerelease"; export type RuntimeReleasePlatform = "darwin" | "linux"; @@ -330,8 +334,12 @@ export async function resolveRuntimeRelease(options: { maxRedirects?: number; }): Promise { const manifestUrl = trustedUrl(options.manifestUrl); + const defaultHosts = + normalizedHost(manifestUrl.hostname) === "github.com" + ? [manifestUrl.hostname, ...GITHUB_RELEASE_REDIRECT_HOSTS] + : [manifestUrl.hostname]; const allowedHosts = new Set( - (options.allowedHosts ?? [manifestUrl.hostname]).map(normalizedHost), + (options.allowedHosts ?? defaultHosts).map(normalizedHost), ); if (!allowedHosts.has(normalizedHost(manifestUrl.hostname))) { throw new Error("release_url_untrusted"); diff --git a/memhub/scripts/publish-cli-after-release.mjs b/memhub/scripts/publish-cli-after-release.mjs index 118ab8f..97ee64c 100644 --- a/memhub/scripts/publish-cli-after-release.mjs +++ b/memhub/scripts/publish-cli-after-release.mjs @@ -23,7 +23,8 @@ export async function publishCliAfterRelease(options, dependencies = {}) { const statImpl = dependencies.statImpl ?? stat; const buildEnvironment = { ...environment, - ONE_MEMORY_VERSION: options.version, + MEMHUB_NPM_VERSION: options.version, + MEMHUB_DEFAULT_RUNTIME_VERSION: options.runtimeVersion, ONE_MEMORY_BUILD_NUMBER: String(options.buildNumber), ONE_MEMORY_COMMIT: options.gitCommit, }; @@ -31,7 +32,7 @@ export async function publishCliAfterRelease(options, dependencies = {}) { const versionReceipt = await verifyVersion( [ "--version", - options.version, + options.runtimeVersion, "--channel", options.channel, "--tag", @@ -43,7 +44,7 @@ export async function publishCliAfterRelease(options, dependencies = {}) { ); const releaseReceipt = await verifyRelease({ manifestUrl: options.manifestUrl, - version: options.version, + version: options.runtimeVersion, gitCommit: options.gitCommit, channel: options.channel, platform: options.platform, @@ -106,7 +107,8 @@ export async function publishCliAfterRelease(options, dependencies = {}) { return { schema_version: 1, product: "memhub-cli-publication", - version: versionReceipt.version, + version: options.version, + runtime_version: releaseReceipt.version, build_number: versionReceipt.build_number, tag: versionReceipt.tag, release: releaseReceipt, @@ -196,6 +198,11 @@ function parseArgs(arguments_) { options.manifestUrl = value; else if (flag === "--version" && options.version === undefined) options.version = value; + else if ( + flag === "--runtime-version" && + options.runtimeVersion === undefined + ) + options.runtimeVersion = value; else if (flag === "--git-commit" && options.gitCommit === undefined) options.gitCommit = value; else if (flag === "--channel" && options.channel === undefined) @@ -219,6 +226,7 @@ function parseArgs(arguments_) { if ( !options.manifestUrl || !options.version || + !options.runtimeVersion || !options.gitCommit || !options.channel || !options.tag || @@ -230,7 +238,7 @@ function parseArgs(arguments_) { !options.npmTag ) { throw new Error( - "usage: publish-cli-after-release.mjs --manifest-url --version --git-commit --channel --tag --build-number --platform --arch --registry --npm-tag [--access ] [--provenance] [--allowed-host ] [--dry-run]", + "usage: publish-cli-after-release.mjs --manifest-url --version --runtime-version --git-commit --channel --tag --build-number --platform --arch --registry --npm-tag [--access ] [--provenance] [--allowed-host ] [--dry-run]", ); } return options; diff --git a/memhub/scripts/publish-cli-after-release.test.mjs b/memhub/scripts/publish-cli-after-release.test.mjs index e447a05..2f5d73e 100644 --- a/memhub/scripts/publish-cli-after-release.test.mjs +++ b/memhub/scripts/publish-cli-after-release.test.mjs @@ -6,6 +6,9 @@ import test from "node:test"; import { publishCliAfterRelease } from "./publish-cli-after-release.mjs"; +const CLI_VERSION = "0.1.1"; +const RUNTIME_VERSION = "0.1.0-internal.1"; + async function fixture(t) { const root = await mkdtemp(path.join(os.tmpdir(), "memhub-publish-gate-")); t.after(() => rm(root, { recursive: true, force: true })); @@ -15,10 +18,10 @@ async function fixture(t) { await mkdir(output, { recursive: true }); await writeFile( path.join(publish, "package.json"), - `${JSON.stringify({ name: "@example/memhub-cli", version: "0.1.0-internal.1", private: false })}\n`, + `${JSON.stringify({ name: "@example/memhub-cli", version: CLI_VERSION, private: false })}\n`, ); await writeFile( - path.join(output, "example-memhub-cli-0.1.0-internal.1.tgz"), + path.join(output, `example-memhub-cli-${CLI_VERSION}.tgz`), "tarball", ); return root; @@ -26,10 +29,11 @@ async function fixture(t) { const options = { manifestUrl: "https://releases.example.test/release-manifest.json", - version: "0.1.0-internal.1", + version: CLI_VERSION, + runtimeVersion: RUNTIME_VERSION, gitCommit: "a".repeat(40), channel: "prerelease", - tag: "v0.1.0-internal.1", + tag: `v${RUNTIME_VERSION}`, buildNumber: 1, platform: "linux", arch: "x64", @@ -49,15 +53,24 @@ test("verifies the Runtime before building and publishing the CLI", async (t) => { verifyVersion: async () => { events.push("version"); - return { version: options.version, build_number: 1, tag: options.tag }; + return { + version: options.runtimeVersion, + build_number: 1, + tag: options.tag, + }; }, verifyRelease: async (verifyOptions) => { events.push("release"); assert.equal(verifyOptions.gitCommit, options.gitCommit); - return { version: options.version, sha256: "a".repeat(64) }; + assert.equal(verifyOptions.version, options.runtimeVersion); + return { version: options.runtimeVersion, sha256: "a".repeat(64) }; }, runCommand: (command, arguments_, commandOptions) => { - assert.equal(commandOptions.env.ONE_MEMORY_VERSION, options.version); + assert.equal(commandOptions.env.MEMHUB_NPM_VERSION, options.version); + assert.equal( + commandOptions.env.MEMHUB_DEFAULT_RUNTIME_VERSION, + options.runtimeVersion, + ); assert.equal( commandOptions.env.ONE_MEMORY_BUILD_NUMBER, String(options.buildNumber), @@ -74,6 +87,8 @@ test("verifies the Runtime before building and publishing the CLI", async (t) => assert.match(events[3], /--access public/); assert.match(events[3], /--provenance/); assert.equal(receipt.npm_package, "@example/memhub-cli"); + assert.equal(receipt.version, CLI_VERSION); + assert.equal(receipt.runtime_version, RUNTIME_VERSION); assert.equal(receipt.dry_run, true); assert.equal(receipt.npm_access, "public"); assert.equal(receipt.provenance, true); @@ -87,7 +102,7 @@ test("never builds or invokes npm when the Runtime release is unavailable", asyn { ...options, root }, { verifyVersion: async () => ({ - version: options.version, + version: options.runtimeVersion, build_number: 1, tag: options.tag, }), @@ -115,11 +130,11 @@ test("rejects a package identity that differs from the configured publish name", }, { verifyVersion: async () => ({ - version: options.version, + version: options.runtimeVersion, build_number: 1, tag: options.tag, }), - verifyRelease: async () => ({ version: options.version }), + verifyRelease: async () => ({ version: options.runtimeVersion }), runCommand: () => undefined, }, ),