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..cc1aa10a36 --- /dev/null +++ b/app/vmui/packages/vmui/src/constants/markedPlugins.test.ts @@ -0,0 +1,61 @@ +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("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"); + 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](<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"); + }); + + it("renders images as inert text without loading them", () => { + 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", () => { + // 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 b14794a1a8..10fc6610db 100644 --- a/app/vmui/packages/vmui/src/constants/markedPlugins.ts +++ b/app/vmui/packages/vmui/src/constants/markedPlugins.ts @@ -1,11 +1,26 @@ 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 })); marked.use({ + renderer: { + link({ href, title, tokens, raw }) { + const text = this.parser.parseInline(tokens); + if (!isExplicitInlineMarkdownLink(raw) || !isAllowedMarkdownLink(href)) { + return escapeHTML(raw); + } + + const titleAttr = title ? ` title="${escapeHTML(title)}"` : ""; + return `${text}`; + }, + image({ raw }) { + return escapeHTML(raw); + }, + }, walkTokens(token) { if (token.type === "html") { token.type = "text"; @@ -13,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..bf9943c2ac --- /dev/null +++ b/app/vmui/packages/vmui/src/utils/marked/markedLinks.ts @@ -0,0 +1,43 @@ +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; + } + }); +}; + +// 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 === "" || forbiddenURLCharsRegex.test(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(")"); +}; diff --git a/docs/victorialogs/CHANGELOG.md b/docs/victorialogs/CHANGELOG.md index 5cfffe1d15..05c3ded07a 100644 --- a/docs/victorialogs/CHANGELOG.md +++ b/docs/victorialogs/CHANGELOG.md @@ -38,6 +38,7 @@ according to the following docs: * BUGFIX: [cluster version](https://docs.victoriametrics.com/victorialogs/cluster/): avoid `cannot connect to storage node at ...: EOF` errors after `vlselect` or `vlinsert` was idle for more than 60 seconds. See [#1440](https://github.com/VictoriaMetrics/VictoriaLogs/issues/1440). * BUGFIX: [vlselect](https://docs.victoriametrics.com/victorialogs/cluster/): return `502 Bad Gateway` HTTP response code for incoming queries when one of the `vlstorage` nodes runs a VictoriaLogs version with an incompatible internal API instead of `400 Bad Request`. This is consistent with the `502 Bad Gateway` response returned when a `vlstorage` node is unavailable, and it allows building a proper failover scheme in high-availability setups. See [these docs](https://docs.victoriametrics.com/victorialogs/cluster/#high-availability). * BUGFIX: [multi-level cluster setup](https://docs.victoriametrics.com/victorialogs/cluster/#multi-level-cluster-setup): properly return `502 Bad Gateway` HTTP response code when a `vlselect` node queries other `vlselect` nodes and the underlying `vlstorage` is unavailable, as described at [high availability](https://docs.victoriametrics.com/victorialogs/cluster/#high-availability) docs. This allows configuring proper failover schemes to a healthy cluster. +* 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). ## [v1.51.0](https://github.com/VictoriaMetrics/VictoriaLogs/releases/tag/v1.51.0)