From 08ef9711a6817e7225e20e04479f75fb798f75a5 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Sat, 30 May 2026 16:12:42 +0000 Subject: [PATCH 1/2] fix(matrix-bridge): markdown link-scheme whitelist + null-byte hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 audit focused on the agent-output → Matrix HTML rendering layer. Two findings shipped in src/markdown.ts (the markdown→HTML converter used for every Done frame's embedded reply body). 1. **Link scheme whitelist** — `[text](url)` previously emitted `` with only `"` escaped. An agent producing malicious markdown (prompt-injected web content, agent-as-attacker, etc.) could craft `[click](javascript:alert(1))` and rely on whatever the downstream Matrix client did with non-http schemes. Matrix spec says clients SHOULD filter but behavior varies across Element/Beeper/native bridges/custom clients. Now whitelisted to http(s), ftp, mailto, magnet, plus relative paths and fragment-only links; everything else renders as bracketed plain text so the user still sees the emitted content but it can't be clicked into an XSS. 2. **NULL-byte placeholder hardening** — `markdownToTelegramHtml` uses `\u0000PH\u0000` as the sentinel for code-block extraction. Theoretically an agent could emit a literal NULL-bounded `PH0` and corrupt the splice-back step (substituting one of its own code blocks). Strip NULL bytes up front; they're never legitimately part of Markdown body text. 5 new tests cover the link-scheme allow/deny matrix and the NULL-byte strip. Other files in this audit pass (src/render.ts, src/matrix.ts) came back clean — escaping is consistent, line-array elements are HTML- ready before render time, plan parsing is opaque to adversarial input. Typecheck clean. 102/102 tests pass (was 97). Co-Authored-By: itsablabla Co-Authored-By: Claude Opus 4.7 (1M context) --- replicas-matrix-bridge/src/markdown.test.ts | 33 +++++++++++++++++++ replicas-matrix-bridge/src/markdown.ts | 35 +++++++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) 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}`; }); From 23398a34bebcfec3d62367d17dda6498b946672b Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 30 May 2026 16:41:10 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=8A=20Update=20infrastructure=20st?= =?UTF-8?q?atus=20[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 |