")).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](javascript: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("")).toBe("
\n");
+ expect(marked(")")).toBe(")
\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)