mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 10:21:24 +00:00
refactor(gateway): share safe avatar file open checks
This commit is contained in:
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { resolveControlUiRootSync } from "../infra/control-ui-assets.js";
|
import { resolveControlUiRootSync } from "../infra/control-ui-assets.js";
|
||||||
import { isWithinDir } from "../infra/path-safety.js";
|
import { isWithinDir } from "../infra/path-safety.js";
|
||||||
|
import { openVerifiedFileSync } from "../infra/safe-open-sync.js";
|
||||||
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
|
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
|
||||||
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
|
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
|
||||||
import {
|
import {
|
||||||
@@ -220,84 +221,40 @@ function isExpectedSafePathError(error: unknown): boolean {
|
|||||||
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
|
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
|
||||||
}
|
}
|
||||||
|
|
||||||
function areSameFileIdentity(preOpen: fs.Stats, opened: fs.Stats): boolean {
|
|
||||||
return preOpen.dev === opened.dev && preOpen.ino === opened.ino;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSafeAvatarFile(filePath: string): { path: string; fd: number } | null {
|
function resolveSafeAvatarFile(filePath: string): { path: string; fd: number } | null {
|
||||||
let fd: number | null = null;
|
const opened = openVerifiedFileSync({
|
||||||
try {
|
filePath,
|
||||||
const candidateStat = fs.lstatSync(filePath);
|
rejectPathSymlink: true,
|
||||||
if (candidateStat.isSymbolicLink()) {
|
maxBytes: AVATAR_MAX_BYTES,
|
||||||
return null;
|
});
|
||||||
}
|
if (!opened.ok) {
|
||||||
const fileReal = fs.realpathSync(filePath);
|
|
||||||
const preOpenStat = fs.lstatSync(fileReal);
|
|
||||||
if (!preOpenStat.isFile() || preOpenStat.size > AVATAR_MAX_BYTES) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const openFlags =
|
|
||||||
fs.constants.O_RDONLY |
|
|
||||||
(typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
|
|
||||||
fd = fs.openSync(fileReal, openFlags);
|
|
||||||
const openedStat = fs.fstatSync(fd);
|
|
||||||
if (
|
|
||||||
!openedStat.isFile() ||
|
|
||||||
openedStat.size > AVATAR_MAX_BYTES ||
|
|
||||||
!areSameFileIdentity(preOpenStat, openedStat)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const safeFile = { path: fileReal, fd };
|
|
||||||
fd = null;
|
|
||||||
return safeFile;
|
|
||||||
} catch {
|
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
|
||||||
if (fd !== null) {
|
|
||||||
fs.closeSync(fd);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return { path: opened.path, fd: opened.fd };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSafeControlUiFile(
|
function resolveSafeControlUiFile(
|
||||||
rootReal: string,
|
rootReal: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
): { path: string; fd: number } | null {
|
): { path: string; fd: number } | null {
|
||||||
let fd: number | null = null;
|
|
||||||
try {
|
try {
|
||||||
const fileReal = fs.realpathSync(filePath);
|
const fileReal = fs.realpathSync(filePath);
|
||||||
if (!isContainedPath(rootReal, fileReal)) {
|
if (!isContainedPath(rootReal, fileReal)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const opened = openVerifiedFileSync({ filePath: fileReal, resolvedPath: fileReal });
|
||||||
const preOpenStat = fs.lstatSync(fileReal);
|
if (!opened.ok) {
|
||||||
if (!preOpenStat.isFile()) {
|
if (opened.reason === "io") {
|
||||||
|
throw opened.error;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return { path: opened.path, fd: opened.fd };
|
||||||
const openFlags =
|
|
||||||
fs.constants.O_RDONLY |
|
|
||||||
(typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
|
|
||||||
fd = fs.openSync(fileReal, openFlags);
|
|
||||||
const openedStat = fs.fstatSync(fd);
|
|
||||||
// Compare inode identity so swaps between validation and open are rejected.
|
|
||||||
if (!openedStat.isFile() || !areSameFileIdentity(preOpenStat, openedStat)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = { path: fileReal, fd };
|
|
||||||
fd = null;
|
|
||||||
return resolved;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isExpectedSafePathError(error)) {
|
if (isExpectedSafePathError(error)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
if (fd !== null) {
|
|
||||||
fs.closeSync(fd);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
type SessionScope,
|
type SessionScope,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
|
import { openVerifiedFileSync } from "../infra/safe-open-sync.js";
|
||||||
import {
|
import {
|
||||||
normalizeAgentId,
|
normalizeAgentId,
|
||||||
normalizeMainKey,
|
normalizeMainKey,
|
||||||
@@ -75,10 +76,6 @@ function tryResolveExistingPath(value: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function areSameFileIdentity(preOpen: fs.Stats, opened: fs.Stats): boolean {
|
|
||||||
return preOpen.dev === opened.dev && preOpen.ino === opened.ino;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveIdentityAvatarUrl(
|
function resolveIdentityAvatarUrl(
|
||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
@@ -103,37 +100,28 @@ function resolveIdentityAvatarUrl(
|
|||||||
if (!isPathWithinRoot(workspaceRoot, resolvedCandidate)) {
|
if (!isPathWithinRoot(workspaceRoot, resolvedCandidate)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
let fd: number | null = null;
|
|
||||||
try {
|
try {
|
||||||
const resolvedReal = fs.realpathSync(resolvedCandidate);
|
const resolvedReal = fs.realpathSync(resolvedCandidate);
|
||||||
if (!isPathWithinRoot(workspaceRoot, resolvedReal)) {
|
if (!isPathWithinRoot(workspaceRoot, resolvedReal)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const preOpenStat = fs.lstatSync(resolvedReal);
|
const opened = openVerifiedFileSync({
|
||||||
if (!preOpenStat.isFile() || preOpenStat.size > AVATAR_MAX_BYTES) {
|
filePath: resolvedReal,
|
||||||
|
resolvedPath: resolvedReal,
|
||||||
|
maxBytes: AVATAR_MAX_BYTES,
|
||||||
|
});
|
||||||
|
if (!opened.ok) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const openFlags =
|
try {
|
||||||
fs.constants.O_RDONLY |
|
const buffer = fs.readFileSync(opened.fd);
|
||||||
(typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
|
const mime = resolveAvatarMime(resolvedCandidate);
|
||||||
fd = fs.openSync(resolvedReal, openFlags);
|
return `data:${mime};base64,${buffer.toString("base64")}`;
|
||||||
const openedStat = fs.fstatSync(fd);
|
} finally {
|
||||||
if (
|
fs.closeSync(opened.fd);
|
||||||
!openedStat.isFile() ||
|
|
||||||
openedStat.size > AVATAR_MAX_BYTES ||
|
|
||||||
!areSameFileIdentity(preOpenStat, openedStat)
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
const buffer = fs.readFileSync(fd);
|
|
||||||
const mime = resolveAvatarMime(resolvedCandidate);
|
|
||||||
return `data:${mime};base64,${buffer.toString("base64")}`;
|
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
} finally {
|
|
||||||
if (fd !== null) {
|
|
||||||
fs.closeSync(fd);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
72
src/infra/safe-open-sync.ts
Normal file
72
src/infra/safe-open-sync.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
export type SafeOpenSyncFailureReason = "path" | "validation" | "io";
|
||||||
|
|
||||||
|
export type SafeOpenSyncResult =
|
||||||
|
| { ok: true; path: string; fd: number; stat: fs.Stats }
|
||||||
|
| { ok: false; reason: SafeOpenSyncFailureReason; error?: unknown };
|
||||||
|
|
||||||
|
const OPEN_READ_FLAGS =
|
||||||
|
fs.constants.O_RDONLY |
|
||||||
|
(typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
|
||||||
|
|
||||||
|
function isExpectedPathError(error: unknown): boolean {
|
||||||
|
const code =
|
||||||
|
typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
|
||||||
|
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean {
|
||||||
|
return left.dev === right.dev && left.ino === right.ino;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openVerifiedFileSync(params: {
|
||||||
|
filePath: string;
|
||||||
|
resolvedPath?: string;
|
||||||
|
rejectPathSymlink?: boolean;
|
||||||
|
maxBytes?: number;
|
||||||
|
}): SafeOpenSyncResult {
|
||||||
|
let fd: number | null = null;
|
||||||
|
try {
|
||||||
|
if (params.rejectPathSymlink) {
|
||||||
|
const candidateStat = fs.lstatSync(params.filePath);
|
||||||
|
if (candidateStat.isSymbolicLink()) {
|
||||||
|
return { ok: false, reason: "validation" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const realPath = params.resolvedPath ?? fs.realpathSync(params.filePath);
|
||||||
|
const preOpenStat = fs.lstatSync(realPath);
|
||||||
|
if (!preOpenStat.isFile()) {
|
||||||
|
return { ok: false, reason: "validation" };
|
||||||
|
}
|
||||||
|
if (params.maxBytes !== undefined && preOpenStat.size > params.maxBytes) {
|
||||||
|
return { ok: false, reason: "validation" };
|
||||||
|
}
|
||||||
|
|
||||||
|
fd = fs.openSync(realPath, OPEN_READ_FLAGS);
|
||||||
|
const openedStat = fs.fstatSync(fd);
|
||||||
|
if (!openedStat.isFile()) {
|
||||||
|
return { ok: false, reason: "validation" };
|
||||||
|
}
|
||||||
|
if (params.maxBytes !== undefined && openedStat.size > params.maxBytes) {
|
||||||
|
return { ok: false, reason: "validation" };
|
||||||
|
}
|
||||||
|
if (!sameFileIdentity(preOpenStat, openedStat)) {
|
||||||
|
return { ok: false, reason: "validation" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const opened = { ok: true as const, path: realPath, fd, stat: openedStat };
|
||||||
|
fd = null;
|
||||||
|
return opened;
|
||||||
|
} catch (error) {
|
||||||
|
if (isExpectedPathError(error)) {
|
||||||
|
return { ok: false, reason: "path", error };
|
||||||
|
}
|
||||||
|
return { ok: false, reason: "io", error };
|
||||||
|
} finally {
|
||||||
|
if (fd !== null) {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user