From afaa95027045aa0759d81813d29d42a7a8e7023a Mon Sep 17 00:00:00 2001 From: func25 Date: Tue, 2 Jun 2026 06:48:14 +0700 Subject: [PATCH 1/5] fix --- .../vmui/src/constants/markedPlugins.test.ts | 36 ++++++++++++ .../vmui/src/constants/markedPlugins.ts | 58 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 app/vmui/packages/vmui/src/constants/markedPlugins.test.ts diff --git a/app/vmui/packages/vmui/src/constants/markedPlugins.test.ts b/app/vmui/packages/vmui/src/constants/markedPlugins.test.ts new file mode 100644 index 0000000000..fd2637494a --- /dev/null +++ b/app/vmui/packages/vmui/src/constants/markedPlugins.test.ts @@ -0,0 +1,36 @@ +import { marked } from "marked"; +import "./markedPlugins"; +import { describe, expect, it } from "vitest"; + +describe("markedPlugins", () => { + it("renders explicit http and https links as active links", () => { + expect(marked("[http](http://example.com)")).toBe("

http

\n"); + expect(marked("[https](https://example.com/path?q=1)")).toBe("

https

\n"); + expect(marked("[title](https://example.com \"a & b\")")).toBe("

title

\n"); + }); + + it("renders non-http links as inert text", () => { + expect(marked("[js](javascript:alert(1))")).toBe("

js

\n"); + expect(marked("[encoded](javascript:alert(1))")).toBe("

encoded

\n"); + expect(marked("[data](data:text/plain,hello)")).toBe("

data

\n"); + expect(marked("[missing-slashes](https:example.com)")).toBe("

missing-slashes

\n"); + expect(marked("[mail](mailto:test@example.com)")).toBe("

mail

\n"); + expect(marked("[tel](tel:123456)")).toBe("

tel

\n"); + expect(marked("[relative](/docs)")).toBe("

relative

\n"); + }); + + it("rejects URLs with whitespace or control characters instead of normalizing them", () => { + expect(marked("[space]()")).toBe("

space

\n"); + expect(marked("[tab]()")).toBe("

tab

\n"); + expect(marked("[encoded-space](https://example.com/a%20b)")).toBe("

encoded-space

\n"); + }); + + it("renders images as inert text without loading them", () => { + expect(marked("![alt text](https://example.com/a.png)")).toBe("

alt text

\n"); + expect(marked("![xss](javascript:alert(1))")).toBe("

xss

\n"); + }); + + it("renders raw HTML as text", () => { + expect(marked("")).toBe("<img src=x onerror=alert(1)>"); + }); +}); diff --git a/app/vmui/packages/vmui/src/constants/markedPlugins.ts b/app/vmui/packages/vmui/src/constants/markedPlugins.ts index b14794a1a8..e90ed10918 100644 --- a/app/vmui/packages/vmui/src/constants/markedPlugins.ts +++ b/app/vmui/packages/vmui/src/constants/markedPlugins.ts @@ -5,7 +5,65 @@ import emojis from "./emojis"; // TODO: Dynamically import the emoji map only if the emoji parser is active marked.use(markedEmoji({ emojis, renderer: (token) => token.emoji })); +const escapeHTML = (value: string): string => value.replace(/[&<>"']/g, (ch) => { + switch (ch) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case "\"": + return """; + case "'": + return "'"; + default: + return ch; + } +}); + +const hasInvalidURLChars = (value: string): boolean => { + for (const ch of value) { + const code = ch.charCodeAt(0); + if (code <= 0x20 || code === 0x7f) { + return true; + } + } + return false; +}; + +const isAllowedMarkdownLink = (href: string): boolean => { + if (href === "" || hasInvalidURLChars(href)) { + return false; + } + const lowerHref = href.toLowerCase(); + if (!lowerHref.startsWith("http://") && !lowerHref.startsWith("https://")) { + return false; + } + + try { + const url = new URL(href); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +}; + marked.use({ + renderer: { + link({ href, title, tokens }) { + const text = this.parser.parseInline(tokens); + if (!isAllowedMarkdownLink(href)) { + return text; + } + + const titleAttr = title ? ` title="${escapeHTML(title)}"` : ""; + return `${text}`; + }, + image({ text }) { + return escapeHTML(text); + }, + }, walkTokens(token) { if (token.type === "html") { token.type = "text"; From 84c735da1966b7838747b3c1275430a7842a3e80 Mon Sep 17 00:00:00 2001 From: func25 Date: Tue, 2 Jun 2026 07:01:47 +0700 Subject: [PATCH 2/5] changelog --- docs/victorialogs/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/victorialogs/CHANGELOG.md b/docs/victorialogs/CHANGELOG.md index 4941dd201d..6a9dbb460e 100644 --- a/docs/victorialogs/CHANGELOG.md +++ b/docs/victorialogs/CHANGELOG.md @@ -42,6 +42,7 @@ according to the following docs: * BUGFIX: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): fix browser navigation issues where the UI state didn't update on URL changes. See [#1056](https://github.com/VictoriaMetrics/VictoriaLogs/issues/1056). * BUGFIX: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): remove extra empty value in `Stream fields` sidebar when selecting all values within a field. See [#1235](https://github.com/VictoriaMetrics/VictoriaLogs/issues/1235). * BUGFIX: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): fix target log rendering in the `Log context` modal. See [#1199](https://github.com/VictoriaMetrics/VictoriaLogs/issues/1199). +* BUGFIX: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): render only explicit `http` and `https` markdown links in log messages as clickable links. Other markdown links and images are rendered as plain text. See [#1470](https://github.com/VictoriaMetrics/VictoriaLogs/pull/1470). * BUGFIX: [HTTP querying APIs](https://docs.victoriametrics.com/victorialogs/querying/#http-api): return `502 Bad Gateway` from [`/select/logsql/stats_query`](https://docs.victoriametrics.com/victorialogs/querying/#querying-log-stats) and [`/select/logsql/stats_query_range`](https://docs.victoriametrics.com/victorialogs/querying/#querying-log-range-stats) when one of `vlstorage` nodes is unavailable in cluster mode. Previously these endpoints could incorrectly return `422 Unprocessable Entity`, which could break retry and failover handling in high-availability setups. See [#1419](https://github.com/VictoriaMetrics/VictoriaLogs/issues/1419). * BUGFIX: [LogsQL](https://docs.victoriametrics.com/victorialogs/logsql/): allow `unpack_json` to parse JSON objects starting with spaces. See [#1416](https://github.com/VictoriaMetrics/VictoriaLogs/pull/1416). * BUGFIX: [web UI](https://docs.victoriametrics.com/victorialogs/querying/#web-ui): preserve the selected tenant while switching between vmui tabs. See [#1447](https://github.com/VictoriaMetrics/VictoriaLogs/issues/1447). From 2211a3ec4aefcb8770a90f3b850811bcacd37e11 Mon Sep 17 00:00:00 2001 From: func25 Date: Tue, 2 Jun 2026 08:41:10 +0700 Subject: [PATCH 3/5] update --- .../vmui/src/constants/markedPlugins.test.ts | 29 ++++++++++++------- .../vmui/src/constants/markedPlugins.ts | 10 +++---- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/app/vmui/packages/vmui/src/constants/markedPlugins.test.ts b/app/vmui/packages/vmui/src/constants/markedPlugins.test.ts index fd2637494a..d74bb1dfbf 100644 --- a/app/vmui/packages/vmui/src/constants/markedPlugins.test.ts +++ b/app/vmui/packages/vmui/src/constants/markedPlugins.test.ts @@ -9,25 +9,32 @@ describe("markedPlugins", () => { expect(marked("[title](https://example.com \"a & b\")")).toBe("

title

\n"); }); + it("renders auto-links and bare URLs as inert text", () => { + expect(marked("https://example.com/a")).toBe("

https://example.com/a

\n"); + expect(marked("")).toBe("

<https://example.com/a>

\n"); + expect(marked("www.example.com")).toBe("

www.example.com

\n"); + }); + it("renders non-http links as inert text", () => { - expect(marked("[js](javascript:alert(1))")).toBe("

js

\n"); - expect(marked("[encoded](javascript:alert(1))")).toBe("

encoded

\n"); - expect(marked("[data](data:text/plain,hello)")).toBe("

data

\n"); - expect(marked("[missing-slashes](https:example.com)")).toBe("

missing-slashes

\n"); - expect(marked("[mail](mailto:test@example.com)")).toBe("

mail

\n"); - expect(marked("[tel](tel:123456)")).toBe("

tel

\n"); - expect(marked("[relative](/docs)")).toBe("

relative

\n"); + expect(marked("[js](javascript:alert(1))")).toBe("

[js](javascript:alert(1))

\n"); + expect(marked("[encoded](javascript:alert(1))")).toBe("

[encoded](jav&#x61;script:alert(1))

\n"); + expect(marked("[data](data:text/plain,hello)")).toBe("

[data](data:text/plain,hello)

\n"); + expect(marked("[missing-slashes](https:example.com)")).toBe("

[missing-slashes](https:example.com)

\n"); + expect(marked("[mail](mailto:test@example.com)")).toBe("

[mail](mailto:test@example.com)

\n"); + expect(marked("[tel](tel:123456)")).toBe("

[tel](tel:123456)

\n"); + expect(marked("[relative](/docs)")).toBe("

[relative](/docs)

\n"); }); it("rejects URLs with whitespace or control characters instead of normalizing them", () => { - expect(marked("[space]()")).toBe("

space

\n"); - expect(marked("[tab]()")).toBe("

tab

\n"); + expect(marked("[space]()")).toBe("

[space](<https://example.com/a b>)

\n"); + expect(marked("[tab]()")).toBe("

[tab](<https://example.com/a\tb>)

\n"); + expect(marked("[script](>)")).toBe("

[script](<https://example.com/a b?x=<script>>)

\n"); expect(marked("[encoded-space](https://example.com/a%20b)")).toBe("

encoded-space

\n"); }); it("renders images as inert text without loading them", () => { - expect(marked("![alt text](https://example.com/a.png)")).toBe("

alt text

\n"); - expect(marked("![xss](javascript:alert(1))")).toBe("

xss

\n"); + expect(marked("![alt text](https://example.com/a.png)")).toBe("

![alt text](https://example.com/a.png)

\n"); + expect(marked("![xss](javascript:alert(1))")).toBe("

![xss](javascript:alert(1))

\n"); }); it("renders raw HTML as text", () => { diff --git a/app/vmui/packages/vmui/src/constants/markedPlugins.ts b/app/vmui/packages/vmui/src/constants/markedPlugins.ts index e90ed10918..33a5cae6b3 100644 --- a/app/vmui/packages/vmui/src/constants/markedPlugins.ts +++ b/app/vmui/packages/vmui/src/constants/markedPlugins.ts @@ -51,17 +51,17 @@ const isAllowedMarkdownLink = (href: string): boolean => { marked.use({ renderer: { - link({ href, title, tokens }) { + link({ href, title, tokens, raw }) { const text = this.parser.parseInline(tokens); - if (!isAllowedMarkdownLink(href)) { - return text; + if (!raw.startsWith("[") || !isAllowedMarkdownLink(href)) { + return escapeHTML(raw); } const titleAttr = title ? ` title="${escapeHTML(title)}"` : ""; return `${text}`; }, - image({ text }) { - return escapeHTML(text); + image({ raw }) { + return escapeHTML(raw); }, }, walkTokens(token) { From b6161d7402ce6d57f58246263ee7beef0f1322bc Mon Sep 17 00:00:00 2001 From: Yury Molodov Date: Wed, 10 Jun 2026 15:23:30 +0200 Subject: [PATCH 4/5] app/vmui: improve safe markdown link rendering in logs --- .../vmui/src/constants/markedPlugins.test.ts | 18 +++++++ .../vmui/src/constants/markedPlugins.ts | 53 +++---------------- .../vmui/src/utils/marked/markedLinks.ts | 50 +++++++++++++++++ 3 files changed, 75 insertions(+), 46 deletions(-) create mode 100644 app/vmui/packages/vmui/src/utils/marked/markedLinks.ts diff --git a/app/vmui/packages/vmui/src/constants/markedPlugins.test.ts b/app/vmui/packages/vmui/src/constants/markedPlugins.test.ts index d74bb1dfbf..cc1aa10a36 100644 --- a/app/vmui/packages/vmui/src/constants/markedPlugins.test.ts +++ b/app/vmui/packages/vmui/src/constants/markedPlugins.test.ts @@ -9,12 +9,27 @@ describe("markedPlugins", () => { expect(marked("[title](https://example.com \"a & b\")")).toBe("

title

\n"); }); + it("escapes HTML in allowed link text while preserving markdown formatting", () => { + // noinspection HtmlRequiredAltAttribute,HtmlUnknownTarget,HtmlDeprecatedAttribute + expect(marked("[](https://example.com)")).toBe("

<img src=x onerror=alert(1)>

\n"); + expect(marked("[](https://example.com)")).toBe("

<svg/onload=alert(1)>

\n"); + expect(marked("[**bold**](https://example.com)")).toBe("

bold

\n"); + }); + it("renders auto-links and bare URLs as inert text", () => { expect(marked("https://example.com/a")).toBe("

https://example.com/a

\n"); expect(marked("")).toBe("

<https://example.com/a>

\n"); expect(marked("www.example.com")).toBe("

www.example.com

\n"); }); + it("renders reference-style links as inert text", () => { + expect(marked("[ref][id]\n\n[id]: https://example.com")).toBe("

[ref][id]

\n

[id]: https://example.com

\n"); + expect(marked("[collapsed][]\n\n[collapsed]: https://example.com")).toBe("

[collapsed][]

\n

[collapsed]: https://example.com

\n"); + expect(marked("[shortcut]\n\n[shortcut]: https://example.com")).toBe("

[shortcut]

\n

[shortcut]: https://example.com

\n"); + expect(marked("[id]: https://example.com")).toBe("

[id]: https://example.com

\n"); + expect(marked("[a\\](b][id)]\n\n[id)]: https://example.com")).toBe("

[a](b][id)]

\n

[id)]: https://example.com

\n"); + }); + it("renders non-http links as inert text", () => { expect(marked("[js](javascript:alert(1))")).toBe("

[js](javascript:alert(1))

\n"); expect(marked("[encoded](javascript:alert(1))")).toBe("

[encoded](jav&#x61;script:alert(1))

\n"); @@ -28,6 +43,8 @@ describe("markedPlugins", () => { it("rejects URLs with whitespace or control characters instead of normalizing them", () => { expect(marked("[space]()")).toBe("

[space](<https://example.com/a b>)

\n"); expect(marked("[tab]()")).toBe("

[tab](<https://example.com/a\tb>)

\n"); + expect(marked("[del]()")).toBe("

[del](<https://example.com/a\u007fb>)

\n"); + expect(marked("[c1]()")).toBe("

[c1](<https://example.com/a\u0085b>)

\n"); expect(marked("[script](>)")).toBe("

[script](<https://example.com/a b?x=<script>>)

\n"); expect(marked("[encoded-space](https://example.com/a%20b)")).toBe("

encoded-space

\n"); }); @@ -38,6 +55,7 @@ describe("markedPlugins", () => { }); it("renders raw HTML as text", () => { + // noinspection HtmlRequiredAltAttribute,HtmlUnknownTarget,HtmlDeprecatedAttribute expect(marked("")).toBe("<img src=x onerror=alert(1)>"); }); }); diff --git a/app/vmui/packages/vmui/src/constants/markedPlugins.ts b/app/vmui/packages/vmui/src/constants/markedPlugins.ts index 33a5cae6b3..10fc6610db 100644 --- a/app/vmui/packages/vmui/src/constants/markedPlugins.ts +++ b/app/vmui/packages/vmui/src/constants/markedPlugins.ts @@ -1,59 +1,16 @@ import markedEmoji from "../utils/marked/markedEmoji"; import { marked } from "marked"; import emojis from "./emojis"; +import { escapeHTML, isAllowedMarkdownLink, isExplicitInlineMarkdownLink } from "../utils/marked/markedLinks"; // TODO: Dynamically import the emoji map only if the emoji parser is active marked.use(markedEmoji({ emojis, renderer: (token) => token.emoji })); -const escapeHTML = (value: string): string => value.replace(/[&<>"']/g, (ch) => { - switch (ch) { - case "&": - return "&"; - case "<": - return "<"; - case ">": - return ">"; - case "\"": - return """; - case "'": - return "'"; - default: - return ch; - } -}); - -const hasInvalidURLChars = (value: string): boolean => { - for (const ch of value) { - const code = ch.charCodeAt(0); - if (code <= 0x20 || code === 0x7f) { - return true; - } - } - return false; -}; - -const isAllowedMarkdownLink = (href: string): boolean => { - if (href === "" || hasInvalidURLChars(href)) { - return false; - } - const lowerHref = href.toLowerCase(); - if (!lowerHref.startsWith("http://") && !lowerHref.startsWith("https://")) { - return false; - } - - try { - const url = new URL(href); - return url.protocol === "http:" || url.protocol === "https:"; - } catch { - return false; - } -}; - marked.use({ renderer: { link({ href, title, tokens, raw }) { const text = this.parser.parseInline(tokens); - if (!raw.startsWith("[") || !isAllowedMarkdownLink(href)) { + if (!isExplicitInlineMarkdownLink(raw) || !isAllowedMarkdownLink(href)) { return escapeHTML(raw); } @@ -71,6 +28,10 @@ marked.use({ } }, tokenizer: { - code() { return undefined; } + // Keep indented/code-like log lines as plain text instead of consuming them as markdown code blocks. + code() { return undefined; }, + + // Keep reference definitions visible in logs instead of treating them as hidden markdown metadata. + def() { return undefined; }, } }); diff --git a/app/vmui/packages/vmui/src/utils/marked/markedLinks.ts b/app/vmui/packages/vmui/src/utils/marked/markedLinks.ts new file mode 100644 index 0000000000..419aa91706 --- /dev/null +++ b/app/vmui/packages/vmui/src/utils/marked/markedLinks.ts @@ -0,0 +1,50 @@ +export const escapeHTML = (value: string): string => { + return value.replace(/[&<>"']/g, (ch) => { + switch (ch) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case "\"": + return """; + case "'": + return "'"; + default: + return ch; + } + }); +}; + +export const hasInvalidURLChars = (value: string): boolean => { + for (const ch of value) { + const code = ch.charCodeAt(0); + if (code <= 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) { + return true; + } + } + return false; +}; + +export const isAllowedMarkdownLink = (href: string): boolean => { + if (href === "" || hasInvalidURLChars(href)) { + return false; + } + + const lowerHref = href.toLowerCase(); + if (!lowerHref.startsWith("http://") && !lowerHref.startsWith("https://")) { + return false; + } + + try { + const url = new URL(href); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +}; + +export const isExplicitInlineMarkdownLink = (raw: string): boolean => { + return raw.startsWith("[") && raw.includes("](") && raw.endsWith(")"); +}; From f768f86252def3038392bf6199c4fafc25e73cb3 Mon Sep 17 00:00:00 2001 From: func25 Date: Sun, 28 Jun 2026 07:36:10 +0700 Subject: [PATCH 5/5] update --- .../packages/vmui/src/utils/marked/markedLinks.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/app/vmui/packages/vmui/src/utils/marked/markedLinks.ts b/app/vmui/packages/vmui/src/utils/marked/markedLinks.ts index 419aa91706..bf9943c2ac 100644 --- a/app/vmui/packages/vmui/src/utils/marked/markedLinks.ts +++ b/app/vmui/packages/vmui/src/utils/marked/markedLinks.ts @@ -17,18 +17,11 @@ export const escapeHTML = (value: string): string => { }); }; -export const hasInvalidURLChars = (value: string): boolean => { - for (const ch of value) { - const code = ch.charCodeAt(0); - if (code <= 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) { - return true; - } - } - return false; -}; +// Reject control, format/bidi, separator and invisible code points that can spoof the URL. +const forbiddenURLCharsRegex = /[\p{Cc}\p{Cf}\p{Zs}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u; export const isAllowedMarkdownLink = (href: string): boolean => { - if (href === "" || hasInvalidURLChars(href)) { + if (href === "" || forbiddenURLCharsRegex.test(href)) { return false; }