mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Sanitize plugin overview rendering: no raw HTML, https links only, no relative links (#6244)
* fix(plugins): restrict overview content to safe Markdown and HTTPS destinations Registry overviews are author-controlled content read away from their repositories. Apply a shared presentation policy instead of the documentation renderer so relative links, non-HTTPS URLs, raw HTML, and author styles never become active overview content. * fix(plugins): choose the first allowed screenshot for previews
This commit is contained in:
@@ -123,6 +123,13 @@ Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
|
|||||||
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
|
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
|
||||||
selection is unavailable for registry installs; install an explicit source to select your own.
|
selection is unavailable for registry installs; install an explicit source to select your own.
|
||||||
|
|
||||||
|
Registry overviews are untrusted author content, even before installation. Render them with
|
||||||
|
`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative
|
||||||
|
URLs, and isolated external links. The website adapter and corpus tests live in
|
||||||
|
`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is
|
||||||
|
`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share
|
||||||
|
this policy. Installed-plugin descriptions in the app remain plain text.
|
||||||
|
|
||||||
Use `host/owner/slug` for a registry at `https://host`, or set
|
Use `host/owner/slug` for a registry at `https://host`, or set
|
||||||
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
|
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
|
||||||
Private registry credentials live in daemon config under
|
Private registry credentials live in daemon config under
|
||||||
|
|||||||
Generated
+1
@@ -40174,6 +40174,7 @@
|
|||||||
"@types/react": "^19.1.6",
|
"@types/react": "^19.1.6",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"@vitejs/plugin-react": "^4.5.1",
|
"@vitejs/plugin-react": "^4.5.1",
|
||||||
|
"parse5": "^7.3.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.3",
|
"vite": "^7.3.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
|
||||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
@@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
|
|||||||
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
|
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
|
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
|
||||||
|
const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
|
||||||
|
await installPluginWithUntrustedDescription(pluginEnvironment, description);
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openPluginSettings(page);
|
||||||
|
const text = page.getByText(description, { exact: true });
|
||||||
|
await expect(text).toBeVisible();
|
||||||
|
// This field must remain a text node, with no Markdown links, images, or HTML children.
|
||||||
|
expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function installPluginWithUntrustedDescription(
|
||||||
|
environment: PluginEnvironment,
|
||||||
|
description: string,
|
||||||
|
) {
|
||||||
|
const directory = await createDirectoryPlugin(
|
||||||
|
environment.directory,
|
||||||
|
"plain-text-plugin",
|
||||||
|
description,
|
||||||
|
"Plain text plugin",
|
||||||
|
);
|
||||||
|
await environment.client.installPluginSource({ source: directory });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { pluginOverviewUrl } from "./plugin-overview.js";
|
||||||
|
|
||||||
|
describe("plugin overview destinations", () => {
|
||||||
|
it.each([
|
||||||
|
undefined,
|
||||||
|
"",
|
||||||
|
"./docs",
|
||||||
|
"../docs",
|
||||||
|
"/login",
|
||||||
|
"//example.com",
|
||||||
|
"#section",
|
||||||
|
"?login",
|
||||||
|
"http://example.com",
|
||||||
|
"mailto:hello@example.com",
|
||||||
|
"ftp://example.com",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"data:image/png;base64,AAAA",
|
||||||
|
"vbscript:msgbox(1)",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"javascript%3Aalert(1)",
|
||||||
|
"javascript%253Aalert(1)",
|
||||||
|
"java\tscript:alert(1)",
|
||||||
|
" https://example.com",
|
||||||
|
"https://example.com\n",
|
||||||
|
"https:example.com",
|
||||||
|
"https:///example.com",
|
||||||
|
"https://paseo.sh@evil.example",
|
||||||
|
"https://example.com\\@evil.example",
|
||||||
|
"https://",
|
||||||
|
])("leaves %s as text", (url) => {
|
||||||
|
expect(pluginOverviewUrl(url)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"https://example.com/path?q=a&b=c#section",
|
||||||
|
"HTTPS://example.com/path",
|
||||||
|
"https://example.com/%3Cscript%3E",
|
||||||
|
])("allows %s without decoding it again", (url) => {
|
||||||
|
expect(pluginOverviewUrl(url)).toBe(url);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/** Presentation contract for author-controlled registry content, independent of the UI runtime. */
|
||||||
|
export const pluginOverviewPolicy = {
|
||||||
|
rawHtml: false,
|
||||||
|
elements: [
|
||||||
|
"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",
|
||||||
|
],
|
||||||
|
link: { rel: "noopener noreferrer nofollow", target: "_blank" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Validate decoded Markdown destinations; never resolve against the reader's page or repository. */
|
||||||
|
export function pluginOverviewUrl(value: string | undefined): string | undefined {
|
||||||
|
// URL parsers discard controls and normalize backslashes; reject those ambiguous spellings.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (!value || !/^https:\/\/[^/?#]/i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value))
|
||||||
|
return undefined;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol !== "https:" || !url.hostname || url.username || url.password)
|
||||||
|
return undefined;
|
||||||
|
return value;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1202
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"id": "security/overview",
|
||||||
|
"name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
|
||||||
|
"description": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
|
||||||
|
"artifact": {
|
||||||
|
"kind": "npm",
|
||||||
|
"package": "@alhassanaraouf/paseo-base2tone-theme",
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg=="
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"url": "javascript:alert(1)"
|
||||||
|
},
|
||||||
|
"categories": ["themes"],
|
||||||
|
"author": {
|
||||||
|
"github": "security",
|
||||||
|
"name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">"
|
||||||
|
},
|
||||||
|
"screenshots": [
|
||||||
|
"data:image/svg+xml,<svg onload=\"globalThis.__overviewExecuted=1\"/>",
|
||||||
|
"http://example.com/image.png"
|
||||||
|
],
|
||||||
|
"submittedAt": "2026-09-24T00:00:00.000Z",
|
||||||
|
"reviewedAt": "2026-10-03T00:00:00.000Z",
|
||||||
|
"publishedAt": "2026-09-24T17:12:05.467Z",
|
||||||
|
"updatedAt": "2026-10-03T00:00:00.000Z",
|
||||||
|
"installs": 0,
|
||||||
|
"readme": "## Overview security\n\n</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
|
||||||
|
"icon": "data:image/png;base64,AAAA"
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import securityPlugin from "./overview.fixture.json" with { type: "json" };
|
||||||
import { expect, test, type Page, type Locator } from "playwright/test";
|
import { expect, test, type Page, type Locator } from "playwright/test";
|
||||||
import registry from "./registry.fixture.json" with { type: "json" };
|
import registry from "./registry.fixture.json" with { type: "json" };
|
||||||
import { CATEGORIES } from "../src/plugins/categories";
|
import { CATEGORIES } from "../src/plugins/categories";
|
||||||
@@ -35,7 +36,7 @@ test("browses from the directory into a category, a plugin, and its author", asy
|
|||||||
await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1);
|
await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1);
|
||||||
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1);
|
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }),
|
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Copy to clipboard" }).click();
|
await page.getByRole("button", { name: "Copy to clipboard" }).click();
|
||||||
await expect
|
await expect
|
||||||
@@ -168,7 +169,7 @@ test.describe("search engine visits without JavaScript", () => {
|
|||||||
expect(response?.headers()["x-robots-tag"]).toBeUndefined();
|
expect(response?.headers()["x-robots-tag"]).toBeUndefined();
|
||||||
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible();
|
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }),
|
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expectPageMetadata(
|
await expectPageMetadata(
|
||||||
page,
|
page,
|
||||||
@@ -366,3 +367,72 @@ async function expectThumbnailCard(card: Locator, source: string, id: string) {
|
|||||||
expect(box.width).toBeGreaterThan(0);
|
expect(box.width).toBeGreaterThan(0);
|
||||||
expect(box.width / box.height).toBeCloseTo(1.6, 2);
|
expect(box.width / box.height).toBeCloseTo(1.6, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("keeps author content inert through SSR and hydration", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}, testInfo) => {
|
||||||
|
await verifyPluginOverviewResponse(request);
|
||||||
|
await openUntrustedPlugin(page);
|
||||||
|
await expectPluginOverviewSafe(page);
|
||||||
|
await page.reload();
|
||||||
|
await expectPluginOverviewSafe(page);
|
||||||
|
await testInfo.attach("plugin-overview", {
|
||||||
|
body: await page.screenshot(),
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function verifyPluginOverviewResponse(request: import("playwright/test").APIRequestContext) {
|
||||||
|
const response = await request.get("/plugins/security/overview");
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const html = await response.text();
|
||||||
|
expect(html).not.toContain("<script>globalThis.__overviewExecuted=1</script>");
|
||||||
|
expect(html).toContain("\\x3C/script>");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openUntrustedPlugin(page: Page) {
|
||||||
|
await page.goto("/plugins/security/overview");
|
||||||
|
await expect(page.getByRole("heading", { name: "Overview security", exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPluginOverviewSafe(page: Page) {
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => (globalThis as { __overviewExecuted?: number }).__overviewExecuted),
|
||||||
|
).toBeUndefined();
|
||||||
|
// Security assertions inspect every emitted attribute, including inaccessible injected elements.
|
||||||
|
const violations = await page.locator(".docs-prose").evaluate((root) => {
|
||||||
|
const bad: string[] = [];
|
||||||
|
for (const element of root.querySelectorAll("*")) {
|
||||||
|
if (
|
||||||
|
["SCRIPT", "IFRAME", "SVG", "FORM", "INPUT", "META", "BASE", "STYLE"].includes(
|
||||||
|
element.tagName,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bad.push(element.tagName);
|
||||||
|
for (const attr of element.attributes) {
|
||||||
|
if (/^on|^style$|^srcdoc$/i.test(attr.name)) bad.push(attr.name);
|
||||||
|
if (["href", "src"].includes(attr.name) && !attr.value.startsWith("https://"))
|
||||||
|
bad.push(attr.value);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
element.tagName === "A" &&
|
||||||
|
(element.getAttribute("rel") !== "noopener noreferrer nofollow" ||
|
||||||
|
element.getAttribute("target") !== "_blank")
|
||||||
|
)
|
||||||
|
bad.push("unsafe anchor");
|
||||||
|
}
|
||||||
|
return bad;
|
||||||
|
});
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
await expect(page.getByRole("link", { name: "Source", exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("img", { name: /screenshot/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("heading", { level: 1 })).toHaveText(securityPlugin.name);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("link", { name: securityPlugin.author.name, exact: true }),
|
||||||
|
).toHaveAttribute("href", "/plugins/security");
|
||||||
|
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
|
||||||
|
"content",
|
||||||
|
securityPlugin.description,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import registry from "./registry.fixture.json" with { type: "json" };
|
import registry from "./registry.fixture.json" with { type: "json" };
|
||||||
|
|
||||||
|
import securityPlugin from "./overview.fixture.json" with { type: "json" };
|
||||||
|
|
||||||
|
import overviewCorpus from "../../protocol/tests/fixtures/plugin-overview.json" with { type: "json" };
|
||||||
|
securityPlugin.readme +=
|
||||||
|
"\n\n" +
|
||||||
|
overviewCorpus
|
||||||
|
.map(({ markdown }) => markdown + "\n\n```html\n" + markdown + "\n```")
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
// Serve the published registry contract so SSR and browser navigation use the same data.
|
// Serve the published registry contract so SSR and browser navigation use the same data.
|
||||||
createServer((request, response) => {
|
createServer((request, response) => {
|
||||||
response.setHeader("Content-Type", "application/json");
|
response.setHeader("Content-Type", "application/json");
|
||||||
@@ -8,7 +17,9 @@ createServer((request, response) => {
|
|||||||
response.end(JSON.stringify(registry));
|
response.end(JSON.stringify(registry));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const plugin = registry.plugins.find((entry) => request.url === `/plugins/${entry.id}.json`);
|
const plugin = [securityPlugin, ...registry.plugins].find(
|
||||||
|
(entry) => request.url === `/plugins/${entry.id}.json`,
|
||||||
|
);
|
||||||
response.statusCode = plugin ? 200 : 404;
|
response.statusCode = plugin ? 200 : 404;
|
||||||
response.end(JSON.stringify(plugin ?? { error: "Plugin not found" }));
|
response.end(JSON.stringify(plugin ?? { error: "Plugin not found" }));
|
||||||
}).listen(8188, "127.0.0.1");
|
}).listen(8188, "127.0.0.1");
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"@types/react": "^19.1.6",
|
"@types/react": "^19.1.6",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"@vitejs/plugin-react": "^4.5.1",
|
"@vitejs/plugin-react": "^4.5.1",
|
||||||
|
"parse5": "^7.3.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.3",
|
"vite": "^7.3.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import registry from "../../e2e/registry.fixture.json";
|
||||||
|
import { PluginTile } from "./plugin-tile";
|
||||||
|
import { PluginCard } from "./plugin-card";
|
||||||
|
import type { Plugin } from "./registry";
|
||||||
|
import React, { createElement } 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(<PluginOverview>{markdown}</PluginOverview>);
|
||||||
|
}
|
||||||
|
function attribute(element: Element, name: string) {
|
||||||
|
return element.attrs.find((attr) => attr.name === name)?.value;
|
||||||
|
}
|
||||||
|
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", (url) => {
|
||||||
|
const plugin = { ...registry.plugins[0], icon: url, screenshots: [url] } as Plugin;
|
||||||
|
for (const component of [
|
||||||
|
createElement(PluginTile, { plugin, size: "sm" }),
|
||||||
|
createElement(PluginCard, { plugin }),
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
elements(renderToStaticMarkup(component)).filter((node) => node.tagName === "img"),
|
||||||
|
).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the first HTTPS screenshot when an earlier screenshot is rejected", () => {
|
||||||
|
const source = "https://example.com/screenshot.png";
|
||||||
|
const plugin = {
|
||||||
|
...registry.plugins[0],
|
||||||
|
icon: undefined,
|
||||||
|
screenshots: ["data:image/png;base64,AAAA", source],
|
||||||
|
} as Plugin;
|
||||||
|
const nodes = elements(renderToStaticMarkup(createElement(PluginCard, { plugin })));
|
||||||
|
expect(
|
||||||
|
nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")),
|
||||||
|
).toEqual([source]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import ReactMarkdown, { type Components } from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||||
|
|
||||||
|
/** A rejected destination leaves its label as text, without even an empty anchor. */
|
||||||
|
export function PluginContentLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
href?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const destination = pluginOverviewUrl(href);
|
||||||
|
return destination ? (
|
||||||
|
<a href={destination} {...pluginOverviewPolicy.link} className={className}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedElements = [...pluginOverviewPolicy.elements];
|
||||||
|
const remarkPlugins = [remarkGfm];
|
||||||
|
|
||||||
|
const components: Components = {
|
||||||
|
a: ({ href, children }) => <PluginContentLink href={href}>{children}</PluginContentLink>,
|
||||||
|
img: ({ src, alt }) => {
|
||||||
|
const source = pluginOverviewUrl(typeof src === "string" ? src : undefined);
|
||||||
|
return source ? <img src={source} alt={alt ?? ""} loading="lazy" /> : alt;
|
||||||
|
},
|
||||||
|
// GFM alignment otherwise becomes an inline style in react-markdown.
|
||||||
|
th: ({ children }) => <th>{children}</th>,
|
||||||
|
td: ({ children }) => <td>{children}</td>,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Untrusted overviews do not inherit documentation directives, anchors, or HTML highlighters. */
|
||||||
|
export function PluginOverview({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<div className="docs-prose">
|
||||||
|
<ReactMarkdown
|
||||||
|
skipHtml={!pluginOverviewPolicy.rawHtml}
|
||||||
|
allowedElements={allowedElements}
|
||||||
|
unwrapDisallowed
|
||||||
|
remarkPlugins={remarkPlugins}
|
||||||
|
urlTransform={pluginOverviewUrl}
|
||||||
|
components={components}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { pluginCardScreenshot } from "./thumbnails";
|
import { pluginCardScreenshot } from "./thumbnails";
|
||||||
import { pluginHref } from "./links";
|
import { pluginHref } from "./links";
|
||||||
@@ -7,9 +8,9 @@ import { formatInstalls, getCategory, type Plugin } from "./registry";
|
|||||||
export const PLUGIN_GRID_CLASS =
|
export const PLUGIN_GRID_CLASS =
|
||||||
"grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4";
|
"grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4";
|
||||||
|
|
||||||
/** First screenshot, or the plugin tile on a quiet backdrop when there is none. */
|
/** First allowed screenshot, or the plugin tile on a quiet backdrop when there is none. */
|
||||||
function PluginShot({ plugin }: { plugin: Plugin }) {
|
function PluginShot({ plugin }: { plugin: Plugin }) {
|
||||||
const url = plugin.screenshots[0];
|
const url = plugin.screenshots.find(pluginOverviewUrl);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||||
import type { Plugin } from "./registry";
|
import type { Plugin } from "./registry";
|
||||||
|
|
||||||
const SIZE_CLASS = {
|
const SIZE_CLASS = {
|
||||||
@@ -12,7 +13,7 @@ export function PluginTile({ plugin, size }: { plugin: Plugin; size: keyof typeo
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
className={`flex flex-shrink-0 items-center justify-center border border-white/10 bg-white/[0.06] font-medium text-white/80 ${SIZE_CLASS[size]}`}
|
className={`flex flex-shrink-0 items-center justify-center border border-white/10 bg-white/[0.06] font-medium text-white/80 ${SIZE_CLASS[size]}`}
|
||||||
>
|
>
|
||||||
{plugin.icon ? (
|
{pluginOverviewUrl(plugin.icon) ? (
|
||||||
<img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" />
|
<img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" />
|
||||||
) : (
|
) : (
|
||||||
plugin.name.charAt(0).toUpperCase()
|
plugin.name.charAt(0).toUpperCase()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PluginContentLink } from "~/plugins/overview";
|
||||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -64,21 +65,16 @@ function AuthorPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4 pt-1">
|
<div className="flex items-center gap-4 pt-1">
|
||||||
{authorNpmUrl(author) && (
|
{authorNpmUrl(author) && (
|
||||||
<a
|
<PluginContentLink href={authorNpmUrl(author)} className={LINK_CLASS}>
|
||||||
href={authorNpmUrl(author)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={LINK_CLASS}
|
|
||||||
>
|
|
||||||
npm
|
npm
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</PluginContentLink>
|
||||||
)}
|
)}
|
||||||
{github && (
|
{github && (
|
||||||
<a href={github} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
<PluginContentLink href={github} className={LINK_CLASS}>
|
||||||
GitHub
|
GitHub
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</PluginContentLink>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { CodeBlock } from "~/components/code-block";
|
import { CodeBlock } from "~/components/code-block";
|
||||||
import { DocsMarkdown } from "~/components/docs-markdown";
|
import { PluginOverview, PluginContentLink } from "~/plugins/overview";
|
||||||
|
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||||
import { SiteShell } from "~/components/site-shell";
|
import { SiteShell } from "~/components/site-shell";
|
||||||
import { pageMeta } from "~/meta";
|
import { pageMeta } from "~/meta";
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +35,7 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
|
|||||||
loaderData?.plugin ? `${loaderData.plugin.name} – Paseo plugin` : "Plugin not found – Paseo",
|
loaderData?.plugin ? `${loaderData.plugin.name} – Paseo plugin` : "Plugin not found – Paseo",
|
||||||
loaderData?.plugin?.description ?? "Plugin not found.",
|
loaderData?.plugin?.description ?? "Plugin not found.",
|
||||||
`/plugins/${params.owner}/${params.slug}`,
|
`/plugins/${params.owner}/${params.slug}`,
|
||||||
loaderData?.plugin.screenshots[0],
|
loaderData?.plugin.screenshots.find(pluginOverviewUrl),
|
||||||
),
|
),
|
||||||
component: PluginPage,
|
component: PluginPage,
|
||||||
notFoundComponent: () => (
|
notFoundComponent: () => (
|
||||||
@@ -75,46 +76,43 @@ function PluginPage() {
|
|||||||
)}
|
)}
|
||||||
<span className="tabular-nums">{formatInstalls(installs)} installs</span>
|
<span className="tabular-nums">{formatInstalls(installs)} installs</span>
|
||||||
<span className="font-mono">{pluginVersion(plugin)}</span>
|
<span className="font-mono">{pluginVersion(plugin)}</span>
|
||||||
<a
|
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
|
||||||
href={plugin.repository.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={META_LINK_CLASS}
|
|
||||||
>
|
|
||||||
Source
|
Source
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</PluginContentLink>
|
||||||
{npm && (
|
{npm && (
|
||||||
<a href={npm} target="_blank" rel="noopener noreferrer" className={META_LINK_CLASS}>
|
<PluginContentLink href={npm} className={META_LINK_CLASS}>
|
||||||
npm
|
npm
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</PluginContentLink>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{plugin.screenshots.length > 0 && (
|
{plugin.screenshots.length > 0 && (
|
||||||
<div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0">
|
<div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0">
|
||||||
{plugin.screenshots.map((url, index) => (
|
{plugin.screenshots.map((url, index) =>
|
||||||
<a
|
pluginOverviewUrl(url) ? (
|
||||||
key={url}
|
<PluginContentLink
|
||||||
href={url}
|
key={url}
|
||||||
target="_blank"
|
href={url}
|
||||||
rel="noopener noreferrer"
|
className="aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]"
|
||||||
className="aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]"
|
>
|
||||||
>
|
<img
|
||||||
<img
|
src={url}
|
||||||
src={url}
|
alt={`${plugin.name} screenshot ${index + 1}`}
|
||||||
alt={`${plugin.name} screenshot ${index + 1}`}
|
loading="lazy"
|
||||||
loading="lazy"
|
className="h-full w-full object-cover object-top"
|
||||||
className="h-full w-full object-cover object-top"
|
/>
|
||||||
/>
|
</PluginContentLink>
|
||||||
</a>
|
) : (
|
||||||
))}
|
<p key={url}>{url}</p>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-10 border-t border-white/10 pt-10">
|
<div className="mt-10 border-t border-white/10 pt-10">
|
||||||
<DocsMarkdown>{readmeBody(plugin.readme)}</DocsMarkdown>
|
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SiteShell>
|
</SiteShell>
|
||||||
|
|||||||
Reference in New Issue
Block a user