mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
* fix(issues): file-card render for self-host with local storage Fixes #1520. When self-hosting without S3, the upload handler returns site-relative URLs like /uploads/workspaces/<wsId>/<file>. Four frontend regexes only matched https?://, so persisted !file[name](/uploads/...) markdown failed to parse and leaked through as raw text in the issue view, chat, skill file viewer, and board card preview. Narrow allow-list: the relative branch only accepts /uploads/ — not any /-prefixed href — so protocol-relative //evil.com/x, path-traversal /../api/x, and other internal /api/... paths are rejected. Without this, a stored file-card with an attacker-chosen filename and a //host/x href would turn into a one-click external-site jump via window.open from inside an issue (per review feedback on #2349). Single source of truth: packages/ui/markdown/file-cards.ts now exports isAllowedFileCardHref + FILE_CARD_URL_PATTERN. The four sites use one of them, so the next regression is cheaper than restoring four parallel regexes. - packages/ui/markdown/file-cards.ts: helper + URL pattern. - packages/views/editor/extensions/file-card.tsx: Tiptap tokenizer composes from FILE_CARD_URL_PATTERN. - packages/views/editor/readonly-content.tsx: sanitiser uses helper. - packages/ui/markdown/Markdown.tsx: sanitiser uses helper. - packages/views/issues/components/board-card.tsx: strip markdown tokens from the line-clamped board preview so raw !file[...] no longer leaks there either. - packages/ui/markdown/file-cards.test.ts: covers accept (/uploads/ok, https://cdn/x) and reject (javascript:, data:, //evil.com/x, /../api/x, /api/x, empty, ftp:, bare 'uploads/x') for both the helper and the parser composed from the pattern. javascript:, data:, and other dangerous schemes remain rejected. * test(markdown): move file-card href allow-list test into @multica/views Per review feedback on #2349: keep the test where vitest is already running instead of bootstrapping a new test runner inside @multica/ui. The test now lives at packages/views/editor/file-card-href.test.ts and imports isAllowedFileCardHref / FILE_CARD_URL_PATTERN / preprocessFileCards from the @multica/ui/markdown public surface, exercising the same 30 cases. Reverts the @multica/ui package.json test script + vitest devDep + the local vitest.config.ts that the previous commit added; the package goes back to typecheck + lint only, matching every other ui-only package in the monorepo. --------- Co-authored-by: Lalbadshah <11599756+Lalbadshah@users.noreply.github.com>
93 lines
2.9 KiB
TypeScript
93 lines
2.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
FILE_CARD_URL_PATTERN,
|
|
isAllowedFileCardHref,
|
|
preprocessFileCards,
|
|
} from "@multica/ui/markdown";
|
|
|
|
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([
|
|
["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);
|
|
});
|
|
});
|
|
|
|
describe("FILE_CARD_URL_PATTERN", () => {
|
|
// Mirror the parser usage: a fresh anchored regex composed from the pattern.
|
|
const parser = new RegExp(
|
|
`^!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([
|
|
"!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);
|
|
});
|
|
});
|
|
|
|
describe("preprocessFileCards (integration)", () => {
|
|
const cdn = "cdn.example.com";
|
|
|
|
it("converts !file[…](/uploads/…) into a file-card div", () => {
|
|
const out = preprocessFileCards("!file[doc.md](/uploads/x.md)", cdn);
|
|
expect(out).toContain('data-type="fileCard"');
|
|
expect(out).toContain('data-href="/uploads/x.md"');
|
|
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"');
|
|
expect(out).toBe("!file[evil.txt](//evil.com/x)");
|
|
});
|
|
|
|
it("leaves javascript: untouched (not parsed as file-card)", () => {
|
|
const out = preprocessFileCards(
|
|
"!file[evil.txt](javascript:alert(1))",
|
|
cdn,
|
|
);
|
|
expect(out).not.toContain('data-type="fileCard"');
|
|
});
|
|
|
|
it("leaves a non-/uploads relative path untouched", () => {
|
|
const out = preprocessFileCards("!file[name](/api/internal/x)", cdn);
|
|
expect(out).not.toContain('data-type="fileCard"');
|
|
});
|
|
});
|