diff --git a/docs/plugins.md b/docs/plugins.md index 897bf29507..866c74128e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 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 `PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix). Private registry credentials live in daemon config under diff --git a/package-lock.json b/package-lock.json index d03e712ee1..a2856c464a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40174,6 +40174,7 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.1", + "parse5": "^7.3.0", "typescript": "^5.9.3", "vite": "^7.3.3", "vite-tsconfig-paths": "^5.1.4", diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 05a2dd765e..84b4cc63a2 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -1,3 +1,4 @@ +import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json"; import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { execFileSync } from "node:child_process"; 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.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 }); +} diff --git a/packages/protocol/src/plugin-overview.test.ts b/packages/protocol/src/plugin-overview.test.ts new file mode 100644 index 0000000000..c4023965da --- /dev/null +++ b/packages/protocol/src/plugin-overview.test.ts @@ -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); + }); +}); diff --git a/packages/protocol/src/plugin-overview.ts b/packages/protocol/src/plugin-overview.ts new file mode 100644 index 0000000000..2507b511b0 --- /dev/null +++ b/packages/protocol/src/plugin-overview.ts @@ -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; + } +} diff --git a/packages/protocol/tests/fixtures/plugin-overview.json b/packages/protocol/tests/fixtures/plugin-overview.json new file mode 100644 index 0000000000..bbe8575ac8 --- /dev/null +++ b/packages/protocol/tests/fixtures/plugin-overview.json @@ -0,0 +1,1202 @@ +[ + { + "name": "script", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "image event", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "svg event", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "iframe", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTML javascript link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "HTML block", + "markdown": "
\n\n
", + "links": [], + "images": [] + }, + { + "name": "comment", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "meta refresh", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "base", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "form", + "markdown": "
", + "links": [], + "images": [] + }, + { + "name": "style expression", + "markdown": "

click

", + "links": [], + "images": [] + }, + { + "name": "style block", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "closing script", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTML entities", + "markdown": "<img src=x onerror=alert(1)>", + "links": [], + "images": [] + }, + { + "name": "nested HTML entities", + "markdown": "&lt;script&gt;alert(1)&lt;/script&gt;", + "links": [], + "images": [] + }, + { + "name": "javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "javascript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript link", + "markdown": "[click](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript image", + "markdown": "![picture](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference link", + "markdown": "[click][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data HTML link", + "markdown": "[click](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML image", + "markdown": "![picture](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML reference link", + "markdown": "[click][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML reference image", + "markdown": "![picture][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data image link", + "markdown": "[click](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image image", + "markdown": "![picture](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image reference link", + "markdown": "[click][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image reference image", + "markdown": "![picture][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "data image HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "vbscript link", + "markdown": "[click](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript image", + "markdown": "![picture](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript reference link", + "markdown": "[click][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript reference image", + "markdown": "![picture][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "http link", + "markdown": "[click](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http image", + "markdown": "![picture](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http reference link", + "markdown": "[click][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http reference image", + "markdown": "![picture][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "http HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "ftp link", + "markdown": "[click](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp image", + "markdown": "![picture](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp reference link", + "markdown": "[click][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp reference image", + "markdown": "![picture][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "ftp HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mailto link", + "markdown": "[click](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto image", + "markdown": "![picture](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto reference link", + "markdown": "[click][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto reference image", + "markdown": "![picture][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "mailto HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "protocol relative link", + "markdown": "[click](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative image", + "markdown": "![picture](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference link", + "markdown": "[click][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference image", + "markdown": "![picture][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "relative link", + "markdown": "[click](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative image", + "markdown": "![picture](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative reference link", + "markdown": "[click][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "parent relative link", + "markdown": "[click](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative image", + "markdown": "![picture](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative reference link", + "markdown": "[click][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "root relative link", + "markdown": "[click](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative image", + "markdown": "![picture](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative reference link", + "markdown": "[click][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative reference image", + "markdown": "![picture][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "root relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "fragment link", + "markdown": "[click](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment image", + "markdown": "![picture](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment reference link", + "markdown": "[click][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment reference image", + "markdown": "![picture][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "fragment HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "query link", + "markdown": "[click](?login)", + "links": [], + "images": [] + }, + { + "name": "query image", + "markdown": "![picture](?login)", + "links": [], + "images": [] + }, + { + "name": "query reference link", + "markdown": "[click][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query reference image", + "markdown": "![picture][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "query HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "entity javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "entity letter link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "nested entity link", + "markdown": "[click](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity image", + "markdown": "![picture](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity reference link", + "markdown": "[click][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "percent encoded link", + "markdown": "[click](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded image", + "markdown": "![picture](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded link", + "markdown": "[click](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded image", + "markdown": "![picture](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "tab scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "newline scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials link", + "markdown": "[click](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials image", + "markdown": "![picture](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference link", + "markdown": "[click][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference image", + "markdown": "![picture][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "backslash link", + "markdown": "[click](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash image", + "markdown": "![picture](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash reference link", + "markdown": "[click][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash reference image", + "markdown": "![picture][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "backslash HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "missing authority link", + "markdown": "[click](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority image", + "markdown": "![picture](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority reference link", + "markdown": "[click][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority reference image", + "markdown": "![picture][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "javascript autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data HTML autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data image autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "vbscript autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "http autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "ftp autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mailto autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTTPS link", + "markdown": "[click](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "HTTPS image", + "markdown": "![picture](https://example.com/image.png)", + "links": [], + "images": ["https://example.com/image.png"] + }, + { + "name": "HTTPS autolink", + "markdown": "", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTPS", + "markdown": "https://example.com/path", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTP", + "markdown": "http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "deceptive RTL text", + "markdown": "[paseo.sh‮evil](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "title injection", + "markdown": "[click](https://example.com/path \"\\\" onmouseover=alert(1)\")", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "table alignment", + "markdown": "| A |\n| :-: |\n| safe |", + "links": [], + "images": [] + }, + { + "name": "heading", + "markdown": "## Heading", + "links": [], + "images": [] + }, + { + "name": "IRC link", + "markdown": "[click](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC image", + "markdown": "![picture](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "IRCS link", + "markdown": "[click](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS image", + "markdown": "![picture](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "XMPP link", + "markdown": "[click](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP image", + "markdown": "![picture](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "tel link", + "markdown": "[click](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel image", + "markdown": "![picture](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "file link", + "markdown": "[click](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file image", + "markdown": "![picture](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "blob link", + "markdown": "[click](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob image", + "markdown": "![picture](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "named entity link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "double encoded entity link", + "markdown": "[click](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity image", + "markdown": "![picture](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity autolink", + "markdown": "", + "links": [], + "images": [] + } +] diff --git a/packages/website/e2e/overview.fixture.json b/packages/website/e2e/overview.fixture.json new file mode 100644 index 0000000000..2d23fd0343 --- /dev/null +++ b/packages/website/e2e/overview.fixture.json @@ -0,0 +1,32 @@ +{ + "id": "security/overview", + "name": "", + "description": "", + "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": "" + }, + "screenshots": [ + "data:image/svg+xml,", + "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", + "icon": "data:image/png;base64,AAAA" +} diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 910f14cc1b..246e383b57 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -1,3 +1,4 @@ +import securityPlugin from "./overview.fixture.json" with { type: "json" }; import { expect, test, type Page, type Locator } from "playwright/test"; import registry from "./registry.fixture.json" with { type: "json" }; 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.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1); 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(); await page.getByRole("button", { name: "Copy to clipboard" }).click(); await expect @@ -168,7 +169,7 @@ test.describe("search engine visits without JavaScript", () => { expect(response?.headers()["x-robots-tag"]).toBeUndefined(); await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible(); 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(); await expectPageMetadata( page, @@ -366,3 +367,72 @@ async function expectThumbnailCard(card: Locator, source: string, id: string) { expect(box.width).toBeGreaterThan(0); 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(""); + 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, + ); +} diff --git a/packages/website/e2e/registry-server.mjs b/packages/website/e2e/registry-server.mjs index 211fee9f38..228f3dd859 100644 --- a/packages/website/e2e/registry-server.mjs +++ b/packages/website/e2e/registry-server.mjs @@ -1,6 +1,15 @@ import { createServer } from "node:http"; 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. createServer((request, response) => { response.setHeader("Content-Type", "application/json"); @@ -8,7 +17,9 @@ createServer((request, response) => { response.end(JSON.stringify(registry)); 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.end(JSON.stringify(plugin ?? { error: "Plugin not found" })); }).listen(8188, "127.0.0.1"); diff --git a/packages/website/package.json b/packages/website/package.json index 3176fd25d5..bf40c0e1a6 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -38,6 +38,7 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.1", + "parse5": "^7.3.0", "typescript": "^5.9.3", "vite": "^7.3.3", "vite-tsconfig-paths": "^5.1.4", diff --git a/packages/website/src/plugins/overview.test.tsx b/packages/website/src/plugins/overview.test.tsx new file mode 100644 index 0000000000..89cf15a947 --- /dev/null +++ b/packages/website/src/plugins/overview.test.tsx @@ -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({markdown}); +} +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]); +}); diff --git a/packages/website/src/plugins/overview.tsx b/packages/website/src/plugins/overview.tsx new file mode 100644 index 0000000000..8fc680f9a9 --- /dev/null +++ b/packages/website/src/plugins/overview.tsx @@ -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 ? ( + + {children} + + ) : ( + children + ); +} + +const allowedElements = [...pluginOverviewPolicy.elements]; +const remarkPlugins = [remarkGfm]; + +const components: Components = { + a: ({ href, children }) => {children}, + img: ({ src, alt }) => { + const source = pluginOverviewUrl(typeof src === "string" ? src : undefined); + return source ? {alt : alt; + }, + // GFM alignment otherwise becomes an inline style in react-markdown. + th: ({ children }) => {children}, + td: ({ children }) => {children}, +}; + +/** Untrusted overviews do not inherit documentation directives, anchors, or HTML highlighters. */ +export function PluginOverview({ children }: { children: string }) { + return ( +
+ + {children} + +
+ ); +} diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx index 4df1d70d1d..429937ab87 100644 --- a/packages/website/src/plugins/plugin-card.tsx +++ b/packages/website/src/plugins/plugin-card.tsx @@ -1,3 +1,4 @@ +import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; import { Download } from "lucide-react"; import { pluginCardScreenshot } from "./thumbnails"; import { pluginHref } from "./links"; @@ -7,9 +8,9 @@ import { formatInstalls, getCategory, type Plugin } from "./registry"; export const PLUGIN_GRID_CLASS = "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 }) { - const url = plugin.screenshots[0]; + const url = plugin.screenshots.find(pluginOverviewUrl); return (
- {plugin.icon ? ( + {pluginOverviewUrl(plugin.icon) ? ( ) : ( plugin.name.charAt(0).toUpperCase() diff --git a/packages/website/src/routes/plugins/$owner.tsx b/packages/website/src/routes/plugins/$owner.tsx index e4db39a205..0345fa1355 100644 --- a/packages/website/src/routes/plugins/$owner.tsx +++ b/packages/website/src/routes/plugins/$owner.tsx @@ -1,3 +1,4 @@ +import { PluginContentLink } from "~/plugins/overview"; import { createFileRoute, notFound } from "@tanstack/react-router"; import { ExternalLink } from "lucide-react"; import { useMemo } from "react"; @@ -64,21 +65,16 @@ function AuthorPage() {

{authorNpmUrl(author) && ( - + npm - + )} {github && ( - + GitHub - + )}
diff --git a/packages/website/src/routes/plugins/$owner_.$slug.tsx b/packages/website/src/routes/plugins/$owner_.$slug.tsx index b70a13d9da..42529775f8 100644 --- a/packages/website/src/routes/plugins/$owner_.$slug.tsx +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -1,7 +1,8 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; import { ExternalLink } from "lucide-react"; 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 { pageMeta } from "~/meta"; 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?.description ?? "Plugin not found.", `/plugins/${params.owner}/${params.slug}`, - loaderData?.plugin.screenshots[0], + loaderData?.plugin.screenshots.find(pluginOverviewUrl), ), component: PluginPage, notFoundComponent: () => ( @@ -75,46 +76,43 @@ function PluginPage() { )} {formatInstalls(installs)} installs {pluginVersion(plugin)} - + Source - + {npm && ( - + npm - + )} {plugin.screenshots.length > 0 && (
- {plugin.screenshots.map((url, index) => ( - - {`${plugin.name} - - ))} + {plugin.screenshots.map((url, index) => + pluginOverviewUrl(url) ? ( + + {`${plugin.name} + + ) : ( +

{url}

+ ), + )}
)}
- {readmeBody(plugin.readme)} + {readmeBody(plugin.readme)}