fix(gateway): block avatar symlink escapes

This commit is contained in:
Peter Steinberger
2026-02-22 08:51:06 +01:00
parent 94e5a46187
commit 3d03375043
3 changed files with 102 additions and 7 deletions

View File

@@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
- Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting.
- Media/Understanding: preserve `application/pdf` MIME classification during text-like file heuristics so PDF uploads use PDF extraction paths instead of being inlined as raw text. (#23191) Thanks @claudeplay2026-byte.
- Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Gateway avatars: block symlink traversal during local avatar `data:` URL resolution by enforcing realpath containment and file-identity checks before reads. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
- Security/Control UI: centralize avatar URL/path validation across gateway/config helpers and enforce a 2 MB max size for local agent avatar files before `/avatar` resolution, reducing oversized-avatar memory risk without changing supported avatar formats.
- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3.

View File

@@ -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", () => {

View File

@@ -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);
}
}
}