GHSA-cp6q-959q-f8rh: XSS
Summary
@tiptap/core's public mergeAttributes() helper uses ordinary bracket assignment on keys returned by Object.entries(). An own proto key from JSON therefore invokes the legacy prototype setter on the fresh merged object. The function returns an object whose prototype is attacker-controlled, while Object.keys() and ordinary own-property checks show no attacker attributes.
When that result is used as a ProseMirror DOMOutputSpec attribute object, prosemirror-model's DOMSerializer.renderSpec() enumerates it with for...in and applies inherited values with setAttribute(). In a browser proof, inherited src and onerror values were copied to an <img> and the error handler executed once. This is per-object prototype manipulation; the proof does not modify global Object.prototype.
Root cause
The affected loop is conceptually:
ts const mergedAttributes = { ...items } for (const [key, value] of Object.entries(item)) { const exists = mergedAttributes[key] // ... mergedAttributes[key] = value }
Object.entries(JSON.parse('{"proto": {...}}')) includes proto. Reading mergedAttributes['proto'] resolves the inherited Object.prototype; assigning to the same key invokes Object.prototype.proto's setter and replaces mergedAttributes' prototype.
Browser reproduction
The following shape was tested with exact @tiptap/core 3.29.2 and prosemirror-model 1.25.11:
js const input = JSON.parse({ "proto": { "data-inherited-canary": "present", "src": "x-invalid://canary", "onerror": "globalThis.tiptapXss += 1" } })
const attrs = mergeAttributes(input) // Object.keys(attrs) === [] // Object.getPrototypeOf(attrs) === input.proto
const schema = new Schema({ nodes: { doc: { content: 'image' }, image: { toDOM: () => ['img', attrs] }, text: {}, }, }) const doc = schema.node('doc', null, [schema.node('image')]) const fragment = DOMSerializer.fromSchema(schema).serializeFragment(doc.content) document.body.append(fragment)
Chromium produced an image with data-inherited-canary, src, and onerror; the handler executed exactly once. Object.prototype remained clean.
Impact and preconditions
Applications that merge untrusted imported document, plugin, CMS, API, tenant, or AI-derived attribute objects can receive a prototype-manipulated result. Consumers that enumerate inherited keys, including ProseMirror's DOM serializer, can turn the hidden properties into DOM attributes and execute JavaScript in the application's origin. Own-key validation, object spread, JSON serialization, and logging can miss the inherited values. Other component consumers can read inherited authorization or configuration fields.
Tiptap's standard fixed ProseMirror schemas discard unknown document attributes, so arbitrary Tiptap JSON is not automatically exploitable in every application. A vulnerable application needs an untrusted object boundary into mergeAttributes() or a dynamic/custom extension or schema that preserves the relevant attribute object.
Affected versions
The unsafe assignment was introduced in commit ecadf7ea0a7f8f39a8496a60edf0ac8f379e6eb3 and is present in the first package tag @tiptap/core@2.0.0-alpha.0, v2.0.0, v2.27.1, v3.0.0, and current v3.29.2 source. No fixed release was found.
Recommended remediation
Reject proto before reading or assigning the key, or define copied keys as own data properties without invoking legacy setters. A minimal hardening is to skip key === 'proto'. Add regression tests using an own JSON-origin proto key and assert that the result keeps Object.prototype as its prototype, exposes no inherited attacker keys, and cannot create an event-handler attribute through DOMSerializer.
This was found during authorized dependency review and is being reported privately. No public zero-day issue has been opened.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/@tiptap/coreto a version that resolves this vulnerability.Fixed in 3.30.4 - Configuration
Before reading or assigning any attribute keys in the object returned/used by mergeAttributes(), reject the key literally equal to '__proto__' (skip processing for key === '__proto__') to prevent per-object prototype manipulation that can lead to DOMSerializer setting inherited attributes (e.g., src/onerror).
Application code using @tiptap/core mergeAttributes() / DOMSerializer __proto__ key handling = reject - Operational
Add regression tests that supply an own JSON-origin '__proto__' key (e.g., via JSON.parse) into the attribute input that reaches mergeAttributes(), then assert: (1) the merged result keeps Object.prototype as its prototype, (2) no inherited attacker keys are exposed/enumerable, and (3) the result cannot create an event-handler attribute through DOMSerializer rendering.
Event History
Frequently Asked Questions
What conditions are required for exploitation?
An attacker needs to supply an attribute object with an own __proto__ property, such as one produced by parsing attacker-controlled JSON, that reaches mergeAttributes(). The resulting merged object must then be used as a ProseMirror DOMOutputSpec attribute object and rendered by DOMSerializer.renderSpec() in a browser.
Does this modify Object.prototype globally?
No. The described behavior manipulates the prototype of the individual merged object; it does not modify the global Object.prototype.
Can ordinary attribute validation or inspection reveal the injected values?
Not reliably. The attacker-controlled values are inherited from the manipulated prototype, so Object.keys() and ordinary own-property checks do not show them, while for...in enumeration can still process them.