Files
multica/packages/views/editor/file-card-href.test.ts
Prateek Bhatnagar 681d720671 fix(issues): file-card render for self-host with local storage (#2349)
* 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>
2026-05-14 18:32:40 +08:00

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"');
});
});