mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-12 23:12:54 +00:00
refactor(security): split gateway auth suites and share safe write path checks
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -11,9 +10,8 @@ import {
|
||||
stripArchivePath,
|
||||
validateArchiveEntryPath,
|
||||
} from "./archive-path.js";
|
||||
import { sameFileIdentity } from "./file-identity.js";
|
||||
import { resolveOpenedFileRealPathForHandle } from "./fs-safe.js";
|
||||
import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-guards.js";
|
||||
import { openWritableFileWithinRoot, SafeOpenError } from "./fs-safe.js";
|
||||
import { isNotFoundPathError, isPathInside } from "./path-guards.js";
|
||||
|
||||
export type ArchiveKind = "tar" | "zip";
|
||||
|
||||
@@ -67,14 +65,6 @@ const ERROR_ARCHIVE_EXTRACTED_SIZE_EXCEEDS_LIMIT = "archive extracted size excee
|
||||
const ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK = "archive entry traverses symlink in destination";
|
||||
|
||||
const TAR_SUFFIXES = [".tgz", ".tar.gz", ".tar"];
|
||||
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
|
||||
const OPEN_WRITE_EXISTING_FLAGS =
|
||||
fsConstants.O_WRONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
||||
const OPEN_WRITE_CREATE_FLAGS =
|
||||
fsConstants.O_WRONLY |
|
||||
fsConstants.O_CREAT |
|
||||
fsConstants.O_EXCL |
|
||||
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
||||
|
||||
export function resolveArchiveKind(filePath: string): ArchiveKind | null {
|
||||
const lower = filePath.toLowerCase();
|
||||
@@ -288,93 +278,28 @@ type OpenZipOutputFileResult = {
|
||||
};
|
||||
|
||||
async function openZipOutputFile(params: {
|
||||
outPath: string;
|
||||
relPath: string;
|
||||
originalPath: string;
|
||||
destinationRealDir: string;
|
||||
}): Promise<OpenZipOutputFileResult> {
|
||||
let ioPath = params.outPath;
|
||||
try {
|
||||
const resolvedRealPath = await fs.realpath(params.outPath);
|
||||
if (!isPathInside(params.destinationRealDir, resolvedRealPath)) {
|
||||
throw symlinkTraversalError(params.originalPath);
|
||||
}
|
||||
ioPath = resolvedRealPath;
|
||||
return await openWritableFileWithinRoot({
|
||||
rootDir: params.destinationRealDir,
|
||||
relativePath: params.relPath,
|
||||
mkdir: false,
|
||||
mode: 0o666,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ArchiveSecurityError) {
|
||||
throw err;
|
||||
}
|
||||
if (!isNotFoundPathError(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let handle: FileHandle;
|
||||
let createdForWrite = false;
|
||||
try {
|
||||
try {
|
||||
handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, 0o666);
|
||||
} catch (err) {
|
||||
if (!isNotFoundPathError(err)) {
|
||||
throw err;
|
||||
}
|
||||
handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, 0o666);
|
||||
createdForWrite = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSymlinkOpenError(err)) {
|
||||
if (
|
||||
err instanceof SafeOpenError &&
|
||||
(err.code === "invalid-path" ||
|
||||
err.code === "outside-workspace" ||
|
||||
err.code === "path-mismatch")
|
||||
) {
|
||||
throw symlinkTraversalError(params.originalPath);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let openedRealPath: string | null = null;
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
throw symlinkTraversalError(params.originalPath);
|
||||
}
|
||||
|
||||
try {
|
||||
const lstat = await fs.lstat(ioPath);
|
||||
if (lstat.isSymbolicLink() || !lstat.isFile()) {
|
||||
throw symlinkTraversalError(params.originalPath);
|
||||
}
|
||||
if (!sameFileIdentity(stat, lstat)) {
|
||||
throw symlinkTraversalError(params.originalPath);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isNotFoundPathError(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const realPath = await resolveOpenedFileRealPathForHandle(handle, ioPath);
|
||||
openedRealPath = realPath;
|
||||
const realStat = await fs.stat(realPath);
|
||||
if (!sameFileIdentity(stat, realStat)) {
|
||||
throw symlinkTraversalError(params.originalPath);
|
||||
}
|
||||
if (!isPathInside(params.destinationRealDir, realPath)) {
|
||||
throw symlinkTraversalError(params.originalPath);
|
||||
}
|
||||
|
||||
// Truncate only after identity + boundary checks complete.
|
||||
if (!createdForWrite) {
|
||||
await handle.truncate(0);
|
||||
}
|
||||
|
||||
return {
|
||||
handle,
|
||||
createdForWrite,
|
||||
openedRealPath: realPath,
|
||||
};
|
||||
} catch (err) {
|
||||
if (createdForWrite && openedRealPath) {
|
||||
await fs.rm(openedRealPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
await handle.close().catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupPartialRegularFile(filePath: string): Promise<void> {
|
||||
@@ -467,12 +392,12 @@ async function prepareZipOutputPath(params: {
|
||||
|
||||
async function writeZipFileEntry(params: {
|
||||
entry: ZipEntry;
|
||||
outPath: string;
|
||||
relPath: string;
|
||||
destinationRealDir: string;
|
||||
budget: ZipExtractBudget;
|
||||
}): Promise<void> {
|
||||
const opened = await openZipOutputFile({
|
||||
outPath: params.outPath,
|
||||
relPath: params.relPath,
|
||||
originalPath: params.entry.name,
|
||||
destinationRealDir: params.destinationRealDir,
|
||||
});
|
||||
@@ -558,7 +483,7 @@ async function extractZip(params: {
|
||||
|
||||
await writeZipFileEntry({
|
||||
entry,
|
||||
outPath: output.outPath,
|
||||
relPath: output.relPath,
|
||||
destinationRealDir,
|
||||
budget,
|
||||
});
|
||||
|
||||
@@ -321,6 +321,7 @@ export async function openWritableFileWithinRoot(params: {
|
||||
rootDir: string;
|
||||
relativePath: string;
|
||||
mkdir?: boolean;
|
||||
mode?: number;
|
||||
}): Promise<SafeWritableOpenResult> {
|
||||
const { rootReal, rootWithSep, resolved } = await resolvePathWithinRoot(params);
|
||||
try {
|
||||
@@ -352,16 +353,18 @@ export async function openWritableFileWithinRoot(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const fileMode = params.mode ?? 0o600;
|
||||
|
||||
let handle: FileHandle;
|
||||
let createdForWrite = false;
|
||||
try {
|
||||
try {
|
||||
handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, 0o600);
|
||||
handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, fileMode);
|
||||
} catch (err) {
|
||||
if (!isNotFoundPathError(err)) {
|
||||
throw err;
|
||||
}
|
||||
handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, 0o600);
|
||||
handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, fileMode);
|
||||
createdForWrite = true;
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user