diff --git a/STATUS.md b/STATUS.md index 97af9477..6c76dd83 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,6 +1,6 @@ # Infrastructure Status -Last Updated: 2026-05-30 16:40 UTC +Last Updated: 2026-05-30 16:41 UTC ## 🖥️ MCP Servers | Server | Status | Latency | diff --git a/replicas-matrix-bridge/src/markdown.test.ts b/replicas-matrix-bridge/src/markdown.test.ts index 6ca527cb..7bff8b80 100644 --- a/replicas-matrix-bridge/src/markdown.test.ts +++ b/replicas-matrix-bridge/src/markdown.test.ts @@ -96,6 +96,39 @@ describe("markdownToTelegramHtml", () => { expect(markdownToTelegramHtml("first\n\nsecond")).toBe("first

second"); }); + it("blocks javascript: link schemes (renders as bracketed plain text)", () => { + const out = markdownToTelegramHtml("[click me](javascript:alert(1))"); + expect(out).not.toContain(" { + const out = markdownToTelegramHtml("[x](data:text/html,)"); + expect(out).not.toContain(""); + }); + + it("allows http, https, mailto, ftp, magnet schemes", () => { + expect(markdownToTelegramHtml("[a](http://x.com)")).toContain('href="http://x.com"'); + expect(markdownToTelegramHtml("[a](https://x.com)")).toContain('href="https://x.com"'); + expect(markdownToTelegramHtml("[a](mailto:x@y.com)")).toContain('href="mailto:x@y.com"'); + expect(markdownToTelegramHtml("[a](ftp://x.com/file)")).toContain('href="ftp://x.com/file"'); + }); + + it("allows relative paths and fragment-only links", () => { + expect(markdownToTelegramHtml("[a](/path/to/thing)")).toContain('href="/path/to/thing"'); + expect(markdownToTelegramHtml("[a](#anchor)")).toContain('href="#anchor"'); + }); + + it("strips null bytes from input so they cannot collide with placeholders", () => { + const out = markdownToTelegramHtml("a\u0000PH0\u0000b"); + // The literal "PH0" survives but the wrapping NULL bytes are gone, + // so the splice-back step cannot mistake it for a real placeholder. + expect(out).not.toContain("\u0000"); + }); + it("the actual tools-list response shape renders cleanly", () => { const md = [ "# Available Tools", diff --git a/replicas-matrix-bridge/src/markdown.ts b/replicas-matrix-bridge/src/markdown.ts index 4542dc04..5491ac41 100644 --- a/replicas-matrix-bridge/src/markdown.ts +++ b/replicas-matrix-bridge/src/markdown.ts @@ -14,7 +14,23 @@ * 4. inline pass on the remaining prose * 5. paste the code placeholders back */ +// Allow-list of URL schemes for markdown `[text](url)` links. Matrix spec +// says clients SHOULD filter to http/https/ftp/mailto/magnet but client +// behavior varies (Element/Beeper/native homeserver-relay/etc.). Bridge +// strips everything else into plain text so a prompt-injected agent +// emitting `[click](javascript:alert(1))` can't produce a clickable XSS +// vector regardless of client filtering. +const ALLOWED_LINK_SCHEMES = /^(?:https?|ftp|mailto|magnet):/i; +const RELATIVE_OR_FRAGMENT = /^(?:[\/#?]|[a-zA-Z0-9_\-.]+$)/; + export function markdownToTelegramHtml(md: string): string { + // Strip NULL bytes up front. The placeholder sentinel below is built + // around `\u0000PH\u0000`; an agent emitting literal NULL bytes in + // its output could otherwise collide with a real placeholder and + // corrupt the spliced-back content. NULL is never legitimately part + // of Markdown body text. + md = md.replace(/\u0000/g, ""); + const placeholders: string[] = []; const placeholder = (html: string): string => { const key = `\u0000PH${placeholders.length}\u0000`; @@ -62,9 +78,24 @@ export function markdownToTelegramHtml(md: string): string { s = s.replace(/~~([^~\n]+)~~/g, "$1"); s = s.replace(/(?$1"); - // Links: [text](url). URL was already &-escaped above; restore safe quotes. + // Links: [text](url). URL was already &-escaped above. We additionally + // scheme-validate so `javascript:`/`data:`/`vbscript:` etc. can't slip + // through into the href — render as plain text in that case so the user + // still sees what was emitted but it can't be clicked into an XSS. s = s.replace(/\[([^\]\n]+)\]\(([^)\n]+)\)/g, (_m, text: string, url: string) => { - const safeUrl = url.replace(/"/g, """); + const trimmed = url.trim(); + const isSchemed = /^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(trimmed); + const allowed = isSchemed + ? ALLOWED_LINK_SCHEMES.test(trimmed) + : RELATIVE_OR_FRAGMENT.test(trimmed); + if (!allowed) { + // Render as bracketed plain-text. The text was already escaped + // by escapeOutsideBlocks; the URL needs explicit quote-escape + // in case it contains `"` (the original code did this too). + const safeUrl = trimmed.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + return `[${text}](${safeUrl})`; + } + const safeUrl = trimmed.replace(/"/g, """); return `${text}`; });