From 03b6b891c4cfe0eb29e0196efcb6d23a728986d7 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 10:33:46 +0000 Subject: [PATCH] fix: harden apply-patch v2 remote reads --- scripts/src/apply-patch-v2.ts | 58 +++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/scripts/src/apply-patch-v2.ts b/scripts/src/apply-patch-v2.ts index 37dc4dd6..89a9992f 100644 --- a/scripts/src/apply-patch-v2.ts +++ b/scripts/src/apply-patch-v2.ts @@ -281,6 +281,7 @@ async function executePlannedOperation(executor: ApplyPatchV2Executor, operation const readBlockBytes = 45_000; const writeB64ArgvLimit = 48_000; const writeB64ChunkChars = 12_000; +const remoteReadBlockMarker = "UNIDESK_APPLY_PATCH_V2_BLOCK"; async function readRemoteText(executor: ApplyPatchV2Executor, target: string): Promise { if (executor.fs) { @@ -333,8 +334,8 @@ async function readRemoteText(executor: ApplyPatchV2Executor, target: string): P let actualBytes = 0; for (let blockIndex = 0; actualBytes < expectedBytes; blockIndex += 1) { const read = await checkedRemoteV2(executor, "read-b64-block", [target, String(blockIndex), String(readBlockBytes)]); - const encoded = read.stdout.replace(/\s+/gu, ""); - const chunk = encoded.length === 0 ? Buffer.alloc(0) : Buffer.from(encoded, "base64"); + const expectedChunkBytes = Math.min(readBlockBytes, expectedBytes - actualBytes); + const chunk = decodeRemoteReadBlock(read.stdout, target, blockIndex, expectedChunkBytes); if (chunk.length === 0) { throw new ApplyPatchV2Error("remote apply-patch v2 read returned an empty block before EOF", { path: target, @@ -366,6 +367,50 @@ async function readRemoteText(executor: ApplyPatchV2Executor, target: string): P return contentBuffer.toString("utf8"); } +function decodeRemoteReadBlock(stdout: string, target: string, blockIndex: number, expectedChunkBytes: number): Buffer { + const lines = stdout.split(/\r?\n/u); + const markerIndex = lines.findIndex((line) => line.startsWith(`${remoteReadBlockMarker} `)); + let encoded = ""; + let declaredBytes: number | null = null; + let declaredSha256: string | null = null; + if (markerIndex >= 0) { + const [, bytesText, sha256] = lines[markerIndex].split(/\s+/u); + declaredBytes = Number(bytesText); + declaredSha256 = sha256 ?? null; + if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0 || !/^[0-9a-f]{64}$/u.test(declaredSha256 ?? "")) { + throw new ApplyPatchV2Error("remote apply-patch v2 read block returned invalid metadata", { + path: target, + blockIndex, + marker: lines[markerIndex].slice(0, 200), + }); + } + encoded = (lines[markerIndex + 1] ?? "").replace(/\s+/gu, ""); + } else { + encoded = stdout.replace(/\s+/gu, ""); + } + const decoded = encoded.length === 0 ? Buffer.alloc(0) : Buffer.from(encoded, "base64"); + if (declaredBytes !== null) { + if (decoded.length !== declaredBytes) { + throw new ApplyPatchV2Error("remote apply-patch v2 read block byte count mismatch", { + path: target, + blockIndex, + expectedBytes: declaredBytes, + actualBytes: decoded.length, + }); + } + const actualSha256 = sha256Hex(decoded); + if (actualSha256 !== declaredSha256) { + throw new ApplyPatchV2Error("remote apply-patch v2 read block sha256 mismatch", { + path: target, + blockIndex, + expectedSha256: declaredSha256, + actualSha256, + }); + } + } + return decoded.length > expectedChunkBytes ? decoded.subarray(0, expectedChunkBytes) : decoded; +} + async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, content: string): Promise { const contentBuffer = Buffer.from(content, "utf8"); if (executor.fs) { @@ -473,7 +518,14 @@ function remoteV2Script(operation: RemoteV2Operation, args: string[]): string[] " block_index=$2", " block_size=$3", " case \"$block_index:$block_size\" in *[!0-9:]*|:*) printf 'invalid read block args\\n' >&2; exit 2;; esac", - " dd if=\"$target\" bs=\"$block_size\" skip=\"$block_index\" count=1 2>/dev/null | base64 | tr -d '\\n'", + " tmp=$(mktemp)", + " dd if=\"$target\" bs=\"$block_size\" skip=\"$block_index\" count=1 2>/dev/null > \"$tmp\"", + " bytes=$(wc -c < \"$tmp\" | tr -d '[:space:]')", + " digest=$(sha256_file \"$tmp\")", + ` printf '${remoteReadBlockMarker} %s %s\\n' "$bytes" "$digest"`, + " base64 < \"$tmp\" | tr -d '\\n'", + " printf '\\n'", + " rm -f -- \"$tmp\"", " ;;", " write-b64-argv)", " target=$1",