/** * Minimal, path-traversal-hardened tar extractor for the frp release archive — TASK B3 extraction * step (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain). * * frp's GitHub releases ship a `.tar.gz` whose payload is `frp___/{frpc,frps,...}` — * the host cannot exec a `.tar.gz`, so after `frpcBinary.ts` has VERIFIED the archive's SHA-256 * against its pin it hands the bytes here to pull out ONLY the inner `frpc`. * * SECURITY (§5): this does NOT reconstruct the archive's directory tree on disk. The caller writes * the returned bytes to a FIXED destination it owns; entries are selected purely by matching the * (sanitized) tar name's BASENAME. Any candidate entry whose name is absolute, contains a NUL, or * has a `..` path segment is REJECTED (never silently skipped, so a `../frpc` decoy cannot be used * to derive a path). A malicious tarball can therefore never cause a write outside the caller's dir. * * Format scope: real frp archives are plain USTAR (magic `ustar `) with 100-byte names (no PAX/GNU * long-name/sparse entries) — verified against frp v0.61.1. The parser reads only what USTAR needs: * name (0..100), size octal (124..136), typeflag (156); regular files are typeflag `0` or legacy NUL. */ import { gunzipSync } from 'node:zlib' const TAR_BLOCK = 512 const NAME_OFFSET = 0 const NAME_LEN = 100 const SIZE_OFFSET = 124 const SIZE_LEN = 12 const TYPEFLAG_OFFSET = 156 // USTAR regular-file type flags: '0' (0x30) and the legacy NUL (0x00). Everything else (dir '5', // symlink '2', PAX 'x'/'g', GNU 'L', …) is not a plain file and is skipped when matching. const TYPE_REGULAR = 0x30 const TYPE_LEGACY = 0x00 // Hard ceiling on a single extracted entry. frp's `frpc` is ~14 MB; 256 MB rejects a corrupt/hostile // size field before it can drive a huge allocation or an out-of-bounds read. const MAX_ENTRY_BYTES = 256 * 1024 * 1024 const FRPC_BASENAME = 'frpc' /** A tar/gzip extraction failure (corrupt gzip, malformed/truncated tar, unsafe or missing entry). */ export class TarExtractError extends Error { constructor(message: string) { super(message) this.name = 'TarExtractError' } } /** * True if `name` could escape a destination directory: contains a NUL, is an absolute path, or has * any `..` path segment. Both `/` and `\` are treated as separators (defense in depth). */ function isUnsafeEntryName(name: string): boolean { if (name.length === 0) return true if (name.includes('\0')) return true if (name.startsWith('/') || name.startsWith('\\')) return true return name.split(/[/\\]/).some((segment) => segment === '..') } /** Last `/`- or `\`-separated segment of a tar entry name. */ function basename(name: string): string { const parts = name.split(/[/\\]/) return parts[parts.length - 1] ?? name } /** Decode the NUL-terminated USTAR name field of the header block at `off`. */ function readName(tar: Uint8Array, off: number): string { const raw = tar.subarray(off + NAME_OFFSET, off + NAME_OFFSET + NAME_LEN) const nul = raw.indexOf(0) return new TextDecoder().decode(raw.subarray(0, nul < 0 ? NAME_LEN : nul)) } /** * Read a NUL/space-terminated octal numeric field. Leading spaces are tolerated (GNU pads them); * any non-octal digit yields `NaN` so the caller can reject a corrupt header. */ function readOctal(tar: Uint8Array, start: number, len: number): number { let value = 0 let seenDigit = false for (let i = start; i < start + len; i++) { const c = tar[i] if (c === undefined || c === 0x00 || c === 0x20) { if (seenDigit) break continue } if (c < 0x30 || c > 0x37) return Number.NaN value = value * 8 + (c - 0x30) seenDigit = true } return seenDigit ? value : 0 } /** True if the 512-byte header block at `off` is entirely zero (the end-of-archive marker). */ function isZeroBlock(tar: Uint8Array, off: number): boolean { for (let i = off; i < off + TAR_BLOCK; i++) { if (tar[i] !== 0) return false } return true } /** * Return the bytes of the FIRST regular-file entry whose safe basename equals `targetBasename`. * * Selection is by basename only — the archive's directory structure is never mapped to a path. A * candidate whose name is unsafe (absolute / NUL / `..`) throws rather than being used. Missing * entry, a truncated/corrupt tar, or an over-size entry all throw `TarExtractError` (place nothing). */ export function extractTarFileByBasename(tar: Uint8Array, targetBasename: string): Uint8Array { let off = 0 while (off + TAR_BLOCK <= tar.length) { if (isZeroBlock(tar, off)) break // end-of-archive const size = readOctal(tar, off + SIZE_OFFSET, SIZE_LEN) if (!Number.isInteger(size) || size < 0) { throw new TarExtractError('corrupt tar: invalid entry size field') } if (size > MAX_ENTRY_BYTES) { throw new TarExtractError(`tar entry exceeds max size (${size} > ${MAX_ENTRY_BYTES})`) } const contentStart = off + TAR_BLOCK const contentEnd = contentStart + size if (contentEnd > tar.length) { throw new TarExtractError('corrupt tar: entry content is truncated') } const typeflag = tar[off + TYPEFLAG_OFFSET] const isRegular = typeflag === TYPE_REGULAR || typeflag === TYPE_LEGACY if (isRegular) { const name = readName(tar, off) if (basename(name) === targetBasename) { if (isUnsafeEntryName(name)) { throw new TarExtractError(`refusing unsafe tar entry name: ${JSON.stringify(name)}`) } return tar.slice(contentStart, contentEnd) // copy — never a view into the archive buffer } } // Advance past this entry's content, padded up to the next 512-byte boundary. off = contentEnd + ((TAR_BLOCK - (size % TAR_BLOCK)) % TAR_BLOCK) } throw new TarExtractError(`no "${targetBasename}" file entry found in tar archive`) } /** * Gunzip a verified frp `.tar.gz` and return the inner `frpc` binary's bytes. `gunzipSync` is used * on already-hash-verified input (the pin gate runs first), so there is no attacker-controlled * decompression-bomb surface here; a corrupt/non-gzip payload throws `TarExtractError`. */ export function extractFrpcBinary(archiveGz: Uint8Array): Uint8Array { return extractTarFileByBasename(gunzip(archiveGz), FRPC_BASENAME) } function gunzip(archiveGz: Uint8Array): Uint8Array { try { return new Uint8Array(gunzipSync(archiveGz)) } catch (err: unknown) { const detail = err instanceof Error ? err.message : 'not a gzip stream' throw new TarExtractError(`corrupt frpc archive: gunzip failed (${detail})`) } }