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}`;
});