import { render, screen, within } from "@testing-library/react";
import { createElement, ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import CustomComponent, {
DomElement,
ProcessNodeContext
} from "../../lib/ReactViews/Custom/CustomComponent";
import parseCustomMarkdownToReact, {
parseCustomMarkdownToReactWithOptions
} from "../../lib/ReactViews/Custom/parseCustomMarkdownToReact";
import registerCustomComponentTypes from "../../lib/ReactViews/Custom/registerCustomComponentTypes";
import { withThemeContext } from "../ReactViews/withContext";
/**
* A minimal custom component that renders its attributes into the DOM so the
* result of sanitization can be observed. `urls` is a URL (list) attribute;
* `label` is free text.
*/
class UrlProbeCustomComponent extends CustomComponent {
get name(): string {
return "urlprobe";
}
get attributes(): string[] {
return ["urls", "label"];
}
get urlAttributes(): string[] {
return ["urls"];
}
processNode(
_context: ProcessNodeContext,
node: DomElement,
_children: ReactElement[],
index: number
): ReactElement {
return createElement(
"span",
{ key: index },
`label=${node.attribs?.label ?? ""} urls=${node.attribs?.urls ?? ""}`
);
}
}
describe("parseCustomMarkdownToReactTs", function () {
beforeEach(function () {
registerCustomComponentTypes();
});
it("correctly parses tooltip terms", function () {
const spatialDataTerm = {
term: "spatial data",
content: "data that is spatial, spluh"
};
const result = parseCustomMarkdownToReactWithOptions(
"something something spatial data mochi",
{
injectTermsAsTooltips: true,
tooltipTerms: [spatialDataTerm]
}
);
render(withThemeContext(result));
expect(
screen.getByRole("button", { name: /spatial data/i })
).toBeInTheDocument();
expect(screen.getByText(spatialDataTerm.content)).toBeInTheDocument();
});
it("skips injecting tooltips when no options provided", function () {
const result = parseCustomMarkdownToReactWithOptions(
"something something spatial data mochi",
{}
);
render(withThemeContext(result));
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("correctly linkifies URLs with ampersands", function () {
const url =
"https://programs.communications.gov.au/geoserver/ows?service=WMS&version=1.3.0&request=GetCapabilities";
const result = parseCustomMarkdownToReact(url);
const { container } = render(withThemeContext(result));
const link = within(container).getByRole("link");
expect(link).toHaveAttribute("href", url);
expect(link).toHaveTextContent(url);
});
});
// Regression tests for F12: parseCustomMarkdownToReact must not preserve
// non-standard URI schemes on browser-navigable attributes. OS/app handler
// schemes (ms-its:, vscode:, slack:, ...) reach the OS when a rendered or
//
is activated, so they must be stripped during sanitization.
describe("parseCustomMarkdownToReact URI scheme sanitization", function () {
beforeEach(function () {
registerCustomComponentTypes();
});
function renderMarkdown(raw: string) {
const { container } = render(
withThemeContext(parseCustomMarkdownToReact(raw))
);
return container;
}
const dangerousHrefSchemes = [
"ms-its:mhtml:file:///c:/!attacker/payload.cab::/index.html",
"vscode://attacker.malicious-extension/install",
"slack://channel?team=attacker",
"intent://attacker.example/#Intent;scheme=https;end"
];
dangerousHrefSchemes.forEach(function (href) {
it(`strips the "${href.split(":")[0]}:" scheme from an `, function () {
const container = renderMarkdown(`click`);
const anchor = container.querySelector("a");
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute("href")).toBeNull();
});
});
it("strips a dangerous scheme from an
", function () {
const container = renderMarkdown(
`
`
);
const img = container.querySelector("img");
expect(img).not.toBeNull();
expect(img?.getAttribute("src")).toBeNull();
});
it("continues to block the javascript: scheme on an ", function () {
const container = renderMarkdown(`click`);
expect(container.querySelector("a")?.getAttribute("href")).toBeNull();
});
it("preserves a normal https link (does not over-strip)", function () {
const url = "https://example.com/data?x=1&y=2";
const container = renderMarkdown(`ok`);
expect(container.querySelector("a")?.getAttribute("href")).toBe(url);
});
it("preserves a relative link (does not over-strip)", function () {
const container = renderMarkdown(`ok`);
expect(container.querySelector("a")?.getAttribute("href")).toBe(
"/relative/path"
);
});
});
// A custom component's URL attribute may be a comma-separated list, and its
// value can flow to an href. DOMPurify only checks the scheme at the start of a
// value, so the sanitize hook must drop dangerous-scheme URLs from anywhere in
// the list while leaving free-text attributes (which may contain `:`) intact.
describe("parseCustomMarkdownToReact custom component attribute sanitization", function () {
beforeEach(function () {
registerCustomComponentTypes();
CustomComponent.register(new UrlProbeCustomComponent());
});
afterEach(function () {
CustomComponent.unregisterAll();
registerCustomComponentTypes();
});
// Serialise the whole converted tree to a string.
function convert(markup: string): string {
return renderToStaticMarkup(parseCustomMarkdownToReact(markup));
}
it("drops a dangerous-scheme URL from a comma-separated URL list", function () {
const html = convert(
``
);
expect(html).toContain("urls=https://example.com/a.csv");
expect(html).not.toContain("vscode:");
});
it("removes a URL attribute whose only value is unsafe", function () {
const html = convert(``);
expect(html).toContain("urls=");
expect(html).not.toContain("vscode:");
});
it("keeps a free-text attribute containing a colon", function () {
const html = convert(``);
expect(html).toContain("label=Time: UTC");
});
});