shelving 1.267.2 → 1.267.4

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.
@@ -42,6 +42,13 @@ export type MarkupOptions = {
42
42
  * Default context to use if one isn't set. Defaults to `"block"`
43
43
  */
44
44
  readonly context?: string;
45
+ /**
46
+ * Current nesting depth — used to cap self-recursive rules (e.g. blockquotes) so pathological input can't
47
+ * overflow the stack. Internal: set automatically as rules recurse via `MarkupParser.nested()`; callers
48
+ * normally leave it at the default `0`.
49
+ * @default 0
50
+ */
51
+ readonly depth?: number | undefined;
45
52
  };
46
53
  /**
47
54
  * Parses a Markdownish markup string and renders it as a React node using a tiered, masking rule engine.
@@ -90,10 +97,29 @@ export declare class MarkupParser implements Parser<string, ReactNode> {
90
97
  * @see https://shelving.cc/markup/MarkupParser/context
91
98
  */
92
99
  readonly context: string;
93
- constructor({ rules, rel, url, root, schemes, context }?: MarkupOptions);
100
+ /**
101
+ * Current nesting depth, incremented by `nested()` as self-recursive rules descend. Used to cap recursion so
102
+ * pathological input (e.g. a long `>>>>…` blockquote) can't overflow the stack.
103
+ * @see https://shelving.cc/markup/MarkupParser/depth
104
+ */
105
+ readonly depth: number;
106
+ constructor({ rules, rel, url, root, schemes, context, depth }?: MarkupOptions);
107
+ /**
108
+ * Return a copy of this parser with `depth` incremented by one, sharing all other options.
109
+ * - Self-recursive rules (e.g. blockquotes) parse their nested content through `nested()` so `depth` tracks how
110
+ * deep the recursion has gone, letting them stop before the call stack overflows.
111
+ *
112
+ * @returns A new `MarkupParser` identical to this one but one level deeper.
113
+ * @see https://shelving.cc/markup/MarkupParser/nested
114
+ */
115
+ nested(): MarkupParser;
94
116
  /**
95
117
  * Parse a text string as Markdownish markup syntax and render it as a React node.
96
118
  * - Syntax is not defined by this code, but by the rules supplied to it.
119
+ * - **Untrusted input:** parsing is linear for normal content, but a few rules can still degrade on adversarial
120
+ * input — long unbroken runs of backtick code fences, and deeply-nested `>` blockquotes (each leading `>`
121
+ * recurses one level, so a pathological line can overflow the stack). When `input` is user-generated, cap its
122
+ * length first; a sane maximum for your use case (for typical user content, tens of kilobytes) bounds worst-case work.
97
123
  *
98
124
  * @param input The string content possibly containing markup syntax, e.g. `"This is a *bold* string."`.
99
125
  * @param context The context to render in (defaults to this parser's `context`, i.e. `"block"` unless overridden).
@@ -48,7 +48,13 @@ export class MarkupParser {
48
48
  * @see https://shelving.cc/markup/MarkupParser/context
49
49
  */
50
50
  context;
51
- constructor({ rules = MARKUP_RULES, rel, url, root, schemes = HTTP_SCHEMES, context = "block" } = {}) {
51
+ /**
52
+ * Current nesting depth, incremented by `nested()` as self-recursive rules descend. Used to cap recursion so
53
+ * pathological input (e.g. a long `>>>>…` blockquote) can't overflow the stack.
54
+ * @see https://shelving.cc/markup/MarkupParser/depth
55
+ */
56
+ depth;
57
+ constructor({ rules = MARKUP_RULES, rel, url, root, schemes = HTTP_SCHEMES, context = "block", depth = 0 } = {}) {
52
58
  this.rules = rules;
53
59
  this.priorities = _getPriorities(rules);
54
60
  this.rel = rel;
@@ -56,10 +62,34 @@ export class MarkupParser {
56
62
  this.root = root;
57
63
  this.schemes = schemes;
58
64
  this.context = context;
65
+ this.depth = depth;
66
+ }
67
+ /**
68
+ * Return a copy of this parser with `depth` incremented by one, sharing all other options.
69
+ * - Self-recursive rules (e.g. blockquotes) parse their nested content through `nested()` so `depth` tracks how
70
+ * deep the recursion has gone, letting them stop before the call stack overflows.
71
+ *
72
+ * @returns A new `MarkupParser` identical to this one but one level deeper.
73
+ * @see https://shelving.cc/markup/MarkupParser/nested
74
+ */
75
+ nested() {
76
+ return new MarkupParser({
77
+ rules: this.rules,
78
+ rel: this.rel,
79
+ url: this.url,
80
+ root: this.root,
81
+ schemes: this.schemes,
82
+ context: this.context,
83
+ depth: this.depth + 1,
84
+ });
59
85
  }
60
86
  /**
61
87
  * Parse a text string as Markdownish markup syntax and render it as a React node.
62
88
  * - Syntax is not defined by this code, but by the rules supplied to it.
89
+ * - **Untrusted input:** parsing is linear for normal content, but a few rules can still degrade on adversarial
90
+ * input — long unbroken runs of backtick code fences, and deeply-nested `>` blockquotes (each leading `>`
91
+ * recurses one level, so a pathological line can overflow the stack). When `input` is user-generated, cap its
92
+ * length first; a sane maximum for your use case (for typical user content, tens of kilobytes) bounds worst-case work.
63
93
  *
64
94
  * @param input The string content possibly containing markup syntax, e.g. `"This is a *bold* string."`.
65
95
  * @param context The context to render in (defaults to this parser's `context`, i.e. `"block"` unless overridden).
@@ -3,6 +3,7 @@
3
3
  * - `>` quote character followed by zero or more spaces.
4
4
  * - No spaces can appear before the `>` quote character.
5
5
  * - Quote block is only broken by `\n\n` two newline characters.
6
+ * - Nesting is capped at `MAX_DEPTH` (10) levels; deeper `>` prefixes are rendered as plain text rather than recursed, so pathological input can't overflow the stack.
6
7
  *
7
8
  * @example new MarkupParser({ rules: [BLOCKQUOTE_RULE] }).parse("> Quoted text")
8
9
  * @see https://shelving.cc/markup/BLOCKQUOTE_RULE
@@ -3,13 +3,23 @@ import { createMarkupRule } from "../MarkupRule.js";
3
3
  import { BLOCK_CONTENT_REGEXP, createBlockRegExp } from "../util/regexp.js";
4
4
  const PREFIX = ">";
5
5
  const INDENT = new RegExp(`^${PREFIX}`, "gm");
6
+ /** Maximum blockquote nesting depth. Each `>` level recurses once, so an unbounded `>>>>…` line would overflow the stack; beyond this the remaining source is left as plain text. */
7
+ const MAX_DEPTH = 10;
6
8
  /**
7
9
  * Blockquote block.
8
10
  * - `>` quote character followed by zero or more spaces.
9
11
  * - No spaces can appear before the `>` quote character.
10
12
  * - Quote block is only broken by `\n\n` two newline characters.
13
+ * - Nesting is capped at `MAX_DEPTH` (10) levels; deeper `>` prefixes are rendered as plain text rather than recursed, so pathological input can't overflow the stack.
11
14
  *
12
15
  * @example new MarkupParser({ rules: [BLOCKQUOTE_RULE] }).parse("> Quoted text")
13
16
  * @see https://shelving.cc/markup/BLOCKQUOTE_RULE
14
17
  */
15
- export const BLOCKQUOTE_RULE = createMarkupRule(createBlockRegExp(`(?<quote>${PREFIX}${BLOCK_CONTENT_REGEXP})`), (key, { quote }, parser) => _jsx("blockquote", { children: parser.parse(quote.replace(INDENT, ""), "block") }, key), ["block", "list"]);
18
+ export const BLOCKQUOTE_RULE = createMarkupRule(createBlockRegExp(`(?<quote>${PREFIX}${BLOCK_CONTENT_REGEXP})`), (key, { quote }, parser) => {
19
+ const inner = quote.replace(INDENT, "");
20
+ // This blockquote is level `depth + 1`; once it reaches the cap, render the (still `>`-prefixed) remainder as
21
+ // plain text instead of recursing, so nesting stops at exactly MAX_DEPTH levels and can't overflow the stack.
22
+ if (parser.depth + 1 >= MAX_DEPTH)
23
+ return _jsx("blockquote", { children: inner }, key);
24
+ return _jsx("blockquote", { children: parser.nested().parse(inner, "block") }, key);
25
+ }, ["block", "list"]);
@@ -15,4 +15,6 @@ import { BLOCK_CONTENT_REGEXP } from "../util/regexp.js";
15
15
  */
16
16
  // Priority 10: code is a higher-precedence tier, resolved and masked before links/emphasis, so a
17
17
  // code span that straddles a link delimiter wins and the link cannot form across it.
18
- export const CODE_RULE = createMarkupRule(getRegExp(`(?<fence>\`+)(?<code>${BLOCK_CONTENT_REGEXP})\\k<fence>`), (key, { code }) => _jsx("code", { children: code }, key), ["inline", "list", "link"], 10);
18
+ export const CODE_RULE = createMarkupRule(
19
+ // Fence length is bounded (`{1,64}`) so a long run of backticks can't force O(n²) backtracking against the closing backreference.
20
+ getRegExp(`(?<fence>\`{1,64})(?<code>${BLOCK_CONTENT_REGEXP})\\k<fence>`), (key, { code }) => _jsx("code", { children: code }, key), ["inline", "list", "link"], 10);
@@ -19,7 +19,10 @@ const INLINE_CHARS = { "~": "del", "+": "ins", "*": "strong", _: "em", "=": "mar
19
19
  * @example new MarkupParser({ rules: [INLINE_RULE] }).parse("This is *bold* and _italic_")
20
20
  * @see https://shelving.cc/markup/INLINE_RULE
21
21
  */
22
- export const INLINE_RULE = createMarkupRule(createWordRegExp(`(?<wrap>(?<char>[${Object.keys(INLINE_CHARS).join("")}])+)(?<text>(?!\\k<char>)\\S(?:[\\s\\S]*?(?!\\k<char>)\\S)?)\\k<wrap>`), (key, { char, text }, parser) => {
22
+ export const INLINE_RULE = createMarkupRule(createWordRegExp(
23
+ // Marker run is bounded (`{1,8}`) and the inner span is bounded (`{0,2000}?`) so a long run of
24
+ // unclosed markers can't force O(n²) backtracking — real emphasis never approaches these limits.
25
+ `(?<wrap>(?<char>[${Object.keys(INLINE_CHARS).join("")}]){1,8})(?<text>(?!\\k<char>)\\S(?:[\\s\\S]{0,2000}?(?!\\k<char>)\\S)?)\\k<wrap>`), (key, { char, text }, parser) => {
23
26
  const Inline = INLINE_CHARS[char];
24
27
  return _jsx(Inline, { children: parser.parse(text, "inline") }, key);
25
28
  }, ["inline", "list", "link"]);
@@ -30,5 +30,7 @@ _renderLink, ["inline", "list"]);
30
30
  * @example new MarkupParser({ rules: [AUTOLINK_RULE] }).parse("http://google.com/maps (Google Maps)")
31
31
  * @see https://shelving.cc/markup/AUTOLINK_RULE
32
32
  */
33
- export const AUTOLINK_RULE = createMarkupRule(getRegExp(/(?<href>[a-z]{3,}?:\S+)(?: +(?:\((?<title>[^)\n]*?)\)))?/), //
33
+ export const AUTOLINK_RULE = createMarkupRule(
34
+ // Scheme length is bounded (`{3,64}?`) so a long run of letters with no `:` can't force O(n²) backtracking.
35
+ getRegExp(/(?<href>[a-z]{3,64}?:\S+)(?: +(?:\((?<title>[^)\n]*?)\)))?/), //
34
36
  _renderLink, ["inline", "list"]);
@@ -11,6 +11,8 @@ import { createLineRegExp } from "../util/regexp.js";
11
11
  * @example new MarkupParser({ rules: [SEPARATOR_RULE] }).parse("---")
12
12
  * @see https://shelving.cc/markup/SEPARATOR_RULE
13
13
  */
14
- export const SEPARATOR_RULE = createMarkupRule(createLineRegExp("([-*•+_=])(?: *\\1){2,}"), //
14
+ export const SEPARATOR_RULE = createMarkupRule(
15
+ // Inter-character whitespace is bounded (`{0,2000}`) so a long run of spaces can't force O(n²) backtracking.
16
+ createLineRegExp("([-*•+_=])(?: {0,2000}\\1){2,}"), //
15
17
  //
16
18
  key => _jsx("hr", {}, key), ["block"]);
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createMarkupRule } from "../MarkupRule.js";
3
3
  import { createBlockRegExp, LINE_SPACE_REGEXP } from "../util/regexp.js";
4
4
  // Constants.
5
- const _SPACE = `${LINE_SPACE_REGEXP}*`; // Run of line whitespace (never crosses a newline).
5
+ const _SPACE = `${LINE_SPACE_REGEXP}{0,2000}`; // Run of line whitespace (never crosses a newline); bounded so a long whitespace line can't force O(n²) backtracking in the delimiter row.
6
6
  const _CELL = `${_SPACE}:?-+:?${_SPACE}`; // Delimiter-row cell: one or more dashes with optional `:` alignment markers.
7
7
  const _DELIMITER_SOURCE = `${_SPACE}\\|?(?:${_CELL}\\|)+(?:${_CELL})?${_SPACE}`; // Delimiter row: pipe-separated dash cells.
8
8
  const _DELIMITER = new RegExp(`^${_DELIMITER_SOURCE}$`, "u"); // Tests whether a single line is a delimiter row.
@@ -13,12 +13,12 @@ export declare const BLOCK_SPACE_REGEXP = "\\s";
13
13
  * Regular expression source matching the start of a block — the start of the string, or one linebreak.
14
14
  * @see https://shelving.cc/markup/BLOCK_START_REGEXP
15
15
  */
16
- export declare const BLOCK_START_REGEXP = "(?:\\s*\\n|^)";
16
+ export declare const BLOCK_START_REGEXP = "(?:\\s{0,2000}\\n|^)";
17
17
  /**
18
18
  * Regular expression source matching the end of a block — the end of the string, or two linebreaks, with trailing whitespace trimmed.
19
19
  * @see https://shelving.cc/markup/BLOCK_END_REGEXP
20
20
  */
21
- export declare const BLOCK_END_REGEXP = "\\s*(?:$|\\n\\s*\\n)";
21
+ export declare const BLOCK_END_REGEXP = "\\s{0,2000}(?:$|\\n\\s{0,2000}\\n)";
22
22
  /**
23
23
  * Regular expression source matching line content — the shortest run of any character except newline.
24
24
  * @see https://shelving.cc/markup/LINE_CONTENT_REGEXP
@@ -33,12 +33,12 @@ export declare const LINE_SPACE_REGEXP = "[^\\n\\S]";
33
33
  * Regular expression source matching the start of a line — the start of the string, or one linebreak.
34
34
  * @see https://shelving.cc/markup/LINE_START_REGEXP
35
35
  */
36
- export declare const LINE_START_REGEXP = "(?:\\s*\\n|^)";
36
+ export declare const LINE_START_REGEXP = "(?:\\s{0,2000}\\n|^)";
37
37
  /**
38
38
  * Regular expression source matching the end of a line — the end of the string, or one linebreak, with trailing whitespace trimmed.
39
39
  * @see https://shelving.cc/markup/LINE_END_REGEXP
40
40
  */
41
- export declare const LINE_END_REGEXP = "[^\\n\\S]*(?:\\s*\\n|$)";
41
+ export declare const LINE_END_REGEXP = "[^\\n\\S]{0,2000}(?:\\s{0,2000}\\n|$)";
42
42
  /**
43
43
  * Regular expression source matching word content — at least one letter or number character.
44
44
  * @see https://shelving.cc/markup/WORD_CONTENT_REGEXP
@@ -13,12 +13,12 @@ export const BLOCK_SPACE_REGEXP = "\\s"; // Block whitespace (run of any whitesp
13
13
  * Regular expression source matching the start of a block — the start of the string, or one linebreak.
14
14
  * @see https://shelving.cc/markup/BLOCK_START_REGEXP
15
15
  */
16
- export const BLOCK_START_REGEXP = "(?:\\s*\\n|^)"; // Start of block (start of string, or one linebreak).
16
+ export const BLOCK_START_REGEXP = "(?:\\s{0,2000}\\n|^)"; // Start of block (start of string, or one linebreak). Whitespace is bounded so a long whitespace run can't force O(n²) backtracking at every offset.
17
17
  /**
18
18
  * Regular expression source matching the end of a block — the end of the string, or two linebreaks, with trailing whitespace trimmed.
19
19
  * @see https://shelving.cc/markup/BLOCK_END_REGEXP
20
20
  */
21
- export const BLOCK_END_REGEXP = "\\s*(?:$|\\n\\s*\\n)"; // End of block (end of string, or two linebreaks, trimmed whitespace).
21
+ export const BLOCK_END_REGEXP = "\\s{0,2000}(?:$|\\n\\s{0,2000}\\n)"; // End of block (end of string, or two linebreaks, trimmed whitespace). Whitespace runs bounded to avoid O(n²) backtracking.
22
22
  /**
23
23
  * Regular expression source matching line content — the shortest run of any character except newline.
24
24
  * @see https://shelving.cc/markup/LINE_CONTENT_REGEXP
@@ -38,7 +38,7 @@ export const LINE_START_REGEXP = BLOCK_START_REGEXP; // Start of line (start of
38
38
  * Regular expression source matching the end of a line — the end of the string, or one linebreak, with trailing whitespace trimmed.
39
39
  * @see https://shelving.cc/markup/LINE_END_REGEXP
40
40
  */
41
- export const LINE_END_REGEXP = `${LINE_SPACE_REGEXP}*(?:\\s*\\n|$)`; // End of line (end of string, or one linebreak, trimmed whitespace).
41
+ export const LINE_END_REGEXP = `${LINE_SPACE_REGEXP}{0,2000}(?:\\s{0,2000}\\n|$)`; // End of line (end of string, or one linebreak, trimmed whitespace). Whitespace runs bounded to avoid O(n²) backtracking.
42
42
  /**
43
43
  * Regular expression source matching word content — at least one letter or number character.
44
44
  * @see https://shelving.cc/markup/WORD_CONTENT_REGEXP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.267.2",
3
+ "version": "1.267.4",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,6 +14,7 @@ export interface MarkupProps extends Partial<MarkupOptions> {
14
14
  * - Defaults to `MARKUP_OPTIONS` (full block + inline rule set). Pass `rules`, `rel`, `url`, `root`, or `schemes` as props to override.
15
15
  * - `url`/`root` default to the current `<Meta>` context so link rules resolve site-absolute and relative hrefs.
16
16
  * - Renders inside whatever ancestor element the caller provides — wrap in `<Prose>` to get the standard prose typography for the produced `<p>` / `<ul>` / `<pre>` / etc.
17
+ * - **Untrusted input:** when `children` comes from users, cap its length before rendering — parsing is linear for normal content but a few rules can degrade on adversarial input (a sane maximum for your use case, typically tens of kilobytes, bounds worst-case work). See `MarkupParser.parse()`.
17
18
  *
18
19
  * @param children The source markup string to parse and render (renders `null` when empty).
19
20
  * @returns The parsed markup as React nodes, or `null` when `children` is empty.
package/ui/misc/Markup.js CHANGED
@@ -5,6 +5,7 @@ import { requireMeta } from "./MetaContext.js";
5
5
  * - Defaults to `MARKUP_OPTIONS` (full block + inline rule set). Pass `rules`, `rel`, `url`, `root`, or `schemes` as props to override.
6
6
  * - `url`/`root` default to the current `<Meta>` context so link rules resolve site-absolute and relative hrefs.
7
7
  * - Renders inside whatever ancestor element the caller provides — wrap in `<Prose>` to get the standard prose typography for the produced `<p>` / `<ul>` / `<pre>` / etc.
8
+ * - **Untrusted input:** when `children` comes from users, cap its length before rendering — parsing is linear for normal content but a few rules can degrade on adversarial input (a sane maximum for your use case, typically tens of kilobytes, bounds worst-case work). See `MarkupParser.parse()`.
8
9
  *
9
10
  * @param children The source markup string to parse and render (renders `null` when empty).
10
11
  * @returns The parsed markup as React nodes, or `null` when `children` is empty.
package/ui/misc/Markup.md CHANGED
@@ -7,6 +7,7 @@ Parses a markup string and renders the resulting React nodes inline. Defaults to
7
7
  - `url` and `root` default to the current `<Meta>` context, so link rules resolve site-absolute and relative hrefs correctly. Override any `MarkupOptions` prop (`rules`, `rel`, `url`, `root`, `schemes`) directly on `<Markup>` for a custom rule set or base URL.
8
8
  - Renders `null` when `children` is empty.
9
9
  - Wrap in `<Prose>` to give the produced `<p>` / `<ul>` / `<pre>` / etc. the standard prose typography.
10
+ - When `children` comes from users, cap its length before rendering: parsing is linear for normal content, but a few rules can degrade on adversarial input (long backtick runs, deeply-nested `>` blockquotes). A sane maximum for your use case — tens of kilobytes for typical user content — keeps worst-case work bounded.
10
11
 
11
12
  ## Usage
12
13
 
@@ -17,6 +17,7 @@ export interface MarkupProps extends Partial<MarkupOptions> {
17
17
  * - Defaults to `MARKUP_OPTIONS` (full block + inline rule set). Pass `rules`, `rel`, `url`, `root`, or `schemes` as props to override.
18
18
  * - `url`/`root` default to the current `<Meta>` context so link rules resolve site-absolute and relative hrefs.
19
19
  * - Renders inside whatever ancestor element the caller provides — wrap in `<Prose>` to get the standard prose typography for the produced `<p>` / `<ul>` / `<pre>` / etc.
20
+ * - **Untrusted input:** when `children` comes from users, cap its length before rendering — parsing is linear for normal content but a few rules can degrade on adversarial input (a sane maximum for your use case, typically tens of kilobytes, bounds worst-case work). See `MarkupParser.parse()`.
20
21
  *
21
22
  * @param children The source markup string to parse and render (renders `null` when empty).
22
23
  * @returns The parsed markup as React nodes, or `null` when `children` is empty.
@@ -21,6 +21,7 @@ export declare const TREE_MARKUP_RULES: MarkupRules;
21
21
  * - Like `<Markup>` but defaults to `TREE_MARKUP_RULES`, so each backtick span resolves through `TreeLink` against the surrounding `<TreeProvider>` — a known token links to its canonical page, an unknown one stays plain code.
22
22
  * - The standard way to render documentation-site content (READMEs, docblocks, per-symbol pages); plain `<Markup>` stays the choice for general user content that shouldn't cross-link.
23
23
  * - Falls back to plain code spans outside a `<TreeProvider>`, so it's safe to use anywhere. Pass any `MarkupOptions` prop (including `rules`) to override.
24
+ * - Inherits `<Markup>`'s input-length guidance: when `children` is untrusted, cap its length before rendering (see `MarkupParser.parse()`).
24
25
  *
25
26
  * @param children The source markup string to parse and render (renders `null` when empty).
26
27
  * @returns The parsed markup as React nodes, or `null` when `children` is empty.
@@ -26,6 +26,7 @@ export const TREE_MARKUP_RULES = MARKUP_RULES.map(rule => (rule === CODE_RULE ?
26
26
  * - Like `<Markup>` but defaults to `TREE_MARKUP_RULES`, so each backtick span resolves through `TreeLink` against the surrounding `<TreeProvider>` — a known token links to its canonical page, an unknown one stays plain code.
27
27
  * - The standard way to render documentation-site content (READMEs, docblocks, per-symbol pages); plain `<Markup>` stays the choice for general user content that shouldn't cross-link.
28
28
  * - Falls back to plain code spans outside a `<TreeProvider>`, so it's safe to use anywhere. Pass any `MarkupOptions` prop (including `rules`) to override.
29
+ * - Inherits `<Markup>`'s input-length guidance: when `children` is untrusted, cap its length before rendering (see `MarkupParser.parse()`).
29
30
  *
30
31
  * @param children The source markup string to parse and render (renders `null` when empty).
31
32
  * @returns The parsed markup as React nodes, or `null` when `children` is empty.
@@ -7,7 +7,7 @@ Renders a markup string just like `<Markup>`, but auto-links every inline code s
7
7
  - Defaults to `TREE_MARKUP_RULES` — the full default rule set with the inline-code rule swapped for `TREE_CODE_RULE`, which renders each span through `TreeLink`.
8
8
  - Resolution is exact-match against the tree map: a hit (`` `BooleanSchema` ``, `` `Store.get` ``) links; a miss (`` `bun run fix` ``, `` `string` ``) falls back to a plain code token, so unknown spans never produce a broken link.
9
9
  - Falls back to plain code spans outside a `<TreeProvider>`, so it's safe to use anywhere — it simply stops linking.
10
- - Inherits everything else from `<Markup>`: `url` / `root` default to the current `<Meta>` context, and any `MarkupOptions` prop (`rules`, `rel`, `url`, `root`, `schemes`) can be overridden directly.
10
+ - Inherits everything else from `<Markup>`: `url` / `root` default to the current `<Meta>` context, and any `MarkupOptions` prop (`rules`, `rel`, `url`, `root`, `schemes`) can be overridden directly. That includes the input-length guidance — cap untrusted `children` length before rendering.
11
11
  - Wrap in `<Prose>` to give the produced `<p>` / `<ul>` / `<pre>` / etc. the standard prose typography.
12
12
 
13
13
  ## Usage
@@ -30,6 +30,7 @@ export const TREE_MARKUP_RULES: MarkupRules = MARKUP_RULES.map(rule => (rule ===
30
30
  * - Like `<Markup>` but defaults to `TREE_MARKUP_RULES`, so each backtick span resolves through `TreeLink` against the surrounding `<TreeProvider>` — a known token links to its canonical page, an unknown one stays plain code.
31
31
  * - The standard way to render documentation-site content (READMEs, docblocks, per-symbol pages); plain `<Markup>` stays the choice for general user content that shouldn't cross-link.
32
32
  * - Falls back to plain code spans outside a `<TreeProvider>`, so it's safe to use anywhere. Pass any `MarkupOptions` prop (including `rules`) to override.
33
+ * - Inherits `<Markup>`'s input-length guidance: when `children` is untrusted, cap its length before rendering (see `MarkupParser.parse()`).
33
34
  *
34
35
  * @param children The source markup string to parse and render (renders `null` when empty).
35
36
  * @returns The parsed markup as React nodes, or `null` when `children` is empty.
@@ -163,6 +163,7 @@ export declare const omitDictionaryItem: <T>(dict: ImmutableDictionary<T>, key:
163
163
  export declare const pickDictionaryItems: <T>(dict: ImmutableDictionary<T>, ...keys: string[]) => ImmutableDictionary<T>;
164
164
  /**
165
165
  * Set a single named item on a dictionary object (by reference) and return its value.
166
+ * - The key is trusted by contract: a runtime-untrusted key like `"__proto__"` would mutate the target's prototype, so validate or filter untrusted keys before calling (or use a null-prototype target).
166
167
  *
167
168
  * @param dict The dictionary to set the item on (modified by reference).
168
169
  * @param key The key of the item to set.
@@ -173,6 +174,7 @@ export declare const pickDictionaryItems: <T>(dict: ImmutableDictionary<T>, ...k
173
174
  export declare const setDictionaryItem: <T>(dict: MutableDictionary<T>, key: string, value: T) => T;
174
175
  /**
175
176
  * Set several named items on a dictionary object (by reference).
177
+ * - Keys are trusted by contract: a runtime-untrusted key like `"__proto__"` would mutate the target's prototype, so validate or filter untrusted keys before calling (or use a null-prototype target).
176
178
  *
177
179
  * @param dict The dictionary to set the items on (modified by reference).
178
180
  * @param entries The items to set, as a dictionary or iterable set of key/value entry tuples.
@@ -139,6 +139,7 @@ export const omitDictionaryItem = omitProps;
139
139
  export const pickDictionaryItems = pickProps;
140
140
  /**
141
141
  * Set a single named item on a dictionary object (by reference) and return its value.
142
+ * - The key is trusted by contract: a runtime-untrusted key like `"__proto__"` would mutate the target's prototype, so validate or filter untrusted keys before calling (or use a null-prototype target).
142
143
  *
143
144
  * @param dict The dictionary to set the item on (modified by reference).
144
145
  * @param key The key of the item to set.
@@ -149,6 +150,7 @@ export const pickDictionaryItems = pickProps;
149
150
  export const setDictionaryItem = setProp;
150
151
  /**
151
152
  * Set several named items on a dictionary object (by reference).
153
+ * - Keys are trusted by contract: a runtime-untrusted key like `"__proto__"` would mutate the target's prototype, so validate or filter untrusted keys before calling (or use a null-prototype target).
152
154
  *
153
155
  * @param dict The dictionary to set the items on (modified by reference).
154
156
  * @param entries The items to set, as a dictionary or iterable set of key/value entry tuples.
package/util/diff.d.ts CHANGED
@@ -44,6 +44,7 @@ export declare function deepDiffArray<R extends ImmutableArray>(left: ImmutableA
44
44
  * @returns Object containing the missing/updated properties that `left` needs to become `right`.
45
45
  * - If the two values are deeply equal the `SAME` constant is returned.
46
46
  * - If `left` isn't an object then the result can't be diffed so entire `right` is returned.
47
+ * - Diff object has a `null` prototype, so an untrusted `"__proto__"` key in `right` becomes an inert own property instead of injecting a prototype.
47
48
  * @see https://shelving.cc/util/diff/deepDiffObject
48
49
  */
49
50
  export declare function deepDiffObject<R extends ImmutableObject>(left: ImmutableObject, right: R): R | DeepPartial<R> | typeof SAME;
package/util/diff.js CHANGED
@@ -42,7 +42,10 @@ export function deepDiffObject(left, right) {
42
42
  // If left is empty, entire right is returned.
43
43
  if (!leftKeys.length)
44
44
  return rightKeys.length ? right : SAME;
45
- const diff = {};
45
+ // Null-prototype accumulator: an untrusted `"__proto__"` key in `right` then becomes an inert own property
46
+ // instead of invoking the inherited `__proto__` setter, so a crafted `{ "__proto__": … }` input can't reassign
47
+ // the diff object's prototype.
48
+ const diff = Object.create(null);
46
49
  let changed = false;
47
50
  for (const k of rightKeys) {
48
51
  const d = deepDiff(left[k], right[k]);
package/util/merge.d.ts CHANGED
@@ -67,6 +67,7 @@ export declare function mergeArray<L, R>(left: ImmutableArray<L>, right: Immutab
67
67
  * - Only works on enumerable own keys (as returned by `Object.keys()`).
68
68
  * - Always returns a new object (or the `left` object if no changes were made).
69
69
  * - Resulting object is `cleaned`, i.e. properties in `left` or `right` that are `undefined` will be removed from the merged object.
70
+ * - Merged object has a `null` prototype, so an untrusted `"__proto__"` key in `right` becomes an inert own property instead of injecting a prototype.
70
71
  *
71
72
  * @param left The left object to merge into.
72
73
  * @param right The right object whose props replace or merge with `left`.
package/util/merge.js CHANGED
@@ -45,7 +45,10 @@ export function mergeObject(left, right, recursor = exactMerge) {
45
45
  if (!rightEntries.length)
46
46
  return left;
47
47
  const leftKeys = Object.keys(left);
48
- const merged = { ...left };
48
+ // Null-prototype accumulator: an untrusted `"__proto__"` key in `right` then becomes an inert own property
49
+ // instead of invoking the inherited `__proto__` setter, so a crafted `{ "__proto__": … }` input can't reassign
50
+ // the merged object's prototype.
51
+ const merged = Object.assign(Object.create(null), left);
49
52
  let changed = false;
50
53
  for (const [k, r] of rightEntries) {
51
54
  if (leftKeys.includes(k)) {
package/util/object.d.ts CHANGED
@@ -249,6 +249,7 @@ export declare const omitProp: <T, K extends Key<T>>(input: T, key: K) => Omit<T
249
249
  export declare function pickProps<T, K extends Key<T>>(obj: T, ...keys: K[]): Pick<T, K>;
250
250
  /**
251
251
  * Set a single named prop on an object (by reference) and return its value.
252
+ * - The key is trusted by contract: a runtime-untrusted key like `"__proto__"` would mutate the target's prototype, so validate or filter untrusted keys before calling (or use a null-prototype target).
252
253
  *
253
254
  * @param obj The object to set the prop on (modified by reference).
254
255
  * @param key The key of the prop to set.
@@ -259,6 +260,7 @@ export declare function pickProps<T, K extends Key<T>>(obj: T, ...keys: K[]): Pi
259
260
  export declare function setProp<T extends MutableObject, K extends Key<T>>(obj: T, key: K, value: T[K]): T[K];
260
261
  /**
261
262
  * Set several named props on an object (by reference).
263
+ * - Keys are trusted by contract: a runtime-untrusted key like `"__proto__"` would mutate the target's prototype, so validate or filter untrusted keys before calling (or use a null-prototype target).
262
264
  *
263
265
  * @param obj The object to set the props on (modified by reference).
264
266
  * @param entries The props to set, as an object or iterable set of key/value entry tuples.
package/util/object.js CHANGED
@@ -157,6 +157,7 @@ function _hasKey([key]) {
157
157
  }
158
158
  /**
159
159
  * Set a single named prop on an object (by reference) and return its value.
160
+ * - The key is trusted by contract: a runtime-untrusted key like `"__proto__"` would mutate the target's prototype, so validate or filter untrusted keys before calling (or use a null-prototype target).
160
161
  *
161
162
  * @param obj The object to set the prop on (modified by reference).
162
163
  * @param key The key of the prop to set.
@@ -170,6 +171,7 @@ export function setProp(obj, key, value) {
170
171
  }
171
172
  /**
172
173
  * Set several named props on an object (by reference).
174
+ * - Keys are trusted by contract: a runtime-untrusted key like `"__proto__"` would mutate the target's prototype, so validate or filter untrusted keys before calling (or use a null-prototype target).
173
175
  *
174
176
  * @param obj The object to set the props on (modified by reference).
175
177
  * @param entries The props to set, as an object or iterable set of key/value entry tuples.
package/util/uri.d.ts CHANGED
@@ -192,6 +192,7 @@ export type PossibleURIParams = PossibleURI | URLSearchParams | ImmutableDiction
192
192
  /**
193
193
  * Get a set of params for a URI as a dictionary.
194
194
  * - Any params with `undefined` value will be ignored.
195
+ * - Returned dictionary has a `null` prototype, so an untrusted `__proto__` param becomes an inert own entry (instead of being silently dropped by the inherited `__proto__` setter).
195
196
  *
196
197
  * @param params Possible URI params to convert.
197
198
  * @param caller Function to attribute a thrown error to (defaults to `getURIParams` itself).
package/util/uri.js CHANGED
@@ -99,6 +99,7 @@ function* getURIEntries(params, caller = getURIParams) {
99
99
  /**
100
100
  * Get a set of params for a URI as a dictionary.
101
101
  * - Any params with `undefined` value will be ignored.
102
+ * - Returned dictionary has a `null` prototype, so an untrusted `__proto__` param becomes an inert own entry (instead of being silently dropped by the inherited `__proto__` setter).
102
103
  *
103
104
  * @param params Possible URI params to convert.
104
105
  * @param caller Function to attribute a thrown error to (defaults to `getURIParams` itself).
@@ -108,7 +109,9 @@ function* getURIEntries(params, caller = getURIParams) {
108
109
  * @see https://shelving.cc/util/uri/getURIParams
109
110
  */
110
111
  export function getURIParams(params, caller = getURIParams) {
111
- const output = {};
112
+ // Null-prototype accumulator: param keys are runtime-untrusted, so an untrusted `"__proto__"` key becomes an
113
+ // inert own entry instead of invoking the inherited `__proto__` setter (which would silently drop the param).
114
+ const output = Object.create(null);
112
115
  for (const [key, str] of getURIEntries(params, caller))
113
116
  output[key] = str;
114
117
  return output;