Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Ponyfill should not tamper with globals #47

Closed
wants to merge 1 commit into from

Conversation

kriskowal
Copy link
Contributor

@benlesh For your consideration,

Prior to this change, the ponyfill module would also polyfill Symbol.observable. With this change, the ponyfill can be used in environments with immutable primordials.

Please let me know if this change is acceptable, or if I can adjust it to be consistent with this project’s style.

Prior to this change, the ponyfill module would also polyfill Symbol.observable.

With this change, the ponyfill can be used in environments with immutable primordials.
root.Symbol = root.Symbol || function Symbol(description) {
return '@@' + description;
};
root.Symbol.observable = result;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will still fail if Symbol (a primordial) is frozen.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two entry points. This is the polyfill (mutates globals) and the other is a ponyfill (the other module) is the subset that exports its API and does not mutate globals.

@@ -16,4 +16,10 @@ if (typeof self !== 'undefined') {
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not for this PR, but I suggest adding a globalThis branch to the above if-else sequence.

@@ -1,17 +1,23 @@

const observableSymbols = new WeakMap();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the following simpler module accomplish the same thing without reference to root or use of WeakMap?

export default Symbol('observable');

where index.js line 18 would just be

import result from 'ponyfill.js';

If not, I don't understand what we're trying to accomplish.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WeakMap was only necessary to make this work with the unit test, which runs the ponyfill multiple times in the same execution environment. If I were to reframe the tests such that they run the ponyfill only once per process, it would be sufficient to capture the result in a module scope singleton.

Copy link
Owner

@benlesh benlesh Aug 31, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want the same reference to a symbol, then Symbol(description) won't do that, but we don't need the WeakMap. We could use Symbol.for('symbol-observable-ponyfill') or the like, and have a unique, but shared symbol. (So long as Symbol.for exists, which it likely does if Symbol exists).

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know that two evaluations of Symbol('observable') will give two separate symbols, which is not what we want. But (under current conditions) a module has one instance, so all importers should get the same symbol.

Yes, using Symbol.for('observable') would be a more robust way to ensure that there is only one such symbol.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ll take another look at this with an eye for removing the WeakMap and using Symbol.for to ensure idempotence instead.

@@ -16,4 +16,10 @@ if (typeof self !== 'undefined') {
}

var result = ponyfill(root);

root.Symbol = root.Symbol || function Symbol(description) {
return '@@' + description;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only problem I see with this is that it doesn't align with what Symbol does.

With Symbol(description) the description doesn't affect the equality of the result. In other words: Symbol('test') !== Symbol('test')... This is different than Symbol.for, which does what you're expecting this to do.

The result of Symbol must be a completely unique value. The @@ helps, and is an accepted practice. But really, I guess you'd need to do something like:

let _symbolNumber = 0;
function Symbol(description) {
  return `@@_${_symbolNumber++}_${description}`
}

The other depressing bit about this, is you'd have to make sure that description was converted into a valid set of characters for a property name. OR you'd have to just attach the description to the string you returned somehow (although I haven't thought much about how).

Since we're polyfilling Symbol in this case, I guess I'd expect it to behave appropriately (and even possibly implement Symbol.for)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, and the code I’m modifying presumes the existence of Symbol or a Symbol shim as you describe. That being the case, I think it would be sufficient for parity to simply remove this line. Does that track?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could remove that line yeah... However, I'm not sure if there's a runtime that exists where WeakMap exists, but Symbol does not. I'm trying to research that now.

So I have two questions:

  1. Can you elaborate on the problem you're trying to solve?
  2. Can we move the creation of the WeakMap inside of the check for existence of Symbol?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess, if I'm understanding right, in a runtime you're using calling Symbol.observable = ??? will throw an error because Symbol is frozen. Is that correct?

If that's the case, could we just wrap it in a try-catch?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could remove the WeakMap but I would have to make a more invasive change to the test, either by exporting a clearForRetest function or running each test in a fresh process.

Capturing the failed assignment to Symbol.observable is an option. Checking the property descriptor for writability would be another. This is still my recommendation since it clearly separates the polyfill from the ponyfill. I’ve really just moved the assignment to Symbol.observable from the ponyfill to the polyfill. I’ll write up whatever solution you prefer.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if there's a runtime that exists where WeakMap exists, but Symbol does not. I'm trying to research that now.

They were both introduced in ES6, so I would be surprised. Please let us know what you find. I'm curious.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess, if I'm understanding right, in a runtime you're using calling Symbol.observable = ??? will throw an error because Symbol is frozen. Is that correct?

Yes.

If that's the case, could we just wrap it in a try-catch?

And do what in the catch clause? Whatever that is, if it works in that case, why not just do that all the time?

@@ -33,7 +33,6 @@ describe('ponyfill unit tests', function () {

var result = ponyfill(root);

expect(Symbol.observable).to.equal('Symbol(observable)');
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should remove this, as if Symbol exists, we're expecting to have polyfilled it, but if it doesn't exist, we at least have the returned value.

if (typeof Symbol === 'function') {
if (Symbol.observable) {
result = Symbol.observable;
} else {
result = Symbol('observable');
Symbol.observable = result;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, I'm not sure if I understand the goal of removing the polyfilling from this?

Is it that it causes an error in some runtimes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. With the XS runtime and with SES, which both protect client code from prototype pollution, Symbol is a frozen object. My understanding is that the useful distinction between a ponyfill and a polyfill is that the former is useful in such environments and doesn’t require the special treatment of shims.

@kriskowal
Copy link
Contributor Author

I should provide more context: I’m coming with this change request because there is a cryptoeconomic project that would benefit from protection against supply chain attacks. That project largely works under SES, but three of its modules depend on this ponyfill.

@benlesh
Copy link
Owner

benlesh commented Aug 31, 2020

Okay, so I think the "ponyfill" aspect of this is maybe a little bit off... when I inherited this repo from @sindresorhus, the ponyfill would also polyfill if it could: https://github.com/benlesh/symbol-observable/blob/1738bfc9fefef23d310dba3d68e0d83d44253265/index.js

So perhaps that is the source of some of the confusion here. BUT... I think we can solve this in this PR.

  1. We can check for the existence of Symbol.for (which TMK exists in all runtimes that support Symbol) and use that to create symbol that is the same every time this ponyfill is called.
  2. If Symbol (and Symbol.for) does not exist, it returns "@@observable", which because it's a string, is always equal no matter how many times this is imported.
  3. If Symbol is frozen with Object.freeze, I think trying to assign to Symbol.observable does nothing at all, not even an error, so that should be fine, but we could wrap it in a try/catch if we don't think that is safe enough.

Do you think that will solve your issues?

@erights
Copy link

erights commented Sep 1, 2020

Okay, so I think the "ponyfill" aspect of this is maybe a little bit off... when I inherited this repo from @sindresorhus, the ponyfill would also polyfill if it could: https://github.com/benlesh/symbol-observable/blob/1738bfc9fefef23d310dba3d68e0d83d44253265/index.js

So perhaps that is the source of some of the confusion here. BUT... I think we can solve this in this PR.

  1. We can check for the existence of Symbol.for (which TMK exists in all runtimes that support Symbol)

I think so as well, since Symbol.for came in with ES6. Again, please let me know what you find. Symbol.for is indeed the most robust solution to ensuring you're talking about the same symbol.

and use that to create symbol that is the same every time this ponyfill is called.
2. If Symbol (and Symbol.for) does not exist, it returns "@@observable", which because it's a string, is always equal no matter how many times this is imported.

yes.

  1. If Symbol is frozen with Object.freeze, I think trying to assign to Symbol.observable does nothing at all, not even an error,

Assigning to a non-writable property does throw in strict-mode code, and therefore in module code. It only does not throw in sloppy mode code.

so that should be fine, but we could wrap it in a try/catch if we don't think that is safe enough.

The try/catch would work, leaving a system in which the correct symbol is importable but not on Symbol, which is good.

Do you think that will solve your issues?

AFAICT, yes.

@benlesh
Copy link
Owner

benlesh commented Sep 1, 2020

@kriskowal, @erights, please review the following for me?

#48

@kriskowal
Copy link
Contributor Author

I believe the changes in #48 do the job. Thank you!

@kriskowal kriskowal closed this Sep 1, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants