mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 18:38:28 +00:00
fix(gateway): harden control-ui avatar reads
This commit is contained in:
@@ -4,7 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "./control-ui-contract.js";
|
||||
import { handleControlUiHttpRequest } from "./control-ui.js";
|
||||
import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
|
||||
import { makeMockHttpResponse } from "./test-http-response.js";
|
||||
|
||||
describe("handleControlUiHttpRequest", () => {
|
||||
@@ -58,6 +58,24 @@ describe("handleControlUiHttpRequest", () => {
|
||||
return { res, end, handled };
|
||||
}
|
||||
|
||||
function runAvatarRequest(params: {
|
||||
url: string;
|
||||
method: "GET" | "HEAD";
|
||||
resolveAvatar: Parameters<typeof handleControlUiAvatarRequest>[2]["resolveAvatar"];
|
||||
basePath?: string;
|
||||
}) {
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiAvatarRequest(
|
||||
{ url: params.url, method: params.method } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
...(params.basePath ? { basePath: params.basePath } : {}),
|
||||
resolveAvatar: params.resolveAvatar,
|
||||
},
|
||||
);
|
||||
return { res, end, handled };
|
||||
}
|
||||
|
||||
async function writeAssetFile(rootPath: string, filename: string, contents: string) {
|
||||
const assetsDir = path.join(rootPath, "assets");
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
@@ -179,6 +197,48 @@ describe("handleControlUiHttpRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("serves local avatar bytes through hardened avatar handler", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-http-"));
|
||||
try {
|
||||
const avatarPath = path.join(tmp, "main.png");
|
||||
await fs.writeFile(avatarPath, "avatar-bytes\n");
|
||||
|
||||
const { res, end, handled } = runAvatarRequest({
|
||||
url: "/avatar/main",
|
||||
method: "GET",
|
||||
resolveAvatar: () => ({ kind: "local", filePath: avatarPath }),
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("avatar-bytes\n");
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects avatar symlink paths from resolver", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-http-link-"));
|
||||
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-http-outside-"));
|
||||
try {
|
||||
const outsideFile = path.join(outside, "secret.txt");
|
||||
await fs.writeFile(outsideFile, "outside-secret\n");
|
||||
const linkPath = path.join(tmp, "avatar-link.png");
|
||||
await fs.symlink(outsideFile, linkPath);
|
||||
|
||||
const { res, end, handled } = runAvatarRequest({
|
||||
url: "/avatar/main",
|
||||
method: "GET",
|
||||
resolveAvatar: () => ({ kind: "local", filePath: linkPath }),
|
||||
});
|
||||
|
||||
expectNotFoundResponse({ handled, res, end });
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
await fs.rm(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects symlinked assets that resolve outside control-ui root", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
|
||||
Reference in New Issue
Block a user