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
23 changes: 12 additions & 11 deletions src/lab/artifacts/secure-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ import {
unlinkSync,
writeSync,
type Stats,
type BigIntStats,
} from "node:fs";
import { join } from "node:path";
import { fileIdentityKey, sameFileIdentity, type FileIdentity } from "../../lib/file-identity";
import {
ARTIFACT_FILENAME_EXT,
MAX_BYTES_PER_ARTIFACT,
Expand Down Expand Up @@ -72,7 +74,7 @@ function openFlags(base: number, noFollow: boolean): number {
return base;
}

function assertRegularFileStats(stats: Stats, label: string): void {
function assertRegularFileStats(stats: Stats | BigIntStats, label: string): void {
if (
!stats.isFile() ||
stats.isSymbolicLink() ||
Expand All @@ -84,19 +86,19 @@ function assertRegularFileStats(stats: Stats, label: string): void {
) {
harnessFailure(`${label}: not a regular file`, "artifact_unsafe_target");
}
if (stats.nlink !== 1) {
if (Number(stats.nlink) !== 1) {
harnessFailure(`${label}: hard links prohibited (nlink=${stats.nlink})`, "artifact_unsafe_target");
}
}

function assertDirectoryStats(stats: Stats, label: string): void {
function assertDirectoryStats(stats: Stats | BigIntStats, label: string): void {
if (!stats.isDirectory() || stats.isSymbolicLink()) {
harnessFailure(`${label}: must be a real directory (no symlink/reparse redirection)`, "artifact_unsafe_target");
}
}

function identityOf(stats: Stats): string {
return `${stats.dev}:${stats.ino}`;
function identityOf(stats: FileIdentity): string {
return fileIdentityKey(stats);
}

function assertRelativeName(name: string): void {
Expand Down Expand Up @@ -154,14 +156,13 @@ function childPath(dir: TrustedArtifactDir, name: string): string {

function assertOpenedPathMatchesDescriptor(dir: TrustedArtifactDir, name: string, fd: number): void {
if (detectArtifactIoMode(dir) !== "win32_pinned") return;
const opened = fstatSync(fd);
const opened = fstatSync(fd, { bigint: true });
assertRegularFileStats(opened, "artifact fd");
const pathEntry = lstatSync(childPath(dir, name));
const pathEntry = lstatSync(childPath(dir, name), { bigint: true });
if (
!pathEntry.isFile() ||
pathEntry.isSymbolicLink() ||
pathEntry.dev !== opened.dev ||
pathEntry.ino !== opened.ino
!sameFileIdentity(pathEntry, opened)
) {
closeSync(fd);
harnessFailure("artifact path identity mismatch after open", "artifact_unsafe_target");
Expand Down Expand Up @@ -202,7 +203,7 @@ function unlinkAtDir(dir: TrustedArtifactDir, name: string): void {
}

function revalidateDir(dir: TrustedArtifactDir): void {
const stats = fstatSync(dir.fd);
const stats = fstatSync(dir.fd, { bigint: true });
assertDirectoryStats(stats, "artifacts dir");
if (identityOf(stats) !== dir.identity) {
harnessFailure("artifacts directory identity changed", "artifact_unsafe_target");
Expand All @@ -225,7 +226,7 @@ export function openTrustedArtifactDir(artifactsDir: string): TrustedArtifactDir
fd = openSync(abs, O_RDONLY);
}

const stats = fstatSync(fd);
const stats = fstatSync(fd, { bigint: true });
assertDirectoryStats(stats, "artifacts dir");
const trusted = { path: abs, fd, identity: identityOf(stats) };
detectArtifactIoMode(trusted);
Expand Down
20 changes: 11 additions & 9 deletions src/lab/fabric/scratch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import {
statSync,
writeSync,
type Stats,
type BigIntStats,
} from "node:fs";
import { dirname, join, posix, relative, resolve, sep } from "node:path";
import { randomBytes } from "node:crypto";
import { fileIdentityKey, type FileIdentity } from "../../lib/file-identity";
import { ensureLabDirs, ensureRestrictedDir, labRoot, labScratchDir } from "../paths";
import { FABRIC_LIMITS, SYNTHETIC_BEFORE_UTF8, SYNTHETIC_VALUE_PATH } from "./constants";
import { FabricTaskError } from "./types";
Expand All @@ -35,22 +37,22 @@ interface TrustedScratchDir {
// inside producer-writable scratch nor its age grants later deletion authority.

/** Require stats to describe a regular file, not a symlink or special node. */
function assertRegularFile(stats: Stats, label: string): void {
function assertRegularFile(stats: Stats | BigIntStats, label: string): void {
if (!stats.isFile() || stats.isSymbolicLink() || stats.isDirectory() || stats.isFIFO() || stats.isSocket() || stats.isCharacterDevice() || stats.isBlockDevice()) {
throw new FabricTaskError(`${label} must be a regular file`, "sandbox_violation", "harness");
}
}

/** Require stats to describe a real directory, not a symlink. */
function assertRealDirectory(stats: Stats, label: string): void {
function assertRealDirectory(stats: Stats | BigIntStats, label: string): void {
if (stats.isSymbolicLink() || !stats.isDirectory()) {
throw new FabricTaskError(`${label} must be a real directory`, "sandbox_violation", "harness");
}
}

/** Stable device/inode identity string for a filesystem node. */
function identityOf(stats: Stats): string {
return `${stats.dev}:${stats.ino}`;
function identityOf(stats: FileIdentity): string {
return fileIdentityKey(stats);
}

/** Whether the host supports O_NOFOLLOW for scratch open operations. */
Expand All @@ -73,7 +75,7 @@ function assertScratchName(name: string): void {

/** Confirm the scratch root path still refers to the same directory inode. */
function revalidateScratchDir(dir: TrustedScratchDir): void {
const stats = lstatSync(dir.path);
const stats = lstatSync(dir.path, { bigint: true });
assertRealDirectory(stats, "scratch dir");
if (identityOf(stats) !== dir.identity) {
throw new FabricTaskError("scratch directory identity changed", "sandbox_violation", "harness");
Expand All @@ -93,12 +95,12 @@ function openAtScratch(dir: TrustedScratchDir, name: string, flags: number, mode
assertScratchName(name);
const path = childScratchPath(dir, name);
const fd = openSync(path, openFlags(flags), mode);
const opened = fstatSync(fd);
const opened = fstatSync(fd, { bigint: true });
if (expectDirectory) {
assertRealDirectory(opened, name);
} else {
assertRegularFile(opened, name);
const pathEntry = lstatSync(path);
const pathEntry = lstatSync(path, { bigint: true });
if (identityOf(opened) !== identityOf(pathEntry)) {
throw new FabricTaskError("scratch path redirection detected", "sandbox_violation", "harness");
}
Expand All @@ -115,7 +117,7 @@ function openTrustedScratchRoot(scratchRoot: string): TrustedScratchDir {
} else {
fd = openSync(abs, fsConstants.O_RDONLY);
}
const stats = fstatSync(fd);
const stats = fstatSync(fd, { bigint: true });
assertRealDirectory(stats, "scratch root");
return { path: abs, fd, identity: identityOf(stats) };
}
Expand Down Expand Up @@ -149,7 +151,7 @@ function openScratchRelativePath(
: openFlags(fsConstants.O_RDONLY, true);
const subFd = openAtScratch(current, part, dirFlags, 0, true);
intermediateFds.push(subFd);
const subStats = fstatSync(subFd);
const subStats = fstatSync(subFd, { bigint: true });
assertRealDirectory(subStats, part);
current = { path: join(current.path, part), fd: subFd, identity: identityOf(subStats) };
continue;
Expand Down
5 changes: 2 additions & 3 deletions src/lab/public/community.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ensureLabDirs, labCommunityDir } from "../paths";
import { validateCommunityEvidenceAuthorities } from "./community-authority";
import { communityBundleFileName } from "./community-files";
import { privateRegularFileSize, readPrivateRegularFile } from "./file-safety";
import { withPublicEvidenceMutationLock } from "./mutation-lock";
import { isMutationLockEntryName, withPublicEvidenceMutationLock } from "./mutation-lock";
import { recordLocalPublicOrigin } from "./origin";
import {
cleanupStalePrivateFileStages,
Expand All @@ -31,7 +31,6 @@ const MAX_DEPTH = 8;
const MAX_OBJECT_KEYS = 64;
const MAX_ARRAY_ELEMENTS = 512;
const MAX_GENERIC_STRING_BYTES = 384 * 1024;
const COMMUNITY_MUTATION_LOCK_NAME = ".mutation-lock";
const COMMUNITY_BUNDLE_FILE_RE = /^bundle-([0-9a-f]{64})-([0-9a-f]{64})\.json$/;
const COMMUNITY_REVOCATION_FILE_RE = /^revocation-([0-9a-f]{64})\.json$/;

Expand Down Expand Up @@ -148,7 +147,7 @@ function cacheUsage(configDir?: string): { names: string[]; bytes: number } {
const dir = labCommunityDir(configDir);
cleanupStalePrivateFileStagesInDir(dir);
const names = readdirSync(dir)
.filter((name) => name !== COMMUNITY_MUTATION_LOCK_NAME && !isPrivateFileStageName(name))
.filter((name) => !isMutationLockEntryName(name) && !isPrivateFileStageName(name))
.sort();
if (names.length > MAX_CACHE_FILES) {
throw new PublicEvidenceValidationError("community_cache_bound", "community cache file bound exceeded");
Expand Down
38 changes: 18 additions & 20 deletions src/lab/public/file-safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
unlinkSync,
} from "node:fs";
import { basename, dirname, join } from "node:path";
import { sameFileIdentity } from "../../lib/file-identity";
import { isPrivateFileStageName } from "./private-file";
import { PublicEvidenceValidationError } from "./validate";

Expand Down Expand Up @@ -38,8 +39,8 @@ function sizeError(options: PrivateRegularFileReadOptions): PublicEvidenceValida
*/
function recoverPublishedPrivateFileStage(path: string): void {
if (process.platform === "win32") return;
const finalStats = lstatSync(path);
if (finalStats.isSymbolicLink() || !finalStats.isFile() || finalStats.nlink <= 1) return;
const finalStats = lstatSync(path, { bigint: true });
if (finalStats.isSymbolicLink() || !finalStats.isFile() || finalStats.nlink <= 1n) return;

const dir = dirname(path);
const prefix = `.${basename(path)}.`;
Expand All @@ -48,12 +49,11 @@ function recoverPublishedPrivateFileStage(path: string): void {
if (!name.startsWith(prefix) || !isPrivateFileStageName(name)) continue;
const stagePath = join(dir, name);
try {
const stageStats = lstatSync(stagePath);
const stageStats = lstatSync(stagePath, { bigint: true });
if (
stageStats.isFile()
&& !stageStats.isSymbolicLink()
&& stageStats.dev === finalStats.dev
&& stageStats.ino === finalStats.ino
&& sameFileIdentity(stageStats, finalStats)
) {
candidates.push(stagePath);
}
Expand All @@ -75,12 +75,11 @@ function recoverPublishedPrivateFileStage(path: string): void {

for (const stagePath of candidates) {
try {
const stageStats = lstatSync(stagePath);
const stageStats = lstatSync(stagePath, { bigint: true });
if (
stageStats.isFile()
&& !stageStats.isSymbolicLink()
&& stageStats.dev === finalStats.dev
&& stageStats.ino === finalStats.ino
&& sameFileIdentity(stageStats, finalStats)
) {
unlinkSync(stagePath);
}
Expand All @@ -95,32 +94,31 @@ function withPrivateRegularFile<T>(
options: PrivateRegularFileReadOptions,
consume: (fd: number, size: number) => T,
): T {
let pathStats = lstatSync(path);
if (!pathStats.isSymbolicLink() && pathStats.isFile() && pathStats.nlink > 1) {
let pathStats = lstatSync(path, { bigint: true });
if (!pathStats.isSymbolicLink() && pathStats.isFile() && Number(pathStats.nlink) > 1) {
recoverPublishedPrivateFileStage(path);
pathStats = lstatSync(path);
pathStats = lstatSync(path, { bigint: true });
}
if (pathStats.isSymbolicLink() || !pathStats.isFile() || pathStats.nlink !== 1) {
if (pathStats.isSymbolicLink() || !pathStats.isFile() || Number(pathStats.nlink) !== 1) {
throw new PublicEvidenceValidationError(options.errorCode, options.errorMessage);
}
if (pathStats.size > options.maxBytes) throw sizeError(options);
if (Number(pathStats.size) > options.maxBytes) throw sizeError(options);

const fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW);
try {
const stats = fstatSync(fd);
const stats = fstatSync(fd, { bigint: true });
if (
!stats.isFile()
|| stats.nlink !== 1
|| stats.dev !== pathStats.dev
|| stats.ino !== pathStats.ino
|| Number(stats.nlink) !== 1
|| !sameFileIdentity(stats, pathStats)
) {
throw new PublicEvidenceValidationError(options.errorCode, options.errorMessage);
}
if (stats.size > options.maxBytes) throw sizeError(options);
if (options.requireMode600 && process.platform !== "win32" && (stats.mode & 0o777) !== 0o600) {
if (Number(stats.size) > options.maxBytes) throw sizeError(options);
if (options.requireMode600 && process.platform !== "win32" && (Number(stats.mode) & 0o777) !== 0o600) {
throw new PublicEvidenceValidationError(options.errorCode, options.errorMessage);
}
return consume(fd, stats.size);
return consume(fd, Number(stats.size));
} finally {
closeSync(fd);
}
Expand Down
Loading
Loading