fix(security): harden avatar validation and size limits

This commit is contained in:
Peter Steinberger
2026-02-22 08:35:23 +01:00
parent 049b8b14bc
commit e0db04a50d
9 changed files with 200 additions and 99 deletions

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
import { resolveAgentAvatar } from "./identity-avatar.js";
async function writeFile(filePath: string, contents = "avatar") {
@@ -127,6 +128,26 @@ describe("resolveAgentAvatar", () => {
}
});
it("rejects local avatars larger than max bytes", async () => {
const root = await createTempAvatarRoot();
const workspace = path.join(root, "work");
const avatarPath = path.join(workspace, "avatars", "too-big.png");
await fs.mkdir(path.dirname(avatarPath), { recursive: true });
await fs.writeFile(avatarPath, Buffer.alloc(AVATAR_MAX_BYTES + 1));
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "main", workspace, identity: { avatar: "avatars/too-big.png" } }],
},
};
const resolved = resolveAgentAvatar(cfg, "main");
expect(resolved.kind).toBe("none");
if (resolved.kind === "none") {
expect(resolved.reason).toBe("too_large");
}
});
it("accepts remote and data avatars", () => {
const cfg: OpenClawConfig = {
agents: {

View File

@@ -1,6 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import {
AVATAR_MAX_BYTES,
isAvatarDataUrl,
isAvatarHttpUrl,
isPathWithinRoot,
isSupportedLocalAvatarExtension,
} from "../shared/avatar-policy.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentWorkspaceDir } from "./agent-scope.js";
import { loadAgentIdentityFromWorkspace } from "./identity-file.js";
@@ -12,8 +19,6 @@ export type AgentAvatarResolution =
| { kind: "remote"; url: string }
| { kind: "data"; url: string };
const ALLOWED_AVATAR_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
function normalizeAvatarValue(value: string | undefined | null): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
@@ -29,15 +34,6 @@ function resolveAvatarSource(cfg: OpenClawConfig, agentId: string): string | nul
return fromIdentity;
}
function isRemoteAvatar(value: string): boolean {
const lower = value.toLowerCase();
return lower.startsWith("http://") || lower.startsWith("https://");
}
function isDataAvatar(value: string): boolean {
return value.toLowerCase().startsWith("data:");
}
function resolveExistingPath(value: string): string {
try {
return fs.realpathSync(value);
@@ -46,14 +42,6 @@ function resolveExistingPath(value: string): string {
}
}
function isPathWithin(root: string, target: string): boolean {
const relative = path.relative(root, target);
if (!relative) {
return true;
}
return !relative.startsWith("..") && !path.isAbsolute(relative);
}
function resolveLocalAvatarPath(params: {
raw: string;
workspaceDir: string;
@@ -65,17 +53,20 @@ function resolveLocalAvatarPath(params: {
? resolveUserPath(raw)
: path.resolve(workspaceRoot, raw);
const realPath = resolveExistingPath(resolved);
if (!isPathWithin(workspaceRoot, realPath)) {
if (!isPathWithinRoot(workspaceRoot, realPath)) {
return { ok: false, reason: "outside_workspace" };
}
const ext = path.extname(realPath).toLowerCase();
if (!ALLOWED_AVATAR_EXTS.has(ext)) {
if (!isSupportedLocalAvatarExtension(realPath)) {
return { ok: false, reason: "unsupported_extension" };
}
try {
if (!fs.statSync(realPath).isFile()) {
const stat = fs.statSync(realPath);
if (!stat.isFile()) {
return { ok: false, reason: "missing" };
}
if (stat.size > AVATAR_MAX_BYTES) {
return { ok: false, reason: "too_large" };
}
} catch {
return { ok: false, reason: "missing" };
}
@@ -87,10 +78,10 @@ export function resolveAgentAvatar(cfg: OpenClawConfig, agentId: string): AgentA
if (!source) {
return { kind: "none", reason: "missing" };
}
if (isRemoteAvatar(source)) {
if (isAvatarHttpUrl(source)) {
return { kind: "remote", url: source };
}
if (isDataAvatar(source)) {
if (isAvatarDataUrl(source)) {
return { kind: "data", url: source };
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);