Skip to content

Commit 9029e91

Browse files
committed
fix(gateway): gate workspace image reads on sniffed magic bytes, not extension
1 parent 8cff150 commit 9029e91

2 files changed

Lines changed: 81 additions & 22 deletions

File tree

src/gateway/server-methods/agents-workspace.test.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -272,10 +272,12 @@ describe("agents.workspace RPC handlers", () => {
272272
expect(Number.isInteger(payload.file.updatedAtMs)).toBe(true);
273273
});
274274

275-
it("reads images as base64 with their mime type", async () => {
276-
const pngBytes = Buffer.from([
277-
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
278-
]);
275+
it("reads images as base64 with their sniffed mime type", async () => {
276+
// 1x1 transparent PNG so magic-byte sniffing sees a real image payload.
277+
const pngBytes = Buffer.from(
278+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
279+
"base64",
280+
);
279281
writeWorkspaceFile(workspaceRoot, "shot.png", pngBytes);
280282

281283
const payload = expectOkPayload(
@@ -290,6 +292,25 @@ describe("agents.workspace RPC handlers", () => {
290292
expect(Buffer.from(payload.file.content, "base64")).toEqual(pngBytes);
291293
});
292294

295+
it("refuses non-image binaries renamed to an image extension", async () => {
296+
writeWorkspaceFile(
297+
workspaceRoot,
298+
"disguised.png",
299+
Buffer.concat([Buffer.from("SQLite format 3"), Buffer.alloc(64, 7)]),
300+
);
301+
302+
const error = expectError(
303+
await invokeWorkspaceHandler("agents.workspace.get", {
304+
agentId: "main",
305+
path: "disguised.png",
306+
}),
307+
);
308+
expect(error.details).toMatchObject({
309+
path: "disguised.png",
310+
type: "workspace_file_unsupported",
311+
});
312+
});
313+
293314
it("refuses non-image binary files", async () => {
294315
writeWorkspaceFile(workspaceRoot, "blob.bin", Buffer.from([0x00, 0x01, 0x02, 0xff]));
295316

src/gateway/server-methods/agents-workspace.ts

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Deliberately no write/delete/upload surface: mutations need their own
33
// reviewed contract; see the allowlisted agents.files.* API for edits.
44
import path from "node:path";
5+
import { detectMime } from "@openclaw/media-core/mime";
56
import {
67
ErrorCodes,
78
errorShape,
@@ -32,17 +33,27 @@ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
3233
const DEFAULT_LIST_LIMIT = 250;
3334
const MAX_LIST_LIMIT = 500;
3435

35-
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
36-
".avif": "image/avif",
37-
".bmp": "image/bmp",
38-
".gif": "image/gif",
39-
".heic": "image/heic",
40-
".heif": "image/heif",
41-
".jpeg": "image/jpeg",
42-
".jpg": "image/jpeg",
43-
".png": "image/png",
44-
".webp": "image/webp",
45-
};
36+
const IMAGE_EXTENSIONS = new Set([
37+
".avif",
38+
".bmp",
39+
".gif",
40+
".heic",
41+
".heif",
42+
".jpeg",
43+
".jpg",
44+
".png",
45+
".webp",
46+
]);
47+
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
48+
"image/avif",
49+
"image/bmp",
50+
"image/gif",
51+
"image/heic",
52+
"image/heif",
53+
"image/jpeg",
54+
"image/png",
55+
"image/webp",
56+
]);
4657

4758
function workspaceError(type: string, message: string, details?: Record<string, unknown>) {
4859
return errorShape(ErrorCodes.INVALID_REQUEST, message, {
@@ -183,8 +194,8 @@ export const agentsWorkspaceHandlers: GatewayRequestHandlers = {
183194
respondNotFound();
184195
return;
185196
}
186-
const imageMime = IMAGE_MIME_BY_EXTENSION[path.extname(browserPath).toLowerCase()];
187-
const maxBytes = imageMime ? MAX_IMAGE_BYTES : WORKSPACE_PREVIEW_MAX_BYTES;
197+
const expectsImage = IMAGE_EXTENSIONS.has(path.extname(browserPath).toLowerCase());
198+
const maxBytes = expectsImage ? MAX_IMAGE_BYTES : WORKSPACE_PREVIEW_MAX_BYTES;
188199
const read =
189200
stat.size > maxBytes
190201
? "too-large"
@@ -205,8 +216,7 @@ export const agentsWorkspaceHandlers: GatewayRequestHandlers = {
205216
respondNotFound();
206217
return;
207218
}
208-
const text = imageMime ? undefined : decodeUtf8Strict(read.buffer);
209-
if (!imageMime && text === undefined) {
219+
const respondUnsupported = () => {
210220
respond(
211221
false,
212222
undefined,
@@ -216,6 +226,34 @@ export const agentsWorkspaceHandlers: GatewayRequestHandlers = {
216226
{ path: browserPath },
217227
),
218228
);
229+
};
230+
// The extension only picks the byte cap; content decides what leaves the
231+
// gateway. Magic-byte sniffing (no filename hints) keeps renamed binaries
232+
// from riding the image path past the UTF-8 text gate.
233+
if (expectsImage) {
234+
const sniffedMime = await detectMime({ buffer: read.buffer });
235+
if (!sniffedMime || !SUPPORTED_IMAGE_MIME_TYPES.has(sniffedMime)) {
236+
respondUnsupported();
237+
return;
238+
}
239+
respond(true, {
240+
agentId,
241+
workspace: workspaceDir,
242+
file: {
243+
path: browserPath,
244+
name: path.basename(browserPath),
245+
size: read.stat.size,
246+
updatedAtMs: toUpdatedAtMs(read.stat.mtimeMs),
247+
mimeType: sniffedMime,
248+
encoding: "base64" as const,
249+
content: read.buffer.toString("base64"),
250+
},
251+
});
252+
return;
253+
}
254+
const text = decodeUtf8Strict(read.buffer);
255+
if (text === undefined) {
256+
respondUnsupported();
219257
return;
220258
}
221259
respond(true, {
@@ -226,9 +264,9 @@ export const agentsWorkspaceHandlers: GatewayRequestHandlers = {
226264
name: path.basename(browserPath),
227265
size: read.stat.size,
228266
updatedAtMs: toUpdatedAtMs(read.stat.mtimeMs),
229-
mimeType: imageMime ?? "text/plain",
230-
encoding: imageMime ? ("base64" as const) : ("utf8" as const),
231-
content: imageMime ? read.buffer.toString("base64") : (text as string),
267+
mimeType: "text/plain",
268+
encoding: "utf8" as const,
269+
content: text,
232270
},
233271
});
234272
},

0 commit comments

Comments
 (0)