Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions app/vmui/packages/vmui/src/constants/markedPlugins.test.ts
Original file line number Diff line number Diff line change
@@ -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("<p><a href=\"http://example.com\">http</a></p>\n");
expect(marked("[https](https://example.com/path?q=1)")).toBe("<p><a href=\"https://example.com/path?q=1\">https</a></p>\n");
expect(marked("[title](https://example.com \"a & b\")")).toBe("<p><a href=\"https://example.com\" title=\"a &amp; b\">title</a></p>\n");
});

it("escapes HTML in allowed link text while preserving markdown formatting", () => {
// noinspection HtmlRequiredAltAttribute,HtmlUnknownTarget,HtmlDeprecatedAttribute
expect(marked("[<img src=x onerror=alert(1)>](https://example.com)")).toBe("<p><a href=\"https://example.com\">&lt;img src=x onerror=alert(1)&gt;</a></p>\n");
expect(marked("[<svg/onload=alert(1)>](https://example.com)")).toBe("<p><a href=\"https://example.com\">&lt;svg/onload=alert(1)&gt;</a></p>\n");
expect(marked("[**bold**](https://example.com)")).toBe("<p><a href=\"https://example.com\"><strong>bold</strong></a></p>\n");
});

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

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

it("renders non-http links as inert text", () => {
expect(marked("[js](javascript:alert(1))")).toBe("<p>[js](javascript:alert(1))</p>\n");
expect(marked("[encoded](jav&#x61;script:alert(1))")).toBe("<p>[encoded](jav&amp;#x61;script:alert(1))</p>\n");
expect(marked("[data](data:text/plain,hello)")).toBe("<p>[data](data:text/plain,hello)</p>\n");
expect(marked("[missing-slashes](https:example.com)")).toBe("<p>[missing-slashes](https:example.com)</p>\n");
expect(marked("[mail](mailto:test@example.com)")).toBe("<p>[mail](mailto:test@example.com)</p>\n");
expect(marked("[tel](tel:123456)")).toBe("<p>[tel](tel:123456)</p>\n");
expect(marked("[relative](/docs)")).toBe("<p>[relative](/docs)</p>\n");
});

it("rejects URLs with whitespace or control characters instead of normalizing them", () => {
expect(marked("[space](<https://example.com/a b>)")).toBe("<p>[space](&lt;https://example.com/a b&gt;)</p>\n");
expect(marked("[tab](<https://example.com/a\tb>)")).toBe("<p>[tab](&lt;https://example.com/a\tb&gt;)</p>\n");
expect(marked("[del](<https://example.com/a\u007fb>)")).toBe("<p>[del](&lt;https://example.com/a\u007fb&gt;)</p>\n");
expect(marked("[c1](<https://example.com/a\u0085b>)")).toBe("<p>[c1](&lt;https://example.com/a\u0085b&gt;)</p>\n");
expect(marked("[script](<https://example.com/a b?x=<script>>)")).toBe("<p>[script](&lt;https://example.com/a b?x=&lt;script&gt;>)</p>\n");
expect(marked("[encoded-space](https://example.com/a%20b)")).toBe("<p><a href=\"https://example.com/a%20b\">encoded-space</a></p>\n");
});

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

it("renders raw HTML as text", () => {
// noinspection HtmlRequiredAltAttribute,HtmlUnknownTarget,HtmlDeprecatedAttribute
expect(marked("<img src=x onerror=alert(1)>")).toBe("&lt;img src=x onerror=alert(1)&gt;");
});
});
21 changes: 20 additions & 1 deletion app/vmui/packages/vmui/src/constants/markedPlugins.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
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 `<a href="${escapeHTML(href)}"${titleAttr}>${text}</a>`;
},
image({ raw }) {
return escapeHTML(raw);
},
},
walkTokens(token) {
if (token.type === "html") {
token.type = "text";
token.text = token.raw ?? token.text ?? "";
}
},
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; },
}
});
43 changes: 43 additions & 0 deletions app/vmui/packages/vmui/src/utils/marked/markedLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export const escapeHTML = (value: string): string => {
return value.replace(/[&<>"']/g, (ch) => {
switch (ch) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case "\"":
return "&quot;";
case "'":
return "&#39;";
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(")");
};
1 change: 1 addition & 0 deletions docs/victorialogs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading