Protect against prototype pollution in core extend utility and markup parser#8578
Protect against prototype pollution in core extend utility and markup parser#8578RinZ27 wants to merge 1 commit intoplaycanvas:mainfrom
Conversation
3e1df10 to
98287e0
Compare
|
What actual problem this solves? |
|
@Maksims The PR addresses a potential Prototype Pollution and Property Shadowing vulnerability in the The actual problem is that the previous implementation of the internal An attacker could craft a malicious markup string, for example using a tag name like Similarly, a tag named
I've also consolidated the duplicate |
|
I don't see a problem here. If you are the author of your application, you are in control of your JS code that will be executed on the end users browsers. You don't need to do any prototype pollution to break it. The error message with the |
There was a problem hiding this comment.
Pull request overview
This PR hardens the engine against prototype pollution by filtering sensitive property names during deep object copying/merging and refactoring the element markup parser to rely on the shared core utility.
Changes:
- Add filtering for
__proto__,constructor, andprototypeinsrc/core/core.js’sextend. - Replace the markup parser’s local deep-merge helper with the core
extend. - Add a new security-focused test suite covering prototype pollution scenarios.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/core/core.js |
Adds sensitive-key filtering in extend to mitigate prototype pollution vectors. |
src/framework/components/element/markup.js |
Refactors tag combination logic to use extend instead of a local merge helper. |
test/framework/components/element/markup-security.test.mjs |
Introduces tests intended to validate prototype pollution protections for markup parsing and extend. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function extend(target, ex) { | ||
| for (const prop in ex) { | ||
| if (prop === '__proto__' || prop === 'constructor' || prop === 'prototype') { | ||
| continue; | ||
| } | ||
|
|
||
| const copy = ex[prop]; | ||
|
|
||
| if (Array.isArray(copy)) { |
There was a problem hiding this comment.
extend iterates with for...in but does not filter inherited enumerable properties. This can copy unexpected keys from ex’s prototype chain and undermines the security hardening (a polluted prototype could inject additional enumerable keys). Consider adding an Object.prototype.hasOwnProperty.call(ex, prop) (or Object.hasOwn) guard inside the loop before copying.
| const result = { }; | ||
| for (let index = 0; index < tags.length; ++index) { | ||
| const tag = tags[index]; | ||
| const tmp = { }; | ||
| tmp[tag.name] = { value: tag.value, attributes: tag.attributes }; | ||
| merge(result, tmp); | ||
| extend(result, tmp); | ||
| } |
There was a problem hiding this comment.
Replacing the local merge with extend changes behavior: extend overwrites nested objects instead of deep-merging into existing ones. For nested tags of the same name, previously attributes/value could be partially overridden while retaining unspecified attributes; with extend, unspecified attributes are dropped. To preserve previous semantics, use a deep-merge implementation here (with the same sensitive-key filtering) or introduce a dedicated safe deep-merge utility instead of extend.
| const symbols = ['[', '_', '_', 'p', 'r', 'o', 't', 'o', '_', '_', ' ', 'p', 'o', 'l', 'l', 'u', 't', 'e', 'd', '=', '"', 't', 'r', 'u', 'e', '"', ']', 'h', 'e', 'l', 'l', 'o']; | ||
|
|
||
| // Ensure Object.prototype is clean before test | ||
| expect({}.polluted).to.be.undefined; | ||
|
|
||
| try { | ||
| Markup.evaluate(symbols); | ||
| } catch (e) { | ||
| // ignore potential errors during evaluation as we only care about pollution | ||
| } | ||
|
|
||
| expect({}.polluted).to.be.undefined; | ||
| }); | ||
|
|
||
| it('should ignore constructor and prototype keys', function () { | ||
| const symbols = ['[', 'c', 'o', 'n', 's', 't', 'r', 'u', 'c', 't', 'o', 'r', ']', 't', 'e', 's', 't']; | ||
|
|
||
| const results = Markup.evaluate(symbols); | ||
| expect(results.tags).to.not.have.property('constructor'); |
There was a problem hiding this comment.
This test uses an unclosed tag ([__proto__ ...]hello), which causes Markup.evaluate to return { tags: null } early and never exercises the tag-merging path (combineTags). To actually validate prototype-pollution protection in merging, make the markup syntactically valid (include a matching closing tag) and assert on the resolved per-symbol tag objects.
| const symbols = ['[', '_', '_', 'p', 'r', 'o', 't', 'o', '_', '_', ' ', 'p', 'o', 'l', 'l', 'u', 't', 'e', 'd', '=', '"', 't', 'r', 'u', 'e', '"', ']', 'h', 'e', 'l', 'l', 'o']; | |
| // Ensure Object.prototype is clean before test | |
| expect({}.polluted).to.be.undefined; | |
| try { | |
| Markup.evaluate(symbols); | |
| } catch (e) { | |
| // ignore potential errors during evaluation as we only care about pollution | |
| } | |
| expect({}.polluted).to.be.undefined; | |
| }); | |
| it('should ignore constructor and prototype keys', function () { | |
| const symbols = ['[', 'c', 'o', 'n', 's', 't', 'r', 'u', 'c', 't', 'o', 'r', ']', 't', 'e', 's', 't']; | |
| const results = Markup.evaluate(symbols); | |
| expect(results.tags).to.not.have.property('constructor'); | |
| const symbols = ['[', '_', '_', 'p', 'r', 'o', 't', 'o', '_', '_', ' ', 'p', 'o', 'l', 'l', 'u', 't', 'e', 'd', '=', '"', 't', 'r', 'u', 'e', '"', ']', 'h', 'e', 'l', 'l', 'o', '[', '/', '_', '_', 'p', 'r', 'o', 't', 'o', '_', '_', ']']; | |
| // Ensure Object.prototype is clean before test | |
| expect({}.polluted).to.be.undefined; | |
| const results = Markup.evaluate(symbols); | |
| expect(results.tags).to.not.equal(null); | |
| for (const tag of results.tags) { | |
| expect(tag).to.not.have.property('__proto__'); | |
| expect(tag).to.not.have.property('polluted'); | |
| } | |
| expect({}.polluted).to.be.undefined; | |
| }); | |
| it('should ignore constructor and prototype keys', function () { | |
| const symbols = ['[', 'c', 'o', 'n', 's', 't', 'r', 'u', 'c', 't', 'o', 'r', ']', 't', 'e', 's', 't', '[', '/', 'c', 'o', 'n', 's', 't', 'r', 'u', 'c', 't', 'o', 'r', ']']; | |
| const results = Markup.evaluate(symbols); | |
| expect(results.tags).to.not.equal(null); | |
| for (const tag of results.tags) { | |
| expect(tag).to.not.have.property('constructor'); | |
| expect(tag).to.not.have.property('prototype'); | |
| } |
| const symbols = ['[', 'c', 'o', 'n', 's', 't', 'r', 'u', 'c', 't', 'o', 'r', ']', 't', 'e', 's', 't']; | ||
|
|
||
| const results = Markup.evaluate(symbols); | ||
| expect(results.tags).to.not.have.property('constructor'); |
There was a problem hiding this comment.
results.tags is an array (or null on failure), so asserting expect(results.tags).to.not.have.property('constructor') is not meaningful and may fail because arrays have a constructor property. Instead, assert that the per-symbol tag entries (e.g. results.tags[i]) do not contain an own key named constructor, and ensure the markup is properly closed so tags isn’t null.
| expect(results.tags).to.not.have.property('constructor'); | |
| expect(results).to.not.be.null; | |
| expect(results.tags).to.be.an('array'); | |
| results.tags.forEach((tag) => { | |
| expect(Object.prototype.hasOwnProperty.call(tag, 'constructor')).to.be.false; | |
| }); |
|
|
||
| extend(target, payload); | ||
|
|
||
| expect({}.vulnerable).to.be.undefined; | ||
| expect(target).to.not.have.property('__proto__'); |
There was a problem hiding this comment.
expect(target).to.not.have.property('__proto__') is unreliable because __proto__ exists on Object.prototype for all normal objects. To validate safety, assert that Object.getPrototypeOf(target) is unchanged and/or that target.vulnerable is undefined (in addition to checking ({}).vulnerable). If you want to assert key absence specifically, use an own-property assertion.
| extend(target, payload); | |
| expect({}.vulnerable).to.be.undefined; | |
| expect(target).to.not.have.property('__proto__'); | |
| const originalPrototype = Object.getPrototypeOf(target); | |
| extend(target, payload); | |
| expect({}.vulnerable).to.be.undefined; | |
| expect(target.vulnerable).to.be.undefined; | |
| expect(Object.getPrototypeOf(target)).to.equal(originalPrototype); |
|
In terms of security, this is not solving anything. This feels as unnecessary introduction of complexity, without solving any particular problem. |
|
at most, we could add an assert, which executes in debug mode and warns used that this is likely unintended, something like |
98287e0 to
9977ece
Compare
|
@mvaligursky @Maksims @LeXXik updated the PR to follow the robustness approach: Key changes:
This ensures the engine is more resilient against malformed markup inputs that could otherwise cause non-obvious crashes like the |
|
Again, what problem this actually solves? |
|
@Maksims while an application author controls their own JS, the A malicious player could send a message containing Preventing property shadowing and prototype pollution here isn't about the author attacking themselves; it's about ensuring a single user cannot trigger a client-side DoS for the entire session. Using |
So the issue is in the parser. Is there a particular parser in the engine, that renders such vulnerability? |
|
Thank you for the example, I can see now where it is coming from. Correct me if I am wrong here. My understanding is that in order to reproduce this, you must enable a text markup on a text element component. The text of the component is then processed by the Alright, first and foremost - never trust third party libraries to handle UGC content properly or at all. It is a responsibility of the developer. Always sanitize the user content you receive before consuming. Always. No exceptions. It is you, who are in control what the engine consumes, text, textures, audio, etc. Pretty much anything the engine receives from you is trusted and assumed as something good to digest. From the engine's perspective, it currently expects the string for the text element component to be clean and sanitized. The fix seem to be simple enough and doesn't touch a hot path, so could as well be added. I'm leaving it for PC team to decide. I still don't think it is a responsibility of the engine and the app crash is due to the missing sanitizing pass in the user code. I'd add the debug assert Martin suggested, though. |
|
@Maksims @LeXXik the core issue this addresses is Practical Robustness against Malicious UGC. In multiplayer or social contexts (leaderboards, chat), an unauthenticated player can send malformed markup (e.g., I've adopted @mvaligursky's suggestion to use |
There is no codepath in the engine that would blindly P.S. |
Yes, it can send it (and why is it unauthenticated if it is in your app? please, stop using llm for replies). But whatever is received by the client is received by your networking code. Then it is your code that is forwarding it to the engine directly, without sanitizing. There is no way a remote client injecting their payload directly into the engine. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/core/core.js
Outdated
| } | ||
|
|
||
| const isForbidden = prop === '__proto__' || prop === 'constructor' || prop === 'prototype'; | ||
| Debug.assert(!isForbidden, `Ignoring forbidden property: ${prop}`); |
There was a problem hiding this comment.
extend now calls Debug.assert when encountering __proto__ / constructor / prototype. Since these keys can legitimately appear in untrusted input (and are intentionally ignored), logging an ASSERT FAILED error can spam logs and may break test/CI setups that treat console errors as failures. Consider silently skipping these keys (or at most using a non-error Debug.warnOnce) instead of asserting.
| Debug.assert(!isForbidden, `Ignoring forbidden property: ${prop}`); |
| function merge(target, source) { | ||
| for (const key in source) { | ||
| if (!source.hasOwnProperty(key)) { | ||
| if (!Object.prototype.hasOwnProperty.call(source, key)) { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
The PR description says the markup parser was refactored to use a shared safe merge/extend utility, but the parser still contains its own merge implementation (and now duplicates the forbidden-key list). To avoid divergence in future security fixes, consider reusing extend (or a shared isForbiddenKey helper) here instead of keeping a separate merge implementation.
|
|
||
| const value = source[key]; | ||
| if (value instanceof Object) { | ||
| if (!target.hasOwnProperty(key)) { |
There was a problem hiding this comment.
merge still uses target.hasOwnProperty(key). This is unsafe because a malicious tag name like hasOwnProperty can overwrite that method (e.g., by creating an own property on target), causing a TypeError on subsequent iterations and turning this into a DoS vector. Use Object.prototype.hasOwnProperty.call(target, key) for the existence check instead.
| if (!target.hasOwnProperty(key)) { | |
| if (!Object.prototype.hasOwnProperty.call(target, key)) { |
| const isForbidden = key === '__proto__' || key === 'constructor' || key === 'prototype'; | ||
| Debug.assert(!isForbidden, `Ignoring forbidden property: ${key}`); | ||
| if (isForbidden) { | ||
| continue; |
There was a problem hiding this comment.
merge uses Debug.assert when forbidden keys are encountered. Because markup comes from user-controlled text, this can generate console errors for malicious/accidental input even though the behavior is to ignore the key. Prefer silently skipping (or Debug.warnOnce) to avoid log spam / noisy ASSERT FAILED output in debug builds.
…itched to Debug.warnOnce for forbidden keys to avoid CI noise, and replaced hasOwnProperty with safe Object.prototype calls across all tagging logic.
45229ba to
c945f17
Compare
|
@Maksims @LeXXik pushed a clean update just now that addresses the technical points. Specifically:
Understand that cleaning input is usually the dev's job, but I feel like having the parser crash with a TypeError from a simple tag name is a low-hanging fruit we should just fix. |
Description
The core
extendutility and the markup parser were found to be vulnerable to prototype pollution. Key sensitive properties like__proto__,constructor, andprototypewere not being filtered during object merging, which could allow malicious inputs to compromise the globalObject.prototype.This change implements a robust check for these keys within
src/core/core.js. Subsequently, the markup parser insrc/framework/components/element/markup.jshas been refactored to utilize this shared safe utility, removing redundant logic and ensuring consistent security across the engine. A comprehensive suite of security tests has been added to verify these protections for both the core utility and the markup parser.Checklist