fix(plugin-sdk): stop the README heading match backtracking

`missingSections()` matched headings with `(.+?)\s*(?:<!--.*)?$`, where the
lazy body and the trailing `\s*` compete over the same run of spaces. That
backtracks quadratically: a heading line padded with 80k spaces takes about
12 seconds, 200k about 75. The gate runs over READMEs anyone can submit to
the registry, so the padding is not the author own input.

The trailing anchor comment is now cut with indexOf, and the body is matched
with its edges pinned to non-space, so nothing competes with the trailing
`\s*`. Same shape the wiki sidebar parser was rewritten to for the same
reason. Behaviour is unchanged on every heading that is not the attack:
trailing comment, unterminated comment, tab separator, padded title.

scripts/check-readme.mjs in the TREK-Plugins registry carries the same
regex and needs the same change.
@
This commit is contained in:
Maurice
2026-08-27 12:34:55 +02:00
committed by GitHub
parent 1f66ef1d32
commit de9bd94279
2 changed files with 39 additions and 3 deletions
+21 -3
View File
@@ -29,11 +29,29 @@ export const PLACEHOLDER_PATTERNS = [
export const MIN_PROSE_CHARS = 400;
/**
* The heading body, with the trailing HTML comment authors use to hide anchors cut off.
*
* Cut with indexOf rather than a regex on purpose. The obvious spellings — the
* `(.+?)\s*(?:<!--.*)?$` this used to be, or a `replace(/\s*<!--.*$/, '')` in front of it —
* put two quantifiers on the same run of spaces, and both then backtrack quadratically:
* a heading line padded with 80k spaces takes ~12s, 200k takes ~30s. This gate runs over
* READMEs anyone can submit to the registry, so that is a stall someone can post. Same
* failure the wiki sidebar parser was rewritten for (server/src/nest/help/wiki.ts).
*/
function headingBody(line: string): string {
const comment = line.indexOf('<!--');
return comment < 0 ? line : line.slice(0, comment);
}
/** Which of REQUIRED_SECTIONS have no matching heading. */
export function missingSections(md: string): string[] {
// The `(?:<!--.*)?` tail mirrors check-readme.mjs — it tolerates a trailing HTML comment
// on a heading line, which authors use to hide anchors.
const headings = [...md.matchAll(/^#{1,6}\s+(.+?)\s*(?:<!--.*)?$/gm)].map((m) => m[1].toLowerCase());
const headings = md
.split('\n')
// `\S.*\S|\S` instead of a lazy body: the edges are pinned to non-space, so nothing
// competes with the trailing `\s*`.
.map((line) => /^#{1,6}\s+(\S.*\S|\S)\s*$/.exec(headingBody(line))?.[1].toLowerCase())
.filter((h): h is string => h !== undefined);
return REQUIRED_SECTIONS.filter((want) => !headings.some((got) => got.includes(want.toLowerCase())));
}
+18
View File
@@ -241,6 +241,24 @@ describe('README helpers mirror the registry exactly', () => {
expect(missingSections('## What it does')).toEqual(['Screenshots', 'Permissions', 'Setup']);
});
it('still ignores the trailing anchor comment authors hide on a heading', () => {
const md = '## What it does <!-- #what -->\n## Screenshots<!--x-->\n##\tPermissions \n## Setup <!-- unterminated';
expect(missingSections(md)).toEqual([]);
// A heading that is nothing but a comment names no section.
expect(missingSections('# <!-- only -->')).toEqual(['What it does', 'Screenshots', 'Permissions', 'Setup']);
});
it('does not stall on a heading padded with spaces', () => {
// The old `(.+?)\s*(?:<!--.*)?$` was quadratic: ~12s at 80k spaces, ~75s at 200k.
// READMEs reach this gate from the registry's submission CI, so the padding is
// attacker-supplied. The bound is 1000x the real cost, so it fails on the shape
// of the regression, not on a slow machine.
const md = `## What it does${' '.repeat(200_000)}x\n## Screenshots\n## Permissions\n## Setup`;
const started = performance.now();
expect(missingSections(md)).toEqual([]);
expect(performance.now() - started).toBeLessThan(1000);
});
it('ignores data: URIs when looking for a screenshot, like the registry does', () => {
expect(images('![a](data:image/png;base64,xxx)')).toEqual([]);
expect(images('<img src="shot.png">')).toEqual(['shot.png']);