import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import registry from "../../e2e/registry.fixture.json";
import { PluginTile } from "./plugin-tile";
import { PluginCard } from "./plugin-card";
import { authorAvatarUrl, getAuthor, type Plugin } from "./registry";
import React, { createElement, type ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { parseFragment, type DefaultTreeAdapterMap } from "parse5";
import { describe, expect, it } from "vitest";
import corpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { PluginOverview } from "./overview";
type Element = DefaultTreeAdapterMap["element"];
function elements(html: string): Element[] {
const result: Element[] = [];
function visit(node: DefaultTreeAdapterMap["node"]) {
if ("tagName" in node) result.push(node);
if ("childNodes" in node) node.childNodes.forEach(visit);
}
visit(parseFragment(html));
return result;
}
function render(markdown: string) {
return renderToStaticMarkup({markdown});
}
/** Renders an element on an English page, as the site's route components do. */
async function renderPage(element: ReactElement): Promise {
const routeTree = createRootRoute({ component: () => element });
const router = createRouter({ routeTree, history: createMemoryHistory() });
await router.load();
return renderToStaticMarkup();
}
function attribute(element: Element, name: string) {
return element.attrs.find((attr) => attr.name === name)?.value;
}
function imageSources(html: string) {
return elements(html)
.filter((node) => node.tagName === "img")
.map((node) => attribute(node, "src"));
}
/** Cards show the author's GitHub avatar beside the plugin's own images. */
function avatarUrl(plugin: Plugin) {
return authorAvatarUrl(getAuthor(plugin));
}
const allowedTags = new Set([
"div",
"p",
"br",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"ul",
"ol",
"li",
"strong",
"em",
"del",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
]);
describe("plugin overview security corpus", () => {
it.each(corpus)("$name", ({ markdown, links, images }) => {
const nodes = elements(render(markdown));
expect(
nodes.filter((node) => !allowedTags.has(node.tagName)).map((node) => node.tagName),
).toEqual([]);
expect(
nodes.flatMap((node) => node.attrs).filter((attr) => /^on|^style$|^srcdoc$/i.test(attr.name)),
).toEqual([]);
const anchors = nodes.filter((node) => node.tagName === "a");
expect(anchors.map((node) => attribute(node, "href"))).toEqual(links);
for (const anchor of anchors) {
expect(attribute(anchor, "rel")).toBe("noopener noreferrer nofollow");
expect(attribute(anchor, "target")).toBe("_blank");
}
expect(
nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")),
).toEqual(images);
});
it.each(corpus)("preserves $name inside code spans and fences", ({ markdown }) => {
for (const code of [
`\`${markdown.replaceAll("\n", " ")}\``,
`\`\`\`html\n${markdown}\n\`\`\``,
]) {
const nodes = elements(render(code));
expect(
nodes.filter((node) => ["a", "img", "script", "iframe", "svg"].includes(node.tagName)),
).toEqual([]);
const text = nodes
.filter((node) => node.tagName === "code")
.flatMap((node) => node.childNodes)
.map((child) => ("value" in child ? child.value : ""))
.join("");
expect(text.trim()).toBe(
code.startsWith("```html") ? markdown : markdown.replaceAll("\n", " "),
);
}
});
it("keeps long untrusted input inert", () => {
const nodes = elements(
render("a".repeat(100_000) + "\n\n" + "[click](javascript:alert(1)) ".repeat(1_000)),
);
expect(nodes.filter((node) => ["a", "img", "script"].includes(node.tagName))).toEqual([]);
});
});
describe("registry images", () => {
it.each([
"http://example.com/x",
"data:image/png;base64,AAAA",
"javascript:alert(1)",
"vbscript:msgbox(1)",
"./icon.png",
"//example.com/x",
])("does not load %s in cards or icons", async (url) => {
const plugin = { ...registry.plugins[0], icon: url, media: [url] } as Plugin;
for (const component of [
createElement(PluginTile, { plugin, size: "sm" }),
createElement(PluginCard, { plugin }),
]) {
expect(
imageSources(await renderPage(component)).filter((src) => src !== avatarUrl(plugin)),
).toEqual([]);
}
});
});
it("uses the first HTTPS screenshot when an earlier screenshot is rejected", async () => {
const source = "https://example.com/screenshot.png";
const plugin = {
...registry.plugins[0],
icon: undefined,
media: ["data:image/png;base64,AAAA", source],
} as Plugin;
expect(imageSources(await renderPage(createElement(PluginCard, { plugin })))).toEqual([
source,
avatarUrl(plugin),
]);
});