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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@
"test:composer-installed-versions-loader-order": "tsx scripts/composer-installed-versions-loader-order-smoke.ts",
"test:recipe-extra-plugin-composer-autoloaders": "tsx tests/recipe-extra-plugin-composer-autoloaders.test.ts",
"test:recipe-extra-plugin-local-zip": "tsx tests/recipe-extra-plugin-local-zip.test.ts",
"test:zip-source-policy": "tsx tests/zip-source-policy.test.ts",
"test:runtime-preset-registry": "tsx tests/runtime-preset-registry.test.ts",
"test:generic-ability-runtime-run": "tsx tests/generic-ability-runtime-run.test.ts",
"test:provider-runtime-contracts": "tsx tests/provider-runtime-contracts.test.ts",
Expand Down
19 changes: 12 additions & 7 deletions packages/cli/src/recipe-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import type { MountSpec, WorkspaceRecipe, WorkspaceRecipeDependencyOverlay, Work
import { executeManagedHostCommand, resolvePluginEntrypointContract } from "@automattic/wp-codebox-core"
import { collectPreparedSourceCleanupPaths, DEFAULT_PREPARED_SOURCE_EXCLUDE_NAMES, localPreparedSourceProvenance, prepareLocalSourceStageSync, SANDBOX_WORKSPACE_ROOT, type PreparedSourceProvenance } from "@automattic/wp-codebox-core/internals"
import { registerRuntimeOverlayDescriptor, runtimeOverlayDescriptor } from "./runtime-overlay-registry.js"
import { evaluateSourcePolicy, evaluateZipSourcePolicy, sourcePolicySnapshot, type SourcePolicyIssue } from "./source-policy.js"
import { evaluateSourcePolicy, evaluateZipSourcePolicy, sourcePolicySnapshot, type ArchiveSourceClass, type SourcePolicyIssue } from "./source-policy.js"
import { prepareLocalZipSource, prepareZipSource } from "./zip-source.js"

export { ALLOW_NETWORK_DOWNLOADS_ENV, ALLOWED_DOWNLOAD_HOSTS_ENV, allowedDownloadHosts, isSha256, maxDownloadBytes, maxExtractedBytes, maxExtractedFiles, MAX_DOWNLOAD_BYTES_ENV, MAX_EXTRACTED_BYTES_ENV, MAX_EXTRACTED_FILES_ENV, REQUIRE_SOURCE_SHA256_ENV, sourceSha256Required } from "./source-policy.js"
export { ALLOW_NETWORK_DOWNLOADS_ENV, ALLOWED_DOWNLOAD_HOSTS_ENV, allowedDownloadHosts, archiveSourceClass, isSha256, maxCompressionRatio, maxDownloadBytes, maxExtractedBytes, maxExtractedFileBytes, maxExtractedFiles, maxExtractedFilesFor, MAX_COMPRESSION_RATIO_ENV, MAX_DOWNLOAD_BYTES_ENV, MAX_EXTRACTED_BYTES_ENV, MAX_EXTRACTED_FILES_ENV, MAX_EXTRACTED_FILE_BYTES_ENV, REQUIRE_SOURCE_SHA256_ENV, sourceSha256Required, TRUSTED_ARCHIVE_MAX_EXTRACTED_FILES_ENV } from "./source-policy.js"

const PHP_AI_CLIENT_RUNTIME_OVERLAY_TARGET = "/wordpress/wp-includes/php-ai-client"
const PHP_SCOPER_DOWNLOAD_ATTEMPTS = 3
Expand Down Expand Up @@ -46,6 +46,9 @@ export interface RecipeSourceProvenance {
maxDownloadBytes: number
maxExtractedBytes: number
maxExtractedFiles: number
maxExtractedFileBytes: number
maxCompressionRatio: number
archiveClass: ArchiveSourceClass
sha256Required: boolean
}
localPathCategory?: "recipe-relative" | "temporary-download" | "temporary-composer-autoload"
Expand Down Expand Up @@ -124,6 +127,7 @@ export interface ParsedRecipeSource {
host: string
expectedSha256?: string
wporgSlug?: string
archiveClass?: ArchiveSourceClass
}

const PHP_SCOPER_VERSION = "0.18.17"
Expand Down Expand Up @@ -1442,7 +1446,7 @@ async function prepareRecipeSource(sourceRef: string, recipeDirectory: string, s
if (policyIssue) {
throw new Error(policyIssue.message)
}
const preparedZip = await prepareLocalZipSource(localPath, slug, source.expectedSha256)
const preparedZip = await prepareLocalZipSource(localPath, slug, source.expectedSha256, source.archiveClass)
return {
source: await extractedPluginSourceDirectory(preparedZip.extractDirectory, slug),
cleanupPaths: [preparedZip.root],
Expand Down Expand Up @@ -1472,7 +1476,7 @@ async function prepareRecipeSource(sourceRef: string, recipeDirectory: string, s
provenance: {
...recipeSourceProvenance(source, recipeDirectory),
digest: { sha256: preparedZip.digest, ...(source.expectedSha256 ? { expected: source.expectedSha256, verified: true } : {}) },
policy: sourcePolicySnapshot(source.host),
policy: sourcePolicySnapshot(source.host, source.archiveClass),
localPathCategory: "temporary-download",
},
}
Expand Down Expand Up @@ -1597,7 +1601,8 @@ export function recipeSource(sourceRef: string, expectedSha256?: string): Parsed
try {
url = new URL(sourceRef)
} catch {
return { type: "local", resolvedUrl: sourceRef, host: "", ...(expectedSha256 ? { expectedSha256: expectedSha256.toLowerCase() } : {}) }
const normalizedSha256 = expectedSha256?.toLowerCase()
return { type: "local", resolvedUrl: sourceRef, host: "", ...(normalizedSha256 ? { expectedSha256: normalizedSha256, archiveClass: "trusted" } : {}) }
}

if (url.protocol !== "https:") {
Expand All @@ -1611,7 +1616,7 @@ export function recipeSource(sourceRef: string, expectedSha256?: string): Parsed
if (url.hostname === "downloads.wordpress.org" && url.pathname.startsWith("/plugin/")) {
const filename = basename(url.pathname)
const match = filename.match(/^([A-Za-z0-9_-]+)\./)
return { type: "wporg_plugin_zip", resolvedUrl: url.toString(), host: url.hostname, ...(expectedSha256 ? { expectedSha256: expectedSha256.toLowerCase() } : {}), ...(match ? { wporgSlug: match[1] } : {}) }
return { type: "wporg_plugin_zip", resolvedUrl: url.toString(), host: url.hostname, archiveClass: "trusted", ...(expectedSha256 ? { expectedSha256: expectedSha256.toLowerCase() } : {}), ...(match ? { wporgSlug: match[1] } : {}) }
}

return { type: "https_zip", resolvedUrl: url.toString(), host: url.hostname, ...(expectedSha256 ? { expectedSha256: expectedSha256.toLowerCase() } : {}) }
Expand Down Expand Up @@ -1661,7 +1666,7 @@ export function recipeSourceProvenance(source: ParsedRecipeSource, recipeDirecto
original: source.resolvedUrl,
resolvedUrl: source.resolvedUrl,
...(source.expectedSha256 ? { digest: { sha256: source.expectedSha256, expected: source.expectedSha256, verified: false } } : {}),
policy: sourcePolicySnapshot(source.host),
policy: sourcePolicySnapshot(source.host, source.archiveClass),
}
}

Expand Down
38 changes: 36 additions & 2 deletions packages/cli/src/source-policy.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
export interface ExternalSourcePolicyInput {
type: string
host: string
archiveClass?: ArchiveSourceClass
}

export type ArchiveSourceClass = "standard" | "trusted"

export interface SourcePolicyIssue {
code: string
message: string
}

export interface SourcePolicySnapshot {
host: string
archiveClass: ArchiveSourceClass
maxDownloadBytes: number
maxExtractedBytes: number
maxExtractedFiles: number
maxExtractedFileBytes: number
maxCompressionRatio: number
sha256Required: boolean
}

Expand All @@ -22,11 +28,17 @@ export const REQUIRE_SOURCE_SHA256_ENV = "WP_CODEBOX_REQUIRE_SOURCE_SHA256"
export const MAX_DOWNLOAD_BYTES_ENV = "WP_CODEBOX_MAX_DOWNLOAD_BYTES"
export const MAX_EXTRACTED_BYTES_ENV = "WP_CODEBOX_MAX_EXTRACTED_BYTES"
export const MAX_EXTRACTED_FILES_ENV = "WP_CODEBOX_MAX_EXTRACTED_FILES"
export const TRUSTED_ARCHIVE_MAX_EXTRACTED_FILES_ENV = "WP_CODEBOX_TRUSTED_ARCHIVE_MAX_EXTRACTED_FILES"
export const MAX_EXTRACTED_FILE_BYTES_ENV = "WP_CODEBOX_MAX_EXTRACTED_FILE_BYTES"
export const MAX_COMPRESSION_RATIO_ENV = "WP_CODEBOX_MAX_COMPRESSION_RATIO"

const DEFAULT_ALLOWED_DOWNLOAD_HOSTS = ["downloads.wordpress.org"]
const DEFAULT_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024
const DEFAULT_MAX_EXTRACTED_BYTES = 100 * 1024 * 1024
const DEFAULT_MAX_EXTRACTED_FILES = 5000
const DEFAULT_TRUSTED_ARCHIVE_MAX_EXTRACTED_FILES = 10_000
const DEFAULT_MAX_EXTRACTED_FILE_BYTES = 25 * 1024 * 1024
const DEFAULT_MAX_COMPRESSION_RATIO = 100

export function isSha256(value: string): boolean {
return /^[a-f0-9]{64}$/i.test(value)
Expand Down Expand Up @@ -119,12 +131,34 @@ export function maxExtractedFiles(): number {
return envPositiveInteger(MAX_EXTRACTED_FILES_ENV, DEFAULT_MAX_EXTRACTED_FILES)
}

export function sourcePolicySnapshot(host: string): SourcePolicySnapshot {
export function archiveSourceClass(source: ExternalSourcePolicyInput): ArchiveSourceClass {
return source.archiveClass === "trusted" ? "trusted" : "standard"
}

export function maxExtractedFilesFor(source: ExternalSourcePolicyInput): number {
return archiveSourceClass(source) === "trusted"
? envPositiveInteger(TRUSTED_ARCHIVE_MAX_EXTRACTED_FILES_ENV, DEFAULT_TRUSTED_ARCHIVE_MAX_EXTRACTED_FILES)
: maxExtractedFiles()
}

export function maxExtractedFileBytes(): number {
return envPositiveInteger(MAX_EXTRACTED_FILE_BYTES_ENV, DEFAULT_MAX_EXTRACTED_FILE_BYTES)
}

export function maxCompressionRatio(): number {
return envPositiveInteger(MAX_COMPRESSION_RATIO_ENV, DEFAULT_MAX_COMPRESSION_RATIO)
}

export function sourcePolicySnapshot(host: string, archiveClass: ArchiveSourceClass = "standard"): SourcePolicySnapshot {
const source = { type: "archive", host, archiveClass }
return {
host,
archiveClass,
maxDownloadBytes: maxDownloadBytes(),
maxExtractedBytes: maxExtractedBytes(),
maxExtractedFiles: maxExtractedFiles(),
maxExtractedFiles: maxExtractedFilesFor(source),
maxExtractedFileBytes: maxExtractedFileBytes(),
maxCompressionRatio: maxCompressionRatio(),
sha256Required: sourceSha256Required(),
}
}
95 changes: 78 additions & 17 deletions packages/cli/src/zip-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "n
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import { executeManagedHostCommand } from "@automattic/wp-codebox-core"
import { allowedDownloadHosts, maxDownloadBytes, maxExtractedBytes, maxExtractedFiles } from "./source-policy.js"
import { allowedDownloadHosts, maxCompressionRatio, maxDownloadBytes, maxExtractedBytes, maxExtractedFileBytes, maxExtractedFilesFor, type ArchiveSourceClass } from "./source-policy.js"

export interface ZipSourceReference {
type: string
resolvedUrl: string
host: string
expectedSha256?: string
archiveClass?: ArchiveSourceClass
}

export interface PreparedZipSource {
Expand All @@ -27,14 +28,14 @@ export async function prepareZipSource<TSource extends ZipSourceReference>(sourc
const extractDirectory = join(root, "extracted")
await mkdir(extractDirectory, { recursive: true })
const digest = await downloadZipSource(source, zipPath, redirectSource)
await assertSafeZipEntries(zipPath)
await assertSafeZipEntries(zipPath, source)
await executeManagedHostCommand({ command: "unzip", args: ["-q", zipPath, "-d", extractDirectory], cwd: root, allowedCwdRoots: [root], label: "extract recipe source zip" })
await assertExtractedSourceBounds(extractDirectory)
await assertExtractedSourceBounds(extractDirectory, source)

return { root, zipPath, extractDirectory, digest }
}

export async function prepareLocalZipSource(sourcePath: string, slug: string, expectedSha256?: string): Promise<PreparedZipSource> {
export async function prepareLocalZipSource(sourcePath: string, slug: string, expectedSha256?: string, archiveClass: ArchiveSourceClass = "standard"): Promise<PreparedZipSource> {
const root = await mkdtemp(join(tmpdir(), `wp-codebox-source-${slug}-`))
const zipPath = join(root, "source.zip")
const extractDirectory = join(root, "extracted")
Expand All @@ -50,9 +51,10 @@ export async function prepareLocalZipSource(sourcePath: string, slug: string, ex
}
await writeFile(zipPath, buffer)
await mkdir(extractDirectory, { recursive: true })
await assertSafeZipEntries(zipPath)
const archiveSource = { type: "local", resolvedUrl: sourcePath, host: "", archiveClass }
await assertSafeZipEntries(zipPath, archiveSource)
await executeManagedHostCommand({ command: "unzip", args: ["-q", zipPath, "-d", extractDirectory], cwd: root, allowedCwdRoots: [root], label: "extract recipe source zip" })
await assertExtractedSourceBounds(extractDirectory)
await assertExtractedSourceBounds(extractDirectory, archiveSource)
return { root, zipPath, extractDirectory, digest }
} catch (error) {
await rm(root, { recursive: true, force: true })
Expand Down Expand Up @@ -91,26 +93,85 @@ async function downloadZipSource<TSource extends ZipSourceReference>(source: TSo
return digest
}

async function assertSafeZipEntries(zipPath: string): Promise<void> {
const root = dirname(zipPath)
const { stdout } = await executeManagedHostCommand({ command: "unzip", args: ["-Z1", zipPath], cwd: root, allowedCwdRoots: [root], label: "list recipe source zip" })
const entries = stdout.split(/\r?\n/).filter(Boolean)
if (entries.length > maxExtractedFiles()) {
throw new Error(`Recipe source zip contains too many entries: ${entries.length}`)
async function assertSafeZipEntries(zipPath: string, source: ZipSourceReference): Promise<void> {
const entries = zipEntries(await readFile(zipPath))
const maxFiles = maxExtractedFilesFor(source)
if (entries.length > maxFiles) {
throw new Error(`Recipe source zip contains too many entries: ${entries.length}; limit ${maxFiles}; archive class ${source.archiveClass ?? "standard"}`)
}

for (const entry of entries) {
const normalized = entry.replace(/\\/g, "/")
const normalized = entry.name.replace(/\\/g, "/")
if (normalized.startsWith("/") || normalized.split("/").includes("..")) {
throw new Error(`Recipe source zip contains an unsafe path: ${entry}`)
throw new Error(`Recipe source zip contains an unsafe path: ${entry.name}`)
}
}

const expandedBytes = entries.reduce((total, entry) => total + entry.uncompressedBytes, 0)
if (expandedBytes > maxExtractedBytes()) {
throw new Error(`Recipe source extraction exceeds ${maxExtractedBytes()} bytes: ${expandedBytes}`)
}

for (const { compressedBytes, uncompressedBytes } of entries) {
if (uncompressedBytes > maxExtractedFileBytes()) {
throw new Error(`Recipe source zip entry exceeds ${maxExtractedFileBytes()} bytes: ${uncompressedBytes}`)
}
if (uncompressedBytes > 0 && (compressedBytes === 0 || uncompressedBytes / compressedBytes > maxCompressionRatio())) {
throw new Error(`Recipe source zip entry exceeds ${maxCompressionRatio()}:1 compression ratio`)
}
}
}

function zipEntries(data: Buffer): Array<{ name: string; compressedBytes: number; uncompressedBytes: number }> {
const minimumEndOfCentralDirectory = 22
const endOfCentralDirectory = findEndOfCentralDirectory(data)
if (endOfCentralDirectory < 0 || data.length < minimumEndOfCentralDirectory) {
throw new Error("Recipe source zip has no valid central directory")
}

const disk = data.readUInt16LE(endOfCentralDirectory + 4)
const centralDirectoryDisk = data.readUInt16LE(endOfCentralDirectory + 6)
const entriesOnDisk = data.readUInt16LE(endOfCentralDirectory + 8)
const entryCount = data.readUInt16LE(endOfCentralDirectory + 10)
const centralDirectoryBytes = data.readUInt32LE(endOfCentralDirectory + 12)
let offset = data.readUInt32LE(endOfCentralDirectory + 16)
if (disk !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== entryCount || entryCount === 0xffff || centralDirectoryBytes === 0xffffffff || offset === 0xffffffff) {
throw new Error("Recipe source zip uses an unsupported central directory")
}

const end = offset + centralDirectoryBytes
if (!Number.isSafeInteger(end) || end > endOfCentralDirectory) throw new Error("Recipe source zip has an invalid central directory range")

const entries: Array<{ name: string; compressedBytes: number; uncompressedBytes: number }> = []
for (let index = 0; index < entryCount; index += 1) {
if (offset + 46 > end || data.readUInt32LE(offset) !== 0x02014b50) throw new Error("Recipe source zip has an invalid central directory entry")
const compressedBytes = data.readUInt32LE(offset + 20)
const uncompressedBytes = data.readUInt32LE(offset + 24)
const nameBytes = data.readUInt16LE(offset + 28)
const extraBytes = data.readUInt16LE(offset + 30)
const commentBytes = data.readUInt16LE(offset + 32)
if (compressedBytes === 0xffffffff || uncompressedBytes === 0xffffffff) throw new Error("Recipe source zip uses an unsupported ZIP64 entry")
entries.push({ name: data.toString("utf8", offset + 46, offset + 46 + nameBytes), compressedBytes, uncompressedBytes })
offset += 46 + nameBytes + extraBytes + commentBytes
}

if (offset !== end) throw new Error("Recipe source zip has an invalid central directory size")
return entries
}

function findEndOfCentralDirectory(data: Buffer): number {
const earliest = Math.max(0, data.length - 0xffff - 22)
for (let offset = data.length - 22; offset >= earliest; offset -= 1) {
if (data.readUInt32LE(offset) === 0x06054b50 && offset + 22 + data.readUInt16LE(offset + 20) === data.length) return offset
}
return -1
}

async function assertExtractedSourceBounds(directory: string): Promise<void> {
async function assertExtractedSourceBounds(directory: string, source: ZipSourceReference): Promise<void> {
const totals = await directoryTotals(directory)
if (totals.files > maxExtractedFiles()) {
throw new Error(`Recipe source extraction contains too many files: ${totals.files}`)
const maxFiles = maxExtractedFilesFor(source)
if (totals.files > maxFiles) {
throw new Error(`Recipe source extraction contains too many files: ${totals.files}; limit ${maxFiles}; archive class ${source.archiveClass ?? "standard"}`)
}
if (totals.bytes > maxExtractedBytes()) {
throw new Error(`Recipe source extraction exceeds ${maxExtractedBytes()} bytes: ${totals.bytes}`)
Expand Down
Loading
Loading