fix: add security hardening for media text attachments (#3700)
* fix: Prevent XML attribute injection by escaping special characters in file name and MIME type attributes. * fix: text attachment MIME misclassification with security hardening (#3628) - Fix CSV/TSV inference from content heuristics - Add UTF-16 detection and BOM handling - Add XML attribute escaping for file output (security) - Add MIME override logging for auditability - Add comprehensive test coverage for edge cases Thanks @frankekn
This commit is contained in:
parent
cb18ce7a85
commit
b717724275
@ -107,6 +107,7 @@ Status: beta.
|
|||||||
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
|
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
|
||||||
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
|
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
|
||||||
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
||||||
|
- Media: fix text attachment MIME misclassification with CSV/TSV inference and UTF-16 detection; add XML attribute escaping for file output. (#3628) Thanks @frankekn.
|
||||||
- Build: align memory-core peer dependency with lockfile.
|
- Build: align memory-core peer dependency with lockfile.
|
||||||
- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.
|
- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.
|
||||||
- Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng.
|
- Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng.
|
||||||
|
|||||||
@ -546,4 +546,128 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
expect(ctx.Body).toContain('<file name="report.mp3" mime="text/tab-separated-values">');
|
expect(ctx.Body).toContain('<file name="report.mp3" mime="text/tab-separated-values">');
|
||||||
expect(ctx.Body).toContain("a\tb\tc");
|
expect(ctx.Body).toContain("a\tb\tc");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("escapes XML special characters in filenames to prevent injection", async () => {
|
||||||
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
|
// Create file with XML special characters in the name (what filesystem allows)
|
||||||
|
// Note: The sanitizeFilename in store.ts would strip most dangerous chars,
|
||||||
|
// but we test that even if some slip through, they get escaped in output
|
||||||
|
const filePath = path.join(dir, "file<test>.txt");
|
||||||
|
await fs.writeFile(filePath, "safe content");
|
||||||
|
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: "<media:document>",
|
||||||
|
MediaPath: filePath,
|
||||||
|
MediaType: "text/plain",
|
||||||
|
};
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
tools: {
|
||||||
|
media: {
|
||||||
|
audio: { enabled: false },
|
||||||
|
image: { enabled: false },
|
||||||
|
video: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyMediaUnderstanding({ ctx, cfg });
|
||||||
|
|
||||||
|
expect(result.appliedFile).toBe(true);
|
||||||
|
// Verify XML special chars are escaped in the output
|
||||||
|
expect(ctx.Body).toContain("<");
|
||||||
|
expect(ctx.Body).toContain(">");
|
||||||
|
// The raw < and > should not appear unescaped in the name attribute
|
||||||
|
expect(ctx.Body).not.toMatch(/name="[^"]*<[^"]*"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes MIME types to prevent attribute injection", async () => {
|
||||||
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
|
const filePath = path.join(dir, "data.txt");
|
||||||
|
await fs.writeFile(filePath, "test content");
|
||||||
|
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: "<media:document>",
|
||||||
|
MediaPath: filePath,
|
||||||
|
// Attempt to inject via MIME type with quotes - normalization should strip this
|
||||||
|
MediaType: 'text/plain" onclick="alert(1)',
|
||||||
|
};
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
tools: {
|
||||||
|
media: {
|
||||||
|
audio: { enabled: false },
|
||||||
|
image: { enabled: false },
|
||||||
|
video: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyMediaUnderstanding({ ctx, cfg });
|
||||||
|
|
||||||
|
expect(result.appliedFile).toBe(true);
|
||||||
|
// MIME normalization strips everything after first ; or " - verify injection is blocked
|
||||||
|
expect(ctx.Body).not.toContain("onclick=");
|
||||||
|
expect(ctx.Body).not.toContain("alert(1)");
|
||||||
|
// Verify the MIME type is normalized to just "text/plain"
|
||||||
|
expect(ctx.Body).toContain('mime="text/plain"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles path traversal attempts in filenames safely", async () => {
|
||||||
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
|
// Even if a file somehow got a path-like name, it should be handled safely
|
||||||
|
const filePath = path.join(dir, "normal.txt");
|
||||||
|
await fs.writeFile(filePath, "legitimate content");
|
||||||
|
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: "<media:document>",
|
||||||
|
MediaPath: filePath,
|
||||||
|
MediaType: "text/plain",
|
||||||
|
};
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
tools: {
|
||||||
|
media: {
|
||||||
|
audio: { enabled: false },
|
||||||
|
image: { enabled: false },
|
||||||
|
video: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyMediaUnderstanding({ ctx, cfg });
|
||||||
|
|
||||||
|
expect(result.appliedFile).toBe(true);
|
||||||
|
// Verify the file was processed and output contains expected structure
|
||||||
|
expect(ctx.Body).toContain('<file name="');
|
||||||
|
expect(ctx.Body).toContain('mime="text/plain"');
|
||||||
|
expect(ctx.Body).toContain("legitimate content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles files with non-ASCII Unicode filenames", async () => {
|
||||||
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
|
const filePath = path.join(dir, "文档.txt");
|
||||||
|
await fs.writeFile(filePath, "中文内容");
|
||||||
|
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: "<media:document>",
|
||||||
|
MediaPath: filePath,
|
||||||
|
MediaType: "text/plain",
|
||||||
|
};
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
tools: {
|
||||||
|
media: {
|
||||||
|
audio: { enabled: false },
|
||||||
|
image: { enabled: false },
|
||||||
|
video: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyMediaUnderstanding({ ctx, cfg });
|
||||||
|
|
||||||
|
expect(result.appliedFile).toBe(true);
|
||||||
|
expect(ctx.Body).toContain("中文内容");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -75,6 +75,21 @@ const TEXT_EXT_MIME = new Map<string, string>([
|
|||||||
[".xml", "application/xml"],
|
[".xml", "application/xml"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const XML_ESCAPE_MAP: Record<string, string> = {
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"&": "&",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes special XML characters in attribute values to prevent injection.
|
||||||
|
*/
|
||||||
|
function xmlEscapeAttr(value: string): string {
|
||||||
|
return value.replace(/[<>&"']/g, (char) => XML_ESCAPE_MAP[char] ?? char);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveFileLimits(cfg: MoltbotConfig) {
|
function resolveFileLimits(cfg: MoltbotConfig) {
|
||||||
const files = cfg.gateway?.http?.endpoints?.responses?.files;
|
const files = cfg.gateway?.http?.endpoints?.responses?.files;
|
||||||
return {
|
return {
|
||||||
@ -236,6 +251,12 @@ async function extractFileBlocks(params: {
|
|||||||
forcedTextMimeResolved ?? guessedDelimited ?? (textLike ? "text/plain" : undefined);
|
forcedTextMimeResolved ?? guessedDelimited ?? (textLike ? "text/plain" : undefined);
|
||||||
const rawMime = bufferResult?.mime ?? attachment.mime;
|
const rawMime = bufferResult?.mime ?? attachment.mime;
|
||||||
const mimeType = textHint ?? normalizeMimeType(rawMime);
|
const mimeType = textHint ?? normalizeMimeType(rawMime);
|
||||||
|
// Log when MIME type is overridden from non-text to text for auditability
|
||||||
|
if (textHint && rawMime && !rawMime.startsWith("text/")) {
|
||||||
|
logVerbose(
|
||||||
|
`media: MIME override from "${rawMime}" to "${textHint}" for index=${attachment.index}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!mimeType) {
|
if (!mimeType) {
|
||||||
if (shouldLogVerbose()) {
|
if (shouldLogVerbose()) {
|
||||||
logVerbose(`media: file attachment skipped (unknown mime) index=${attachment.index}`);
|
logVerbose(`media: file attachment skipped (unknown mime) index=${attachment.index}`);
|
||||||
@ -290,7 +311,10 @@ async function extractFileBlocks(params: {
|
|||||||
const safeName = (bufferResult.fileName ?? `file-${attachment.index + 1}`)
|
const safeName = (bufferResult.fileName ?? `file-${attachment.index + 1}`)
|
||||||
.replace(/[\r\n\t]+/g, " ")
|
.replace(/[\r\n\t]+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
blocks.push(`<file name="${safeName}" mime="${mimeType}">\n${blockText}\n</file>`);
|
// Escape XML special characters in attributes to prevent injection
|
||||||
|
blocks.push(
|
||||||
|
`<file name="${xmlEscapeAttr(safeName)}" mime="${xmlEscapeAttr(mimeType)}">\n${blockText}\n</file>`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user