Skip to content
Merged
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
30 changes: 29 additions & 1 deletion tools/mdx-lint/mdx-lint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const ALLOWED_COMPONENTS = new Set([
"Info",
"Icon",
"Frame",
"CardGroup",
"Card",
"Check",
"Tabs",
Expand All @@ -41,6 +42,7 @@ const ALLOWED_COMPONENTS = new Set([
"Step",
]);

const ALLOWED_INTRINSIC_ELEMENTS = new Set(["br"]);
const URL_ATTRIBUTE_NAMES = new Set(["href", "src", "action", "formaction"]);

function decodeHtmlEntities(input) {
Expand Down Expand Up @@ -81,6 +83,26 @@ function fail(node, message) {
throw new Error(`${at(node)} ${message}`);
}

function isMdxCommentExpression(node) {
const value = String(node.value ?? "").trim();
if (!value) {
return false;
}

let rest = value;
while (rest) {
if (!rest.startsWith("/*")) {
return false;
}
const end = rest.indexOf("*/", 2);
if (end < 0) {
return false;
}
rest = rest.slice(end + 2).trim();
}
return true;
}

function validateUrlNode(node) {
if (node.url && containsDangerousUrl(node.url)) {
fail(node, "contains a dangerous URL scheme");
Expand Down Expand Up @@ -121,7 +143,10 @@ function validateJsxElement(node) {
if (node.name.includes(".")) {
fail(node, `contains disallowed JSX component "${node.name}"`);
}
if (!ALLOWED_COMPONENTS.has(node.name)) {
if (
!ALLOWED_COMPONENTS.has(node.name) &&
!ALLOWED_INTRINSIC_ELEMENTS.has(node.name)
) {
fail(node, `contains disallowed JSX component "${node.name}"`);
}

Expand All @@ -138,6 +163,9 @@ function validateTree(tree) {
break;
case "mdxFlowExpression":
case "mdxTextExpression":
if (isMdxCommentExpression(node)) {
break;
}
fail(node, "contains an MDX expression");
break;
case "mdxJsxFlowElement":
Expand Down
29 changes: 29 additions & 0 deletions tools/mdx-lint/mdx-lint.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { describe, it } from "node:test";

import { lintMdxContent } from "./mdx-lint.mjs";
Expand All @@ -11,6 +12,13 @@ async function expectInvalid(content, pattern) {
await assert.rejects(() => lintMdxContent(content), pattern);
}

const sharedFixtures = JSON.parse(
await readFile(
new URL("./testdata/shared-mdx-validation.json", import.meta.url),
"utf8",
),
);

describe("mdx-lint policy", () => {
it("allows supported connector docs content", async () => {
await expectValid(`---
Expand Down Expand Up @@ -70,6 +78,12 @@ Read the public setup guide at https://example.com/docs.
await expectInvalid("{process.env.SECRET}\n", /MDX expression/);
});

it("allows MDX comments", async () => {
await expectValid(`{/* AUTO-GENERATED:START - capabilities
Generated from baton_capabilities.json. Do not edit manually. */}
`);
});

it("rejects raw HTML elements", async () => {
await expectInvalid("<div>raw html</div>\n", /disallowed JSX component "div"/);
});
Expand All @@ -96,4 +110,19 @@ Read the public setup guide at https://example.com/docs.
await expectInvalid("\ufeff# Title\n", /byte order marks/);
await expectInvalid("hello\0world\n", /NUL bytes/);
});

describe("shared validation fixtures", () => {
for (const fixture of sharedFixtures) {
it(fixture.name, async () => {
if (fixture.valid) {
await expectValid(fixture.content);
return;
}
await expectInvalid(
fixture.content,
new RegExp(fixture.errorContains),
);
});
}
});
});
100 changes: 100 additions & 0 deletions tools/mdx-lint/testdata/shared-mdx-validation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
[
{
"name": "allow dangerous scheme word in prose",
"valid": true,
"content": "<Steps>\n <Step>\n In the permissions section, choose the relevant permissions:\n\n To sync (read) data:\n\n - Project: Read\n </Step>\n</Steps>\n"
},
{
"name": "allow angle bracket placeholder in inline code",
"valid": true,
"content": "Give the app a globally unique name, such as \"c1-integration-`<org name>`\".\n"
},
{
"name": "allow data scheme literal in inline code",
"valid": true,
"content": "Use `data:` as the literal scheme name in examples.\n"
},
{
"name": "allow data URL literal in fenced code",
"valid": true,
"content": "```json\n{\"tenant\":\"{tenant}\",\"url\":\"data:image/png;base64,abc\"}\n```\n"
},
{
"name": "allow MDX comment",
"valid": true,
"content": "{/* AUTO-GENERATED:START - capabilities\n Generated from baton_capabilities.json. Do not edit manually. */}\n"
},
{
"name": "allow nested indented fence placeholders",
"valid": true,
"content": " ```yaml expandable\n BATON_CLIENT_ID: <ConductorOne client ID>\n ```\n"
},
{
"name": "allow tab-indented fenced content",
"valid": true,
"content": " ```json expandable\n\t{\n\t \"ok\": true\n\t}\n ```\n"
},
{
"name": "allow escaped angle bracket placeholder",
"valid": true,
"content": "Use \\<CATALOG ITEM ID> as the placeholder.\n"
},
{
"name": "reject double escaped JSX tag",
"valid": false,
"errorContains": "disallowed JSX component",
"content": "This is not escaped JSX: \\\\<script>alert(1)</script>\n"
},
{
"name": "reject escaped fake MDX comment hiding JSX",
"valid": false,
"errorContains": "dangerous URL scheme",
"content": "Note: write the literal sequence \\{/* in your markdown when you need it.\n\n<Frame src=\"javascript:alert(1)\">bad</Frame>\n\nEnd of note */}.\n"
},
{
"name": "reject inline code fake MDX comment hiding JSX",
"valid": false,
"errorContains": "dangerous URL scheme",
"content": "Use `{/*` as literal syntax.\n\n<Frame src=\"javascript:alert(1)\">bad</Frame>\n\nUse `*/}` as literal syntax.\n"
},
{
"name": "reject mixed MDX comment expression",
"valid": false,
"errorContains": "MDX expression",
"content": "{/* a */ x /* b */}\n"
},
{
"name": "allow table line break element",
"valid": true,
"content": "| Field | Description |\n| --- | --- |\n| Values | `one`<br/>`two` |\n"
},
{
"name": "allow CardGroup layout wrapper",
"valid": true,
"content": "<CardGroup>\n<Card title=\"Learn more\" icon=\"book\" horizontal href=\"https://example.com/docs\" />\n</CardGroup>\n"
},
{
"name": "reject backtick fence info string with backtick",
"valid": false,
"errorContains": "MDX expression",
"content": "``` `\n{process.env.REGISTRY_TOKEN}\n```\n"
},
{
"name": "reject markdown data URL",
"valid": false,
"errorContains": "dangerous URL scheme",
"content": "[image](data:image/png;base64,abc)\n"
},
{
"name": "reject JSX data URL attribute",
"valid": false,
"errorContains": "dangerous URL scheme",
"content": "<Frame src=\"data:image/svg+xml,<svg onload=alert(1)>\">bad</Frame>\n"
},
{
"name": "reject encoded javascript URL",
"valid": false,
"errorContains": "dangerous URL scheme",
"content": "[link](java&#x73;cript:alert(1))\n"
}
]