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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Reject synchronous secret reads when the path is retargeted after the preview check, matching the asynchronous reader's `path-mismatch` contract instead of returning bytes from the replacement file.
- Preserve dangling symlinks when trash moves cross filesystems instead of failing while following their missing targets.
- Reject non-canonical FileStore keys and malformed archive names before filesystem access, keep JavaScript/native TAR and ZIP rejection semantics aligned (including full-width base-256 sizes and empty ZIP files), and add deterministic property-based regression coverage for path aliasing, parser boundaries, collisions, truncation, and extraction limits.

## 0.5.2 - 2026-08-02

Expand Down
35 changes: 35 additions & 0 deletions native/src/tar_meter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ impl<R> TarMetadataMeter<R> {

fn parse_size(field: &[u8; 12]) -> io::Result<u64> {
if field[0] & 0x80 != 0 {
if field[0] != 0x80 || field[1..4].iter().any(|byte| *byte != 0) {
return Err(Self::invalid("base-256 size is negative or overflows u64"));
}
let mut value = 0_u64;
for byte in &field[4..] {
value = value
Expand Down Expand Up @@ -80,6 +83,18 @@ impl<R> TarMetadataMeter<R> {
self.block_len = 0;
return Ok(());
}
let name_end = self.block[..100]
.iter()
.position(|byte| *byte == 0)
.unwrap_or(100);
if name_end == 0 {
return Err(Self::invalid("entry path is empty"));
}
if self.block[156] != b'5' && self.block[..name_end].last() == Some(&b'/') {
return Err(Self::invalid(
"non-directory entry path ends with a separator",
));
}
let size = Self::parse_size(self.block[124..136].try_into().unwrap())?;
let padded = Self::padded_size(size)?;
let entry_type = self.block[156];
Expand Down Expand Up @@ -263,4 +278,24 @@ mod tests {
.contains(INVALID_HEADER)
);
}

#[test]
fn rejects_base_256_sizes_with_nonzero_high_order_bytes() {
for offset in 1..4 {
let mut block = header(b'0', 0, true);
block[124 + offset] = 1;
let error = consume(block.to_vec(), 1024).unwrap_err();
assert!(error.to_string().contains(INVALID_HEADER));
assert!(error.to_string().contains("overflows u64"));
}
}

#[test]
fn rejects_an_empty_entry_path() {
let mut block = header(b'0', 0, false);
block[..100].fill(0);
let error = consume(block.to_vec(), 1024).unwrap_err();
assert!(error.to_string().contains(INVALID_HEADER));
assert!(error.to_string().contains("entry path is empty"));
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
"@napi-rs/cli": "3.8.2",
"@types/node": "^26.1.2",
"@vitest/coverage-v8": "4.1.10",
"fast-check": "^4.9.0",
"sigstore": "5.0.0",
"typescript": "^7.0.2",
"vite": "8.2.0",
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 21 additions & 1 deletion src/archive-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,21 @@ export function validateArchiveEntryPath(
`archive entry contains a NUL byte: ${formatErrorDetail(entryPath)}`,
);
}
const normalized = path.posix.normalize(normalizeArchiveEntryPath(entryPath));
const slashNormalized = normalizeArchiveEntryPath(entryPath);
const normalized = path.posix.normalize(slashNormalized);
if (
normalized.split("/").some((segment) =>
Math.max(
Buffer.byteLength(segment.normalize("NFC")),
Buffer.byteLength(segment.normalize("NFD")),
) > 255
)
) {
throw new ArchiveSecurityError(
"entry-path",
`archive entry has an overlong path component: ${formatErrorDetail(entryPath)}`,
);
}
const escapeLabel = params?.escapeLabel ?? "destination";
if (normalized === ".." || normalized.startsWith("../")) {
throw new ArchiveSecurityError(
Expand All @@ -46,6 +60,12 @@ export function validateArchiveEntryPath(
`archive entry is absolute: ${formatErrorDetail(entryPath)}`,
);
}
if (slashNormalized.split("/").includes("..")) {
throw new ArchiveSecurityError(
"entry-path",
`archive entry contains a parent segment: ${formatErrorDetail(entryPath)}`,
);
}
}

export function stripArchivePath(entryPath: string, stripComponents: number): string | null {
Expand Down
7 changes: 7 additions & 0 deletions src/archive-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ export class ArchiveFormatError extends Error {
this.code = "archive-header-invalid";
}
}

export function isArchiveFormatErrorMessage(message: string): boolean {
return (
message.includes("archive-header-invalid") ||
message.includes("archive entry size did not match its manifest")
);
}
13 changes: 10 additions & 3 deletions src/archive-native.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import { ArchiveFormatError, ArchiveSecurityError } from "./archive-errors.js";
import {
ArchiveFormatError,
ArchiveSecurityError,
isArchiveFormatErrorMessage,
} from "./archive-errors.js";
import { formatErrorDetail } from "./error-detail.js";
import {
createArchiveOutputPathTracker,
Expand Down Expand Up @@ -40,9 +44,12 @@ function throwMappedNativeError(error: unknown): never {
for (const code of Object.values(ARCHIVE_LIMIT_ERROR_CODE)) {
if (error.message.includes(code)) throw new ArchiveLimitError(code);
}
if (error.message.includes("archive-header-invalid")) {
if (isArchiveFormatErrorMessage(error.message)) {
throw new ArchiveFormatError(error.message, { cause: error });
}
if ((error as Error & { code?: unknown }).code === "InvalidArg") {
throw new ArchiveFormatError(`invalid archive: ${error.message}`, { cause: error });
}
}
throw error;
}
Expand Down Expand Up @@ -163,7 +170,7 @@ export async function extractNativeArchive(params: {
plan,
limits.maxMetaEntryBytes,
params.deadline.signal,
);
).catch(throwMappedNativeError);
} finally {
await directory.close().catch(() => undefined);
}
Expand Down
101 changes: 57 additions & 44 deletions src/archive-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import fsSync from "node:fs";
import fs from "node:fs/promises";
import { Readable } from "node:stream";
import { readFileHandleBounded } from "./bounded-read.js";
import { ArchiveFormatError, ArchiveSecurityError } from "./archive-errors.js";
import {
ArchiveFormatError,
ArchiveSecurityError,
isArchiveFormatErrorMessage,
} from "./archive-errors.js";
import { formatErrorDetail } from "./error-detail.js";
import {
normalizeArchiveEntryPath,
Expand All @@ -18,9 +22,12 @@ import {
} from "./archive-limits.js";
import { readTarEntryInfo } from "./archive-tar.js";
import { preflightTarMetadata } from "./archive-tar-meta.js";
import { importOptionalTar } from "./archive-tar-runtime.js";
import { importOptionalTar, normalizeTarParserError } from "./archive-tar-runtime.js";
import { loadZipArchiveWithPreflight } from "./archive-zip-preflight.js";
import { createZipIntegrityTransform } from "./archive-zip-integrity.js";
import {
createZipIntegrityTransform,
normalizeZipIntegrityError,
} from "./archive-zip-integrity.js";
import type { ZipEntry } from "./archive-zip-entry.js";
import { FsSafeError } from "./errors.js";
import { sameFileIdentity } from "./file-identity.js";
Expand Down Expand Up @@ -133,11 +140,13 @@ async function readZipEntry(buffer: Buffer, entryPath: string, maxBytes: number)
) {
throw new Error(`archive entry is a link: ${formatErrorDetail(entryPath)}`);
}
const stream =
const stream: NodeJS.ReadableStream =
typeof entry.nodeStream === "function"
? entry.nodeStream()
: Readable.from(await entry.async("nodebuffer"));
return await readStreamBounded(stream.pipe(createZipIntegrityTransform(entry)), maxBytes);
const integrity = createZipIntegrityTransform(entry);
stream.once("error", (error: Error) => integrity.destroy(normalizeZipIntegrityError(error)));
return await readStreamBounded(stream.pipe(integrity), maxBytes);
}

async function readTarEntry(archivePath: string, entryPath: string, maxBytes: number): Promise<Buffer> {
Expand All @@ -149,44 +158,48 @@ async function readTarEntry(archivePath: string, entryPath: string, maxBytes: nu
let matched: Promise<Buffer> | undefined;
let entryError: Error | undefined;
const seenPaths = new Set<string>();
await tar.t({
file: archivePath,
strict: true,
maxMetaEntrySize: DEFAULT_MAX_META_ENTRY_BYTES,
onReadEntry(entry) {
const info = readTarEntryInfo(entry);
validateArchiveEntryPath(info.path, { escapeLabel: "archive root" });
const normalized = normalizeArchiveEntryPath(info.path).replace(/^\.\//, "");
if (seenPaths.has(normalized)) {
entryError ??= new ArchiveSecurityError(
"entry-path",
`archive contains duplicate entry path: ${formatErrorDetail(normalized)}`,
);
entry.resume();
return;
}
seenPaths.add(normalized);
if (normalized !== entryPath) {
entry.resume();
return;
}
if (info.type !== "File" && info.type !== "OldFile" && info.type !== "ContiguousFile") {
entryError ??= new Error(
`archive entry is not a file: ${formatErrorDetail(entryPath)}`,
);
entry.resume();
return;
}
if (info.size > maxBytes) {
entryError ??= new ArchiveLimitError(
ARCHIVE_LIMIT_ERROR_CODE.ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT,
);
entry.resume();
return;
}
matched = readStreamBounded(entry, maxBytes);
},
});
try {
await tar.t({
file: archivePath,
strict: true,
maxMetaEntrySize: DEFAULT_MAX_META_ENTRY_BYTES,
onReadEntry(entry) {
const info = readTarEntryInfo(entry);
validateArchiveEntryPath(info.path, { escapeLabel: "archive root" });
const normalized = normalizeArchiveEntryPath(info.path).replace(/^\.\//, "");
if (seenPaths.has(normalized)) {
entryError ??= new ArchiveSecurityError(
"entry-path",
`archive contains duplicate entry path: ${formatErrorDetail(normalized)}`,
);
entry.resume();
return;
}
seenPaths.add(normalized);
if (normalized !== entryPath) {
entry.resume();
return;
}
if (info.type !== "File" && info.type !== "OldFile" && info.type !== "ContiguousFile") {
entryError ??= new Error(
`archive entry is not a file: ${formatErrorDetail(entryPath)}`,
);
entry.resume();
return;
}
if (info.size > maxBytes) {
entryError ??= new ArchiveLimitError(
ARCHIVE_LIMIT_ERROR_CODE.ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT,
);
entry.resume();
return;
}
matched = readStreamBounded(entry, maxBytes);
},
});
} catch (error) {
throw normalizeTarParserError(error);
}
if (entryError) {
throw entryError;
}
Expand Down Expand Up @@ -265,7 +278,7 @@ export async function readArchiveEntry(
ARCHIVE_LIMIT_ERROR_CODE.ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT,
);
}
if (error instanceof Error && error.message.includes("archive-header-invalid")) {
if (error instanceof Error && isArchiveFormatErrorMessage(error.message)) {
throw new ArchiveFormatError(error.message, { cause: error });
}
throw error;
Expand Down
8 changes: 8 additions & 0 deletions src/archive-tar-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ class TarMetadataMeter extends Transform {
this.state = { kind: "header" };
return;
}
const nameEnd = this.block.subarray(0, 100).indexOf(0);
const name = this.block.subarray(0, nameEnd < 0 ? 100 : nameEnd);
if (name.length === 0) {
throw this.invalid("entry path is empty");
}
if (this.block[156] !== 0x35 && name.at(-1) === 0x2f) {
throw this.invalid("non-directory entry path ends with a separator");
}
const size = this.parseSize();
const type = this.block[156];
if ([0x78, 0x67, 0x4c, 0x4b, 0x58].includes(type ?? -1) && size > this.maxMetaEntryBytes) {
Expand Down
13 changes: 13 additions & 0 deletions src/archive-tar-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ArchiveFormatError } from "./archive-errors.js";

export type TarParserEntry = {
meta?: boolean;
size: number;
Expand Down Expand Up @@ -47,3 +49,14 @@ export async function importOptionalTar(): Promise<TarModule> {
);
}
}

export function normalizeTarParserError(error: unknown): unknown {
const code = (error as { code?: unknown } | null)?.code;
if (typeof code !== "string" || !code.startsWith("TAR_")) {
return error;
}
const message = error instanceof Error ? error.message : String(error);
return new ArchiveFormatError(`invalid TAR archive: ${message}`, {
cause: error instanceof Error ? error : undefined,
});
}
1 change: 1 addition & 0 deletions src/archive-tar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export function createTarEntryPreflightChecker(params: {
);
}

budget.startEntry();
budget.addEntrySize(entry.size);
return true;
};
Expand Down
Loading