Skip to content

Commit d5e9724

Browse files
authored
Merge 6b1fe51 into 0f35ec2
2 parents 0f35ec2 + 6b1fe51 commit d5e9724

4 files changed

Lines changed: 71 additions & 5 deletions

File tree

src/auto-reply/reply/reply-media-paths.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,28 @@ describe("createReplyMediaPathNormalizer", () => {
252252
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
253253
});
254254

255+
it("stages absolute workspace media paths before sandbox mapping", async () => {
256+
ensureSandboxWorkspaceForSession.mockResolvedValue({
257+
workspaceDir: "/tmp/sandboxes/session-1",
258+
containerWorkdir: "/workspace",
259+
});
260+
const absolutePath = "/Users/peter/.openclaw/workspace/reports/screenshot.png";
261+
const normalize = createReplyMediaPathNormalizer({
262+
cfg: {},
263+
sessionKey: "session-key",
264+
workspaceDir: "/Users/peter/.openclaw/workspace",
265+
});
266+
267+
const result = await normalize({
268+
mediaUrls: [absolutePath],
269+
});
270+
271+
expectMedia(result, "/tmp/outbound-media/screenshot.png", [
272+
"/tmp/outbound-media/screenshot.png",
273+
]);
274+
expectOutboundAttachmentCall(0, absolutePath, 5 * 1024 * 1024);
275+
});
276+
255277
it("stages absolute workspace media paths so the PR scenario now works", async () => {
256278
const absolutePath = "/Users/peter/.openclaw/workspace/exports/images/chart.png";
257279
const normalize = createReplyMediaPathNormalizer({

src/auto-reply/reply/reply-media-paths.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,17 @@ export function createReplyMediaPathNormalizer(params: {
144144
return resolvePathFromInput(relativeWorkspacePath, params.workspaceDir);
145145
};
146146

147+
const resolveAbsoluteWorkspaceMedia = (media: string): string | undefined => {
148+
if (FILE_URL_RE.test(media) || (!path.isAbsolute(media) && !WINDOWS_DRIVE_RE.test(media))) {
149+
return undefined;
150+
}
151+
try {
152+
return resolveWorkspaceRelativeMedia(media);
153+
} catch {
154+
return undefined;
155+
}
156+
};
157+
147158
const normalizeMediaSource = async (raw: string): Promise<string> => {
148159
const media = raw.trim();
149160
if (!media) {
@@ -153,6 +164,10 @@ export function createReplyMediaPathNormalizer(params: {
153164
if (isPassThroughRemoteMediaSource(media)) {
154165
return media;
155166
}
167+
const absoluteWorkspaceMedia = resolveAbsoluteWorkspaceMedia(media);
168+
if (absoluteWorkspaceMedia) {
169+
return await persistLocalReplyMedia(absoluteWorkspaceMedia);
170+
}
156171
const isRelativeLocalMedia =
157172
isLikelyLocalMediaSource(media) &&
158173
!FILE_URL_RE.test(media) &&

src/media/web-media.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,20 @@ describe("loadWebMedia", () => {
759759
expect(result.contentType).toBe("text/markdown");
760760
});
761761

762+
it("rejects host-read HTML files without a separate security-boundary approval", async () => {
763+
const htmlFile = path.join(fixtureRoot, "report.html");
764+
await fs.writeFile(htmlFile, "<!doctype html><title>Report</title><h1>Report</h1>\n", "utf8");
765+
await expectLoadWebMediaErrorCode(
766+
loadWebMedia(htmlFile, {
767+
maxBytes: 1024 * 1024,
768+
localRoots: "any",
769+
readFile: async (filePath) => await fs.readFile(filePath),
770+
hostReadCapability: true,
771+
}),
772+
"path-not-allowed",
773+
);
774+
});
775+
762776
it.each([
763777
{
764778
label: "ZIP",
@@ -819,6 +833,7 @@ describe("loadWebMedia", () => {
819833

820834
it.each([
821835
{ label: "CSV", fileName: "opaque.csv" },
836+
{ label: "HTML", fileName: "opaque.html" },
822837
{ label: "Markdown", fileName: "opaque.md" },
823838
])("rejects opaque non-NUL binary data disguised as %s", async ({ fileName }) => {
824839
const fakeTextFile = path.join(fixtureRoot, fileName);
@@ -840,6 +855,7 @@ describe("loadWebMedia", () => {
840855

841856
it.each([
842857
{ label: "CSV", fileName: "prefix-tail.csv" },
858+
{ label: "HTML", fileName: "prefix-tail.html" },
843859
{ label: "Markdown", fileName: "prefix-tail.md" },
844860
])(
845861
"rejects %s files with a text prefix and binary tail after the old sample window",
@@ -907,6 +923,7 @@ describe("loadWebMedia", () => {
907923

908924
it.each([
909925
{ label: "CSV", fileName: "nul-padded.csv" },
926+
{ label: "HTML", fileName: "nul-padded.html" },
910927
{ label: "Markdown", fileName: "nul-padded.md" },
911928
])("rejects NUL-padded binary data disguised as %s", async ({ fileName }) => {
912929
const fakeTextFile = path.join(fixtureRoot, fileName);
@@ -930,6 +947,7 @@ describe("loadWebMedia", () => {
930947

931948
it.each([
932949
{ label: "CSV", fileName: "bom-binary.csv" },
950+
{ label: "HTML", fileName: "bom-binary.html" },
933951
{ label: "Markdown", fileName: "bom-binary.md" },
934952
])("rejects UTF-16 BOM-prefixed binary data disguised as %s", async ({ fileName }) => {
935953
const fakeTextFile = path.join(fixtureRoot, fileName);
@@ -953,6 +971,7 @@ describe("loadWebMedia", () => {
953971

954972
it.each([
955973
{ label: "CSV", fileName: "alternating-high.csv" },
974+
{ label: "HTML", fileName: "alternating-high.html" },
956975
{ label: "Markdown", fileName: "alternating-high.md" },
957976
])("rejects alternating ASCII/high-byte data disguised as %s", async ({ fileName }) => {
958977
const fakeTextFile = path.join(fixtureRoot, fileName);
@@ -977,6 +996,7 @@ describe("loadWebMedia", () => {
977996

978997
it.each([
979998
{ label: "CSV", fileName: "high-bytes.csv" },
999+
{ label: "HTML", fileName: "high-bytes.html" },
9801000
{ label: "Markdown", fileName: "high-bytes.md" },
9811001
])("rejects high-byte opaque data disguised as %s", async ({ fileName }) => {
9821002
const fakeTextFile = path.join(fixtureRoot, fileName);

src/media/web-media.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,13 @@ const HOST_READ_ALLOWED_DOCUMENT_MIMES = new Set([
151151
"text/csv",
152152
"text/markdown",
153153
]);
154-
// file-type returns undefined (no magic bytes) for plain-text formats like CSV and
155-
// Markdown, so host-read needs an explicit "this really decodes as text" fallback.
154+
// file-type returns undefined (no magic bytes) for plain-text formats like CSV
155+
// and Markdown, so host-read needs an explicit text validation fallback.
156156
const HOST_READ_TEXT_PLAIN_ALIASES = new Set(["text/csv", "text/markdown"]);
157+
// HTML remains deliberately outside the host-read allowlist pending a separate
158+
// security-boundary review, but extension-declared .html files still need to
159+
// fail closed instead of falling through to binary/media sniffing.
160+
const HOST_READ_DECLARED_TEXT_MIMES = new Set([...HOST_READ_TEXT_PLAIN_ALIASES, "text/html"]);
157161
const MB = 1024 * 1024;
158162

159163
function getTextStats(text: string): { printableRatio: number } {
@@ -268,12 +272,17 @@ function assertHostReadMediaAllowed(params: {
268272
}): void {
269273
const declaredMime = normalizeMimeType(mimeTypeFromFilePath(params.filePath));
270274
const normalizedMime = normalizeMimeType(params.contentType);
271-
// For extension-declared plain-text aliases such as .csv/.md, trust only the
275+
// For extension-declared plain-text aliases such as .csv/.html/.md, trust only the
272276
// text validator path. Some opaque blobs can still produce bogus binary MIME
273277
// hits (for example BOM-prefixed 0xFF data sniffing as audio/mpeg), and
274278
// host-read should reject those instead of returning early on the sniff.
275-
if (declaredMime && HOST_READ_TEXT_PLAIN_ALIASES.has(declaredMime)) {
276-
if (!params.sniffedContentType && params.buffer && isValidatedHostReadText(params.buffer)) {
279+
if (declaredMime && HOST_READ_DECLARED_TEXT_MIMES.has(declaredMime)) {
280+
if (
281+
HOST_READ_TEXT_PLAIN_ALIASES.has(declaredMime) &&
282+
!params.sniffedContentType &&
283+
params.buffer &&
284+
isValidatedHostReadText(params.buffer)
285+
) {
277286
return;
278287
}
279288
throw new LocalMediaAccessError(

0 commit comments

Comments
 (0)