mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 05:41:24 +00:00
fix(gateway): block avatar symlink escapes
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
capArrayByJsonBytes,
|
||||
classifySessionKey,
|
||||
deriveSessionTitle,
|
||||
listAgentsForGateway,
|
||||
listSessionsFromStore,
|
||||
parseGroupKey,
|
||||
pruneLegacyStoreKeys,
|
||||
@@ -16,6 +17,19 @@ import {
|
||||
resolveSessionStoreKey,
|
||||
} from "./session-utils.js";
|
||||
|
||||
function createSymlinkOrSkip(targetPath: string, linkPath: string): boolean {
|
||||
try {
|
||||
fs.symlinkSync(targetPath, linkPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (process.platform === "win32" && (code === "EPERM" || code === "EACCES")) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway session utils", () => {
|
||||
test("capArrayByJsonBytes trims from the front", () => {
|
||||
const res = capArrayByJsonBytes(["a", "b", "c"], 10);
|
||||
@@ -217,6 +231,52 @@ describe("gateway session utils", () => {
|
||||
});
|
||||
expect(Object.keys(store).toSorted()).toEqual(["agent:ops:work"]);
|
||||
});
|
||||
|
||||
test("listAgentsForGateway rejects avatar symlink escapes outside workspace", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-avatar-outside-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
fs.mkdirSync(workspace, { recursive: true });
|
||||
const outsideFile = path.join(root, "outside.txt");
|
||||
fs.writeFileSync(outsideFile, "top-secret", "utf8");
|
||||
const linkPath = path.join(workspace, "avatar-link.png");
|
||||
if (!createSymlinkOrSkip(outsideFile, linkPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, workspace, identity: { avatar: "avatar-link.png" } }],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = listAgentsForGateway(cfg);
|
||||
expect(result.agents[0]?.identity?.avatarUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
test("listAgentsForGateway allows avatar symlinks that stay inside workspace", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "session-utils-avatar-inside-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
fs.mkdirSync(path.join(workspace, "avatars"), { recursive: true });
|
||||
const targetPath = path.join(workspace, "avatars", "actual.png");
|
||||
fs.writeFileSync(targetPath, "avatar", "utf8");
|
||||
const linkPath = path.join(workspace, "avatar-link.png");
|
||||
if (!createSymlinkOrSkip(targetPath, linkPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, workspace, identity: { avatar: "avatar-link.png" } }],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = listAgentsForGateway(cfg);
|
||||
expect(result.agents[0]?.identity?.avatarUrl).toBe(
|
||||
`data:image/png;base64,${Buffer.from("avatar").toString("base64")}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSessionModelRef", () => {
|
||||
|
||||
@@ -66,6 +66,19 @@ export type {
|
||||
} from "./session-utils.types.js";
|
||||
|
||||
const DERIVED_TITLE_MAX_LEN = 60;
|
||||
|
||||
function tryResolveExistingPath(value: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function areSameFileIdentity(preOpen: fs.Stats, opened: fs.Stats): boolean {
|
||||
return preOpen.dev === opened.dev && preOpen.ino === opened.ino;
|
||||
}
|
||||
|
||||
function resolveIdentityAvatarUrl(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
@@ -85,21 +98,42 @@ function resolveIdentityAvatarUrl(
|
||||
return undefined;
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const workspaceRoot = path.resolve(workspaceDir);
|
||||
const resolved = path.resolve(workspaceRoot, trimmed);
|
||||
if (!isPathWithinRoot(workspaceRoot, resolved)) {
|
||||
const workspaceRoot = tryResolveExistingPath(workspaceDir) ?? path.resolve(workspaceDir);
|
||||
const resolvedCandidate = path.resolve(workspaceRoot, trimmed);
|
||||
if (!isPathWithinRoot(workspaceRoot, resolvedCandidate)) {
|
||||
return undefined;
|
||||
}
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
const stat = fs.statSync(resolved);
|
||||
if (!stat.isFile() || stat.size > AVATAR_MAX_BYTES) {
|
||||
const resolvedReal = fs.realpathSync(resolvedCandidate);
|
||||
if (!isPathWithinRoot(workspaceRoot, resolvedReal)) {
|
||||
return undefined;
|
||||
}
|
||||
const buffer = fs.readFileSync(resolved);
|
||||
const mime = resolveAvatarMime(resolved);
|
||||
const preOpenStat = fs.lstatSync(resolvedReal);
|
||||
if (!preOpenStat.isFile() || preOpenStat.size > AVATAR_MAX_BYTES) {
|
||||
return undefined;
|
||||
}
|
||||
const openFlags =
|
||||
fs.constants.O_RDONLY |
|
||||
(typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
|
||||
fd = fs.openSync(resolvedReal, openFlags);
|
||||
const openedStat = fs.fstatSync(fd);
|
||||
if (
|
||||
!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 {
|
||||
return undefined;
|
||||
} finally {
|
||||
if (fd !== null) {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user