Skip to content

Security

binpatch is the choke point between an attacker-controlled patch and your binary. This page documents the threats we considered, the controls we ship by default, and what you must add on top.

Attacker Capability Defense
Anyone with a write to your patch registry Push a malicious TRDIFF10 patch SHA-256 verification on the final output
Anyone with a write to your registry Push a patch with a huge newSize to OOM the consumer MAX_OUTPUT_SIZE = 2 GiB cap pre-allocation
Anyone with a write to your registry Push a patch that causes an infinite apply loop MAX_NIGHTLY_CHAIN_DEPTH = 30, MAX_STABLE_CHAIN_DEPTH = 10
Network attacker on TLS path Stall blob downloads indefinitely Per-HTTP AbortSignal.timeout(30_000)
Attacker on your user’s machine Swap the old binary to a smaller file mid-apply Apply reads from in-memory Uint8Array, not the file
Attacker with a custom-CA TLS interception Re-route your OCI requests to a fake registry OciClient and ghcrSource accept an injected fetch — pass your TLS-aware fetch (e.g. undici with a custom Agent and your CA bundle)

applyPatchChainInMemory returns the SHA-256 (hex) of the final output directly. Consumers compare it against the expected SHA-256 (from the patch manifest’s sha256-<binaryName> annotation, or the GitHub Release asset). If they don’t match, throw and fall back to a full download.

import { applyPatchChainInMemory } from "binpatch";
const sha256 = await applyPatchChainInMemory(
"/usr/local/bin/myapp", // oldPath: file on disk
chain, // patches: Uint8Array[]
"/usr/local/bin/myapp.new", // destPath: written with the final binary
);
if (sha256 !== expectedSha256) {
// Patch is corrupt OR a different patch than expected.
// Throw, fall back to full download, alert.
throw new Error(`SHA-256 mismatch: got ${sha256}, expected ${expectedSha256}`);
}

The library does not verify per-hop intermediate hashes; it trusts the final result. This is intentional: each hop’s from-version annotation is a chain-pointer, not a content hash. A malicious hop that corrupts intermediate output will fail the final hash check anyway.

MAX_OUTPUT_SIZE (2 GiB pre-allocation guard)

Section titled “MAX_OUTPUT_SIZE (2 GiB pre-allocation guard)”

parsePatchHeader rejects any patch whose declared newSize exceeds 2 GiB (MAX_OUTPUT_SIZE = 2_147_483_648 bytes) before allocating the output buffer. Without this, a 2 GiB-attacker-controlled newSize would force a pre-verification Uint8Array allocation that OOMs the consumer.

import { parsePatchHeader, MAX_OUTPUT_SIZE } from "binpatch";
try {
const header = parsePatchHeader(patchBytes);
// header.newSize is guaranteed to be <= MAX_OUTPUT_SIZE
} catch (e) {
// "Invalid TRDIFF10 patch: newSize exceeds maximum (2147483648)"
}

We chose 2 GiB because:

  • Real-world self-updating CLI binaries are typically 100–500 MB.
  • Even a hypothetical 10× growth from a 100 MB binary tops out at ~3 GB, which is above our cap — but legitimate patches in that range are not the consumers of binpatch’s simple apply path. If you ship multi-GB binaries, set MAX_OUTPUT_SIZE higher in your consumer (the constant is exported for this reason).

A chain with 100 hops is a sign of either a runaway agent pushing versions, or a malicious chain attempting to pin the consumer in a long loop. We cap:

  • MAX_NIGHTLY_CHAIN_DEPTH = 30 — nightly publishes are frequent, 30 hops covers a year of monthly releases.
  • MAX_STABLE_CHAIN_DEPTH = 10 — stable publishes are rare, 10 hops covers a year of monthly releases.

If a chain exceeds the depth, the resolution returns null with reason "too_long". Consumers fall back to full download.

SIZE_THRESHOLD_RATIO = 0.6 — if the sum of patch sizes in a chain exceeds 60% of the new binary size, the resolution returns null with reason "over_budget". This is a defense against a chain that “looks like” a full re-derivation — if you’re going to push 60%+ of the new binary anyway, the user might as well download it as a single file.

Every HTTP call inside the library (fetchManifest, downloadBlob, redirect fetches) is wrapped in buildSignal(timeout, externalSignal):

  • Default request timeout: 10 seconds
  • Default blob timeout: 30 seconds (gzipped binaries are larger)

If the external signal is passed (e.g. from a higher-level cancel), the effective deadline is whichever fires first. Redirect fetches that follow a 3xx response carry the same timeout wrapper — a stalled Azure redirect (or similar) cannot hang the apply.

binpatch does not provide:

  • Patch authenticity — that’s the consumer’s job (sign your patches, verify signatures on receipt).
  • TLS configuration — pass an appropriate fetch implementation (undici with your CA bundle, node-fetch with a custom agent, etc.). See Custom fetch / CA.
  • Sandboxing the apply — if you don’t trust the patch data, run resolveAndApply in a worker thread with limited memory.