fix(security): close code-scanning and dependabot alerts (#5557)

* fix(security): close code-scanning and dependabot alerts

- markdown-paste.ts: strip <style>/<script> in a loop, not a single
  pass, so nested/overlapping tags can't leave a surviving <script>
  behind (incomplete multi-character sanitization)
- block-identity.ts: annotate the two SHA-1 uses as intentional
  (UUIDv5 per RFC 4122, deterministic id derivation only, not a
  security use of the hash) rather than swap algorithms, which would
  change every derived fork block id
- apps/pii: bump transformers 4.56.2 -> 5.3.0 (CVE-2026-4372 RCE via
  crafted config.json, CVE-2026-1839 RCE via Trainer torch.load),
  huggingface_hub 0.35.3 -> 1.3.0 (transformers 5.3.0's floor), and
  pytest 8.4.1 -> 9.0.3 (CVE-2025-71176 tmpdir handling); verified
  pip resolves cleanly and the unit test suite passes on 9.0.3

* fix(files): make markdown-paste sanitizer O(n) instead of O(n*depth)

Greptile flagged the repeated-replace loop from the prior commit: it
strips <style>/<script> correctly but rescans the whole string once
per nesting level, so deeply nested clipboard HTML can freeze the tab.
Replace it with a single linear pass that tracks nesting depth of the
open tag via a tag-token scan, dropping the element in one pass no
matter how deeply nested.

* style: fold inline comments into TSDoc per repo comment convention

Repo convention is TSDoc-only documentation, no non-TSDoc explanatory
comments. Moved the uuidV5 SHA-1 rationale and the stray-close-tag note
into the existing TSDoc blocks above each function. Left the two
lgtm[...] annotations as trailing comments since those are functional
CodeQL suppression directives (must sit on the flagged line), not
documentation.

* test(files): lock in nested-tag stripping regression for markdown paste

Covers the case Greptile flagged: nested and 50-deep <script> tags
must strip in one pass without leaking a dangling tag.

* fix(files): drop unterminated <script>/<style> instead of leaking it

Cursor Bugbot caught two related bugs in the single-pass rewrite: if
pasted HTML ends while a script/style element is still open (truncated
or malformed clipboard HTML), cursor never advanced past the open tag,
so the final flush re-appended the untouched tag/content (leaking an
unstripped <script>) and duplicated the prefix already copied into
result.

Fix: advance cursor the moment a tag opens, not when it closes, and
only do the final flush when we end at depth 0. An element that never
closes has cursor already past its open tag, so it and everything
after it is dropped instead of reappearing.
This commit is contained in:
Waleed
2026-07-09 20:54:29 -07:00
committed by GitHub
parent e2cb3b29ba
commit f9a5d8b113
5 changed files with 58 additions and 8 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Test-only deps. Unit tests need requirements.txt + this file (no models);
# integration tests additionally need the models baked into the docker images
# (see tests/test_integration.py).
pytest==8.4.1
pytest==9.0.3
httpx==0.28.1
+2 -2
View File
@@ -6,5 +6,5 @@
# torch is pinned in the Dockerfile instead: the CPU and CUDA targets install
# the same version from different wheel indexes.
gliner==0.2.27
transformers==4.56.2
huggingface_hub==0.35.3
transformers==5.3.0
huggingface_hub==1.3.0
@@ -240,4 +240,18 @@ describe('markdown paste', () => {
expect(cleaned).toContain('<td>a</td>')
expect(transformHtml(editor, 'a<script>alert(1)</script>b')).toBe('ab')
})
it('strips nested/repeated <script> tags in a single pass, even deeply nested', () => {
editor = mount()
expect(transformHtml(editor, 'a<script>x<script>y</script></script>b')).toBe('ab')
const deeplyNested = `a${'<script>'.repeat(50)}x${'</script>'.repeat(50)}b`
expect(transformHtml(editor, deeplyNested)).toBe('ab')
})
it('drops an unterminated <script>/<style> and everything after it, without duplicating the prefix', () => {
editor = mount()
expect(transformHtml(editor, 'abc<script>never-closes')).toBe('abc')
expect(transformHtml(editor, 'abc<style>never-closes')).toBe('abc')
expect(transformHtml(editor, '<script>x<script>y</script>')).toBe('')
})
})
@@ -73,17 +73,48 @@ function parseVscodeLanguage(data: string | undefined): string {
}
}
/** `<style>`/`<script>` elements (with their content), matched as a pair via the tag backreference. */
const NON_CONTENT_HTML = /<(style|script)\b[\s\S]*?<\/\1>/gi
/** A `<style>`/`<script>` open or close tag token, scanned one at a time (never the element body). */
const NON_CONTENT_TAG = /<\/?\s*(style|script)\b[^>]*>/gi
/**
* Strips `<style>`/`<script>` elements from pasted HTML. Google Sheets and Word prepend a `<style>`
* block of CSS (and Sheets a `<google-sheets-html-origin>` wrapper); ProseMirror's DOM parser has no
* rule for `<style>`, so it would walk the element's CSS text into the document as literal paragraphs.
* Removing these before parsing keeps the pasted content clean (PM already discards unknown wrappers).
*
* Scans tag tokens in a single linear pass, tracking nesting depth of the currently-open tag name, so
* nested/overlapping tags — e.g. `<script><script>x</script>` — can't leave a surviving `<script>`
* behind. A naive single `replace()` pass over `<tag>[\s\S]*?<\/tag>` matches only the innermost pair
* and leaves the outer tag dangling; repeating that replace until stable fixes correctness but costs
* O(depth) full-string rescans on attacker-controlled clipboard input. This does it in one pass instead.
* A stray close tag encountered outside any open element (depth 0) is left in place untouched. `cursor`
* advances past an open tag the moment it opens (not when it closes), so if the input ends before the
* element closes — truncated or malformed clipboard HTML — the unterminated element and everything
* after it is dropped rather than reappearing unstripped in the final `html.slice(cursor)` flush.
*/
function stripNonContentHtml(html: string): string {
return html.replace(NON_CONTENT_HTML, '')
let result = ''
let cursor = 0
let depth = 0
let openTagName = ''
NON_CONTENT_TAG.lastIndex = 0
let match: RegExpExecArray | null
while ((match = NON_CONTENT_TAG.exec(html))) {
const isClosing = match[0][1] === '/'
const tagName = match[1].toLowerCase()
if (depth === 0) {
if (isClosing) continue
result += html.slice(cursor, match.index)
openTagName = tagName
depth = 1
cursor = match.index + match[0].length
} else if (tagName === openTagName) {
depth += isClosing ? -1 : 1
if (depth === 0) cursor = match.index + match[0].length
}
}
if (depth === 0) result += html.slice(cursor)
return result
}
/**
@@ -15,11 +15,16 @@ function uuidToBytes(uuid: string): Buffer {
/**
* Deterministic UUIDv5 (SHA-1) of `name` within `namespace`. The same inputs
* always yield the same UUID, which is how fork block identity stays stable.
*
* SHA-1 is mandated by RFC 4122 for UUIDv5 and is used here only for deterministic id derivation,
* never for secrecy or integrity — not a security use of the algorithm. Swapping it would change
* every derived id, breaking webhook URLs and stored block-id references across existing forks
* (see {@link FORK_BLOCK_NAMESPACE}).
*/
function uuidV5(name: string, namespace: string): string {
const hash = createHash('sha1')
hash.update(uuidToBytes(namespace))
hash.update(Buffer.from(name, 'utf8'))
hash.update(uuidToBytes(namespace)) // lgtm[js/weak-cryptographic-algorithm]
hash.update(Buffer.from(name, 'utf8')) // lgtm[js/weak-cryptographic-algorithm]
const bytes = hash.digest().subarray(0, 16)
bytes[6] = (bytes[6] & 0x0f) | 0x50
bytes[8] = (bytes[8] & 0x3f) | 0x80