Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion STATUS.md
Original file line number Diff line number Diff line change
@@ -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 |
Expand Down
33 changes: 33 additions & 0 deletions replicas-matrix-bridge/src/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,39 @@ describe("markdownToTelegramHtml", () => {
expect(markdownToTelegramHtml("first\n\nsecond")).toBe("first<br><br>second");
});

it("blocks javascript: link schemes (renders as bracketed plain text)", () => {
const out = markdownToTelegramHtml("[click me](javascript:alert(1))");
expect(out).not.toContain("<a ");
expect(out).not.toContain("href=");
expect(out).toContain("[click me]");
expect(out).toContain("javascript:alert(1)");
});

it("blocks data: URI link schemes", () => {
const out = markdownToTelegramHtml("[x](data:text/html,<script>alert(1)</script>)");
expect(out).not.toContain("<a ");
expect(out).not.toContain("<script>");
});

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",
Expand Down
35 changes: 33 additions & 2 deletions replicas-matrix-bridge/src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<n>\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`;
Expand Down Expand Up @@ -62,9 +78,24 @@ export function markdownToTelegramHtml(md: string): string {
s = s.replace(/~~([^~\n]+)~~/g, "<s>$1</s>");
s = s.replace(/(?<![~\w])~([^~\n]+)~(?![~\w])/g, "<s>$1</s>");

// 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, "&quot;");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
return `[${text}](${safeUrl})`;
}
const safeUrl = trimmed.replace(/"/g, "&quot;");
return `<a href="${safeUrl}">${text}</a>`;
});

Expand Down