@usegraft/mdx-safety 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +114 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anderson Joseph
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @usegraft/mdx-safety
|
|
2
|
+
|
|
3
|
+
> Refuse executable MDX before it is stored or rendered.
|
|
4
|
+
|
|
5
|
+
Part of [Graft](https://github.com/AndersonDesign1/graft), a CMS built so an AI agent is the primary operator.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
MDX is code. `{expr}` evaluates JavaScript, `import` pulls modules in, and `@mdx-js/mdx`'s `run()` evaluates the compiled body with `new Function` in the host runtime, with full `process`, `fetch` and dynamic `import()`.
|
|
10
|
+
|
|
11
|
+
For content an operator wrote and reviewed in git, that is the feature. It stops being the feature the moment someone else can author a page: on shared infrastructure, one author's body reaches every other tenant.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm i @usegraft/mdx-safety
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Use
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { assertSafeMdx, findExecutableMdx, type MdxTrust } from "@usegraft/mdx-safety";
|
|
23
|
+
|
|
24
|
+
// throw, with every offender named at once
|
|
25
|
+
assertSafeMdx(body, { label: "pages/home" });
|
|
26
|
+
|
|
27
|
+
// or collect them yourself
|
|
28
|
+
const found = findExecutableMdx(body);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Prose, GFM and components with literal attributes are unaffected. Expressions, `import`, `export`, expression-valued attributes and `{...spread}` attributes are refused.
|
|
32
|
+
|
|
33
|
+
Source the checker cannot parse throws `UncheckableMdxError` rather than passing. That is deliberate: the renderer's parser is not this one, and the gap between two independently configured parsers is exactly where executable source would hide.
|
|
34
|
+
|
|
35
|
+
## Not a sanitiser
|
|
36
|
+
|
|
37
|
+
This refuses _executable_ constructs. It is not a general HTML sanitiser. If you render content from people you do not trust at all, put a sanitiser in front of it as well.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
MIT. [Repository](https://github.com/AndersonDesign1/graft) · [Changelog](https://github.com/AndersonDesign1/graft/blob/feat/core/packages/mdx-safety/CHANGELOG.md) · [Security policy](https://github.com/AndersonDesign1/graft/blob/feat/core/SECURITY.md)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** One executable construct found in a body, with where it was. */
|
|
2
|
+
interface ExecutableNode {
|
|
3
|
+
/** What it is, in the reader's terms rather than the AST's. */
|
|
4
|
+
kind: "expression" | "import-or-export" | "attribute-expression" | "attribute-spread" | "scripting-element" | "event-handler";
|
|
5
|
+
/** 1-based line, when the parser knew it. */
|
|
6
|
+
line?: number;
|
|
7
|
+
/** The offending source, truncated. */
|
|
8
|
+
snippet?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Thrown when the checker cannot parse a body, so it cannot vouch for it. */
|
|
11
|
+
declare class UncheckableMdxError extends Error {
|
|
12
|
+
readonly cause: unknown;
|
|
13
|
+
constructor(cause: unknown);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Every executable construct in an MDX body, in source order.
|
|
17
|
+
*
|
|
18
|
+
* Returns them rather than throwing so a caller can report all of them at once
|
|
19
|
+
* — an author fixing one at a time learns the rule slowly and resents it.
|
|
20
|
+
*
|
|
21
|
+
* Source the checker cannot parse throws {@link UncheckableMdxError}. It used
|
|
22
|
+
* to return `[]` on the reasoning that "a body that will not parse cannot
|
|
23
|
+
* execute either" — which only holds if this parser understands at least as
|
|
24
|
+
* much as the one that renders. Two independently-configured parsers WILL
|
|
25
|
+
* drift, and the failure mode of that shortcut is to wave through exactly the
|
|
26
|
+
* source that sits in the gap. Refusing what we cannot read is the only
|
|
27
|
+
* direction that stays safe when they diverge.
|
|
28
|
+
*/
|
|
29
|
+
declare function findExecutableMdx(source: string): ExecutableNode[];
|
|
30
|
+
/**
|
|
31
|
+
* How much of MDX a body is allowed to be.
|
|
32
|
+
*
|
|
33
|
+
* `"restricted"` refuses executable constructs. `"full"` accepts them, and is
|
|
34
|
+
* only correct where every author has commit access, because rendering
|
|
35
|
+
* evaluates `{…}` and `import` as JavaScript on the server. The name is
|
|
36
|
+
* declared here so the compiler, the SDKs and `graft.config.ts` all mean the
|
|
37
|
+
* same thing by it.
|
|
38
|
+
*/
|
|
39
|
+
type MdxTrust = "restricted" | "full";
|
|
40
|
+
interface AssertSafeMdxOptions {
|
|
41
|
+
/** What is being checked, for the error message (e.g. "pages/home"). */
|
|
42
|
+
label?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Throw unless the body is free of executable constructs.
|
|
46
|
+
*
|
|
47
|
+
* Called on the surfaces that accept content from someone who is not the
|
|
48
|
+
* operator — MCP `write_content`, Studio document saves.
|
|
49
|
+
*
|
|
50
|
+
* Content already in git is checked too, by `graft compile` via
|
|
51
|
+
* {@link findExecutableMdx}, unless the project sets `mdxTrust = "full"`. That
|
|
52
|
+
* setting is what "code review is the control" looks like when a project
|
|
53
|
+
* actually claims it. Compile checks because `MdxBody` refuses executable
|
|
54
|
+
* bodies at render by default, so an unchecked tree fails per-request in
|
|
55
|
+
* production instead of at build time.
|
|
56
|
+
*/
|
|
57
|
+
declare function assertSafeMdx(source: string, options?: AssertSafeMdxOptions): void;
|
|
58
|
+
|
|
59
|
+
export { type AssertSafeMdxOptions, type ExecutableNode, type MdxTrust, UncheckableMdxError, assertSafeMdx, findExecutableMdx };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { GraftError } from "@usegraft/contracts";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import remarkMdx from "remark-mdx";
|
|
5
|
+
import remarkParse from "remark-parse";
|
|
6
|
+
import { unified } from "unified";
|
|
7
|
+
import { visit } from "unist-util-visit";
|
|
8
|
+
var KIND_LABEL = {
|
|
9
|
+
expression: "a `{\u2026}` expression",
|
|
10
|
+
"import-or-export": "an `import` or `export`",
|
|
11
|
+
"attribute-expression": "an attribute whose value is a `{\u2026}` expression",
|
|
12
|
+
"attribute-spread": "a `{...spread}` attribute",
|
|
13
|
+
"scripting-element": "an element that can load or run code",
|
|
14
|
+
"event-handler": "an inline event-handler attribute"
|
|
15
|
+
};
|
|
16
|
+
var SCRIPTING_ELEMENTS = /* @__PURE__ */ new Set([
|
|
17
|
+
"script",
|
|
18
|
+
"iframe",
|
|
19
|
+
"object",
|
|
20
|
+
"embed",
|
|
21
|
+
"frame",
|
|
22
|
+
"frameset",
|
|
23
|
+
"base"
|
|
24
|
+
]);
|
|
25
|
+
var EVENT_HANDLER_RE = /^on[a-z]/i;
|
|
26
|
+
function snippetOf(value) {
|
|
27
|
+
if (typeof value !== "string" || value.length === 0) return void 0;
|
|
28
|
+
const flat = value.replace(/\s+/g, " ").trim();
|
|
29
|
+
return flat.length > 80 ? `${flat.slice(0, 77)}\u2026` : flat;
|
|
30
|
+
}
|
|
31
|
+
var processor = unified().use(remarkParse).use(remarkGfm).use(remarkMdx);
|
|
32
|
+
var UncheckableMdxError = class extends Error {
|
|
33
|
+
constructor(cause) {
|
|
34
|
+
super("MDX could not be parsed for safety checking");
|
|
35
|
+
this.cause = cause;
|
|
36
|
+
this.name = "UncheckableMdxError";
|
|
37
|
+
}
|
|
38
|
+
cause;
|
|
39
|
+
};
|
|
40
|
+
function findExecutableMdx(source) {
|
|
41
|
+
let tree;
|
|
42
|
+
try {
|
|
43
|
+
tree = processor.parse(source);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new UncheckableMdxError(error);
|
|
46
|
+
}
|
|
47
|
+
const found = [];
|
|
48
|
+
const at = (node) => node.position?.start?.line;
|
|
49
|
+
visit(tree, (node) => {
|
|
50
|
+
if (node.type === "mdxFlowExpression" || node.type === "mdxTextExpression") {
|
|
51
|
+
found.push({ kind: "expression", line: at(node), snippet: snippetOf(node.value) });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (node.type === "mdxjsEsm") {
|
|
55
|
+
found.push({ kind: "import-or-export", line: at(node), snippet: snippetOf(node.value) });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
|
|
59
|
+
const tag = node.name;
|
|
60
|
+
if (typeof tag === "string" && SCRIPTING_ELEMENTS.has(tag.toLowerCase())) {
|
|
61
|
+
found.push({ kind: "scripting-element", line: at(node), snippet: `<${tag}>` });
|
|
62
|
+
}
|
|
63
|
+
for (const attribute of node.attributes ?? []) {
|
|
64
|
+
if (attribute.type === "mdxJsxExpressionAttribute") {
|
|
65
|
+
found.push({
|
|
66
|
+
kind: "attribute-spread",
|
|
67
|
+
line: at(node),
|
|
68
|
+
snippet: snippetOf(attribute.value)
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (typeof attribute.name === "string" && EVENT_HANDLER_RE.test(attribute.name)) {
|
|
73
|
+
found.push({
|
|
74
|
+
kind: "event-handler",
|
|
75
|
+
line: at(node),
|
|
76
|
+
snippet: snippetOf(attribute.name)
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const value = attribute.value;
|
|
81
|
+
if (value !== null && typeof value === "object" && value.type === "mdxJsxAttributeValueExpression") {
|
|
82
|
+
found.push({
|
|
83
|
+
kind: "attribute-expression",
|
|
84
|
+
line: at(node),
|
|
85
|
+
snippet: snippetOf(attribute.name)
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
function assertSafeMdx(source, options = {}) {
|
|
94
|
+
const found = findExecutableMdx(source);
|
|
95
|
+
if (found.length === 0) return;
|
|
96
|
+
const where = options.label ? ` in ${options.label}` : "";
|
|
97
|
+
const listed = found.slice(0, 5).map((node) => {
|
|
98
|
+
const line = node.line === void 0 ? "" : ` (line ${node.line})`;
|
|
99
|
+
const snippet = node.snippet === void 0 ? "" : `: ${node.snippet}`;
|
|
100
|
+
return `${KIND_LABEL[node.kind]}${line}${snippet}`;
|
|
101
|
+
}).join("; ");
|
|
102
|
+
const more = found.length > 5 ? ` \u2026and ${found.length - 5} more` : "";
|
|
103
|
+
throw new GraftError({
|
|
104
|
+
code: "INPUT_VALIDATION_FAILED",
|
|
105
|
+
message: `Executable MDX is not accepted${where} \u2014 found ${listed}${more}.`,
|
|
106
|
+
fix: "Write prose, Markdown and components with literal attributes. `{\u2026}` expressions, `import`, `export` and spread attributes are refused because rendering evaluates them as JavaScript on the server. If this content is operator-authored and needs full MDX, commit it to the repository instead, where code review is the control.",
|
|
107
|
+
details: { found: found.length, nodes: found.slice(0, 20) }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
export {
|
|
111
|
+
UncheckableMdxError,
|
|
112
|
+
assertSafeMdx,
|
|
113
|
+
findExecutableMdx
|
|
114
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@usegraft/mdx-safety",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Refuse executable MDX before it is stored or rendered. MDX is code, and rendering evaluates it on the server.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"ai",
|
|
8
|
+
"cms",
|
|
9
|
+
"content-security",
|
|
10
|
+
"graft",
|
|
11
|
+
"headless-cms",
|
|
12
|
+
"mcp",
|
|
13
|
+
"mdx",
|
|
14
|
+
"sandbox",
|
|
15
|
+
"security",
|
|
16
|
+
"typescript"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/AndersonDesign1/graft#readme",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/AndersonDesign1/graft.git",
|
|
23
|
+
"directory": "packages/mdx-safety"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@usegraft/contracts": "0.2.0",
|
|
40
|
+
"remark-gfm": "^4.0.1",
|
|
41
|
+
"remark-mdx": "^3.1.1",
|
|
42
|
+
"remark-parse": "^11.0.0",
|
|
43
|
+
"unified": "^11.0.5",
|
|
44
|
+
"unist-util-visit": "^5.1.0"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=22.16"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
51
|
+
"dev": "tsup src/index.ts --format esm --watch",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"test": "vitest run --passWithNoTests"
|
|
54
|
+
}
|
|
55
|
+
}
|