fix(markdown): allow attachment download file-card hrefs

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-06-15 16:37:37 +08:00
parent 0e31a9ca58
commit 417d1a403e
5 changed files with 155 additions and 39 deletions

View File

@@ -15,22 +15,36 @@
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff?)$/i
// Keep in sync with UUID_RE in packages/core/types/attachment-url.ts.
const ATTACHMENT_UUID_SOURCE =
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'
const ATTACHMENT_DOWNLOAD_URL_SOURCE = `/api/attachments/${ATTACHMENT_UUID_SOURCE}/download`
const ATTACHMENT_DOWNLOAD_URL_RE = new RegExp(
`^${ATTACHMENT_DOWNLOAD_URL_SOURCE}$`,
)
/**
* URL alternation accepted inside `!file[name](url)` markdown.
*
* Restricted to:
* - `/uploads/...` site-relative paths (LocalStorage backend with no LOCAL_UPLOAD_BASE_URL)
* - `/api/attachments/<UUID>/download` site-relative attachment downloads
* - `http(s)://...` absolute URLs (S3 / CloudFront / hosted)
*
* Anything else — `javascript:`, `data:`, protocol-relative `//host/x`, other
* APIs `/api/…`, path-traversal `/../…` — is rejected so a stored file-card
* cannot be turned into an out-of-band navigation.
*/
export const FILE_CARD_URL_PATTERN = /\/uploads\/[^)]*|https?:\/\/[^)]+/
export const FILE_CARD_URL_PATTERN = new RegExp(
`/uploads/[^)]*|https?:\\/\\/[^)]+|${ATTACHMENT_DOWNLOAD_URL_SOURCE}`,
)
/** Prefix test applied by renderers to validate `data-href` before opening it. */
export function isAllowedFileCardHref(href: string): boolean {
return /^(https?:\/\/|\/uploads\/)/i.test(href)
return (
/^(https?:\/\/|\/uploads\/)/i.test(href) ||
ATTACHMENT_DOWNLOAD_URL_RE.test(href)
)
}
/** New syntax: !file[name](url) — unambiguous, no hostname matching needed. */

View File

@@ -564,6 +564,31 @@ describe("Attachment — file-card dispatch", () => {
expect(document.querySelector("img")).toBeNull();
});
it("url-only stable attachment download file-card resolves to record and downloads by id", () => {
const id = "11111111-2222-3333-4444-555555555555";
const href = `/api/attachments/${id}/download`;
resolverState.attachments = [
makeRecord({
id,
filename: "manual.pdf",
content_type: "application/pdf",
url: "/uploads/manual.pdf",
markdown_url: href,
download_url: href,
}),
];
renderWithQuery(
<Attachment
attachment={{ kind: "url", url: href, filename: "manual.pdf" }}
/>,
);
expect(screen.getByText("manual.pdf")).toBeTruthy();
fireEvent.mouseDown(screen.getByTitle("Download"));
expect(downloadMock).toHaveBeenCalledWith(id);
});
it("uploading file-card surfaces the uploading template, no Preview/Download", () => {
renderWithQuery(
<Attachment

View File

@@ -6,30 +6,53 @@ import {
preprocessFileCards,
} from "@multica/ui/markdown";
const ATTACHMENT_ID = "11111111-2222-3333-4444-555555555555";
const ATTACHMENT_DOWNLOAD = `/api/attachments/${ATTACHMENT_ID}/download`;
const allowedClickHrefs = [
"/uploads/ok",
"/uploads/workspaces/abc/file.png",
"https://cdn.example.com/x",
"http://localhost:8080/uploads/x.png",
"HTTPS://CDN.EXAMPLE.COM/x",
ATTACHMENT_DOWNLOAD,
];
const parsedAllowedFileCardHrefs = [
"/uploads/x.md",
"/uploads/workspaces/abc/019e.md",
"https://cdn.example.com/x.md",
"http://localhost:8080/uploads/x.md",
ATTACHMENT_DOWNLOAD,
];
const rejectedFileCardHrefs = [
"javascript:alert(1)",
"JavaScript:alert(1)",
"data:text/html,xss",
"//evil.com/x",
"/../api/x",
"/api/x",
"/api/internal/x",
`/api/attachments/${ATTACHMENT_ID}/content`,
`/api/attachments/${ATTACHMENT_ID}`,
"/api/attachments/not-a-uuid/download",
"/api/attachments//download",
`/api/attachments/${ATTACHMENT_ID}/download/../../x`,
`/api/attachments/${ATTACHMENT_ID}/download?x=1`,
`/api/attachments/${ATTACHMENT_ID}/download#fragment`,
"",
"ftp://example.com/x",
"uploads/x",
];
describe("isAllowedFileCardHref", () => {
it.each([
["/uploads/ok", true],
["/uploads/workspaces/abc/file.png", true],
["https://cdn.example.com/x", true],
["http://localhost:8080/uploads/x.png", true],
["HTTPS://CDN.EXAMPLE.COM/x", true],
])("accepts %s", (href, expected) => {
expect(isAllowedFileCardHref(href)).toBe(expected);
it.each(allowedClickHrefs)("accepts %s", (href) => {
expect(isAllowedFileCardHref(href)).toBe(true);
});
it.each([
["javascript:alert(1)", false],
["JavaScript:alert(1)", false],
["data:text/html,xss", false],
["//evil.com/x", false],
["/../api/x", false],
["/api/x", false],
["/api/internal/x", false],
["", false],
["ftp://example.com/x", false],
["uploads/x", false],
])("rejects %s", (href, expected) => {
expect(isAllowedFileCardHref(href)).toBe(expected);
it.each(rejectedFileCardHrefs)("rejects %s", (href) => {
expect(isAllowedFileCardHref(href)).toBe(false);
});
});
@@ -39,25 +62,20 @@ describe("FILE_CARD_URL_PATTERN", () => {
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)$`,
);
it.each([
"!file[doc.md](/uploads/x.md)",
"!file[name](/uploads/workspaces/abc/019e.md)",
"!file[doc.md](https://cdn.example.com/x.md)",
"!file[doc.md](http://localhost:8080/uploads/x.md)",
])("parses %s", (input) => {
expect(parser.test(input)).toBe(true);
it.each(parsedAllowedFileCardHrefs)("parses %s", (href) => {
expect(parser.test(`!file[doc.md](${href})`)).toBe(true);
});
it.each(rejectedFileCardHrefs)("does not parse %s", (href) => {
expect(parser.test(`!file[doc.md](${href})`)).toBe(false);
});
it.each([
"!file[evil.txt](javascript:alert(1))",
"!file[evil.txt](data:text/html,xss)",
"!file[evil.txt](//evil.com/x)",
"!file[evil.txt](/../api/x)",
"!file[evil.txt](/api/x)",
"!file[doc.md](uploads/x.md)",
"!file[doc.md](ftp://example.com/x)",
])("does not parse %s", (input) => {
expect(parser.test(input)).toBe(false);
...parsedAllowedFileCardHrefs.map((href) => [href, true] as const),
...rejectedFileCardHrefs.map((href) => [href, false] as const),
])("matches the click gate for %s", (href, expected) => {
expect(parser.test(`!file[doc.md](${href})`)).toBe(expected);
expect(isAllowedFileCardHref(href)).toBe(expected);
});
});
@@ -71,6 +89,16 @@ describe("preprocessFileCards (integration)", () => {
expect(out).toContain('data-filename="doc.md"');
});
it("converts !file[…](attachment download URL) into a file-card div", () => {
const out = preprocessFileCards(
`!file[doc.md](${ATTACHMENT_DOWNLOAD})`,
cdn,
);
expect(out).toContain('data-type="fileCard"');
expect(out).toContain(`data-href="${ATTACHMENT_DOWNLOAD}"`);
expect(out).toContain('data-filename="doc.md"');
});
it("leaves a protocol-relative href untouched (not parsed as file-card)", () => {
const out = preprocessFileCards("!file[evil.txt](//evil.com/x)", cdn);
expect(out).not.toContain('data-type="fileCard"');

View File

@@ -470,6 +470,31 @@ describe("ReadonlyContent file-card → AttachmentBlock HTML routing", () => {
// <p class="truncate"> row. HtmlAttachmentPreview replaces it entirely.
expect(queryByText("report.html")).toBeNull();
});
it("renders a stable attachment download URL as file-card chrome", () => {
const id = "11111111-2222-3333-4444-555555555555";
const href = `/api/attachments/${id}/download`;
const attachment = {
id,
url: "/uploads/report.pdf",
filename: "report.pdf",
content_type: "application/pdf",
size_bytes: 1024,
markdown_url: href,
download_url: href,
} as any;
const { container, getByText } = renderWithQuery(
<ReadonlyContent
content={`!file[report.pdf](${href})`}
attachments={[attachment]}
/>,
);
expect(getByText("report.pdf")).toBeTruthy();
expect(container.querySelector("iframe")).toBeNull();
expect(container.querySelector("img")).toBeNull();
});
});
describe("ReadonlyContent slash command rendering", () => {

View File

@@ -86,3 +86,27 @@ describe("AttachmentList — standalone HTML attachment routes through Attachmen
expect(screen.queryByText("report.html")).toBeNull();
});
});
describe("AttachmentList — inline attachment filtering", () => {
it("does not render a bottom attachment row when the body already has the stable file-card URL", () => {
const id = "11111111-2222-3333-4444-555555555555";
const href = `/api/attachments/${id}/download`;
const attachment = {
id,
url: "/uploads/report.pdf",
filename: "report.pdf",
content_type: "application/pdf",
size_bytes: 1024,
} as any;
const { container } = renderWithQuery(
<AttachmentList
attachments={[attachment]}
content={`!file[report.pdf](${href})`}
/>,
);
expect(screen.queryByText("report.pdf")).toBeNull();
expect(container.firstChild).toBeNull();
});
});