Files
multica/packages/ui/markdown/file-cards.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

110 lines
3.8 KiB
TypeScript

/**
* File card preprocessing for markdown content.
*
* Converts file-card syntax into HTML divs that can be rendered by
* react-markdown with a custom `div` component.
*
* Two syntaxes are supported:
* 1. `!file[name](url)` — new unambiguous syntax (no hostname check needed)
* 2. `[name](cdnUrl)` — legacy syntax, matched by CDN hostname on own line
*
* Output: `<div data-type="fileCard" data-href="url" data-filename="name"></div>`
*
* All functions are pure — no global state, no imports from core/ or views/.
*/
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff?)$/i
/**
* URL alternation accepted inside `!file[name](url)` markdown.
*
* Restricted to:
* - `/uploads/...` site-relative paths (LocalStorage backend with no LOCAL_UPLOAD_BASE_URL)
* - `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?:\/\/[^)]+/
/** Prefix test applied by renderers to validate `data-href` before opening it. */
export function isAllowedFileCardHref(href: string): boolean {
return /^(https?:\/\/|\/uploads\/)/i.test(href)
}
/** New syntax: !file[name](url) — unambiguous, no hostname matching needed. */
const NEW_FILE_CARD_RE = new RegExp(
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)$`,
)
/** Legacy syntax: [name](cdnUrl) on its own line — matched by CDN hostname. */
const FILE_LINK_LINE = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/
function escapeAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}
function toFileCardHtml(filename: string, url: string): string {
return `<div data-type="fileCard" data-href="${escapeAttr(url)}" data-filename="${escapeAttr(filename)}"></div>`
}
/**
* Check if a URL points to our upload CDN.
*
* Uses exact hostname match against `cdnDomain` (e.g. "multica-static.copilothub.ai"),
* and also matches any `.amazonaws.com` subdomain as a fallback for direct S3 URLs.
*/
export function isCdnUrl(url: string, cdnDomain: string): boolean {
try {
const u = new URL(url)
return u.hostname === cdnDomain || u.hostname.endsWith('.amazonaws.com')
} catch {
return false
}
}
/**
* Check if a CDN URL is a non-image file that should render as a file card.
* Image URLs (png, jpg, etc.) are excluded — they render as inline images.
*/
export function isFileCardUrl(url: string, cdnDomain: string): boolean {
try {
return isCdnUrl(url, cdnDomain) && !IMAGE_EXTS.test(new URL(url).pathname)
} catch {
return false
}
}
/**
* Preprocess markdown to convert file-card syntax into HTML divs.
*
* Handles both `!file[name](url)` (new syntax) and legacy `[name](cdnUrl)`
* lines. Only standalone lines are matched — inline links are left untouched.
*
* @param markdown Raw markdown string
* @param cdnDomain CDN hostname for legacy link detection (e.g. "multica-static.copilothub.ai")
*/
export function preprocessFileCards(markdown: string, cdnDomain: string): string {
return markdown
.split('\n')
.map((line) => {
const trimmed = line.trim()
// New syntax: !file[name](url) — always a file card, no hostname check needed.
const newMatch = trimmed.match(NEW_FILE_CARD_RE)
if (newMatch) {
return toFileCardHtml(newMatch[1]!, newMatch[2]!)
}
// Legacy: [name](cdnUrl) on its own line — CDN hostname matching.
const match = trimmed.match(FILE_LINK_LINE)
if (!match) return line
const filename = match[1]!
const url = match[2]!
if (!isFileCardUrl(url, cdnDomain)) return line
return toFileCardHtml(filename, url)
})
.join('\n')
}