A proposal for a pair of new directives, tentatively "sensitive"
and "hide source"
, which provide a way for developers to indicate that certain implementation details should not be exposed to other user code. This has benefits for authors of library code who would like to refactor without fear of breaking consumers relying on their implementation details, authors of security-sensitive code, and authors of polyfills, among others.
In practice, the "hide source"
directive hides the source text revealed by Function.prototype.toString
and the file attribution and position information revealed by Error.prototype.stack
. The "sensitive"
directive hides the source text revealed by Function.prototype.toString
and omits the function entirely from Error.prototype.stack
. The "sensitive"
directive is intended to be expanded in the future as new security-impacting information leakages are discovered or added to the language.
This proposal is at stage 2 in the TC39 process, and was last presented to the committee in July, 2019.
Function.prototype.toString
JavaScript's Function.prototype.toString
method reveals the source text originally used to create the function. Revealing the source text of a function gives callers unnecessary insight into its implementation details. They can introspect on a function's implementation and react to it, causing what would otherwise be harmless refactorings to become breaking changes. They can also extract secret values from the source text to compromise an application's attempts at encapsulation.
A historical example of this in action is AngularJS's reliance on f.toString()
to inspect the parameter names of a function. It would then use these as part of its dependency injection framework. This made what was previously a non-breaking change — modifying the name of a parameter — into a breaking one. In particular, this kind of usage of Function.prototype.toString
makes it impossible to use otherwise-semantics-preserving minification tools in combination with this mode of AngularJS.
Another unnecessary insight gained by f.toString()
is how a function was created. Most dramatically, it can be used to detect whether an implementation is "native" or not, by looking for the pattern of [native code]
. This makes high-fidelity polyfilling difficult; indeed, some zealous polyfill libraries have gone as far as to replace Function.prototype.toString
or introduce an own-property polyfillFn.toString()
to prevent detection (1, 2). This is only possible because engines have an implementation-hiding capability which is not available to developers. Finally, f.toString()
can also be used to detect whether the implementation is done via a class
, a function
, an arrow function, a method, etc. It would be preferable if these were mostly-undetectable implementation details, allowing more confidence in refactoring between them.
Error.prototype.stack
JavaScript's (non-standard though de facto) Error.prototype.stack
getter reveals calling behavior in the presence/absence of a stack frame or, in the case of recursive functions, the number of times a particular function appears in a stack trace. If this calling behaviour is dependent upon secret values, whether present in the function source text or just lexically available to the function, it can result in these secrets being partially or fully exposed. Additionally, Error.prototype.stack
reveals position (line/column number) information and file attribution information that restricts refactoring in a similar way to Function.prototype.toString
's exposure of the function source text.
The solution is to provide a way to modify the output of the above functions, to prevent them from exposing implementation details. This is done via the above-described new directives, tentatively "hide source"
and "sensitive"
. Like "use strict"
, they can be applied to either an entire source file or per-function.
Similar to the "use strict"
directive, these new directives apply "inclusively downward", so that everything within the scope, plus the function itself when in function scope, gets hidden. For example:
function foo() {
const x = () => {};
const y = () => {
"hide source";
class Z {
m() {}
}
};
}
In this example, foo
and x
remain unhidden, while y
, Z
, and Z.prototype.m
are considered hidden.
Note: historically, folks have also explored out-of-band, application-level switches for hiding implementation details, often motivated by memory use. See the appendix for more discussion on that.
This proposal draws heavily on the strengths of JavaScript's existing directive prologue support:
"hide source"
will be a no-op in engines that do not implement this proposal.A stack trace:
$ node
Welcome to Node.js v12.13.0.
Type ".help" for more information.
> console.log((new Error).stack)
Error
at repl:1:14
at Script.runInThisContext (vm.js:116:20)
at REPLServer.defaultEval (repl.js:404:29)
at bound (domain.js:420:14)
at REPLServer.runBound [as eval] (domain.js:433:12)
at REPLServer.onLine (repl.js:715:10)
at REPLServer.emit (events.js:215:7)
at REPLServer.EventEmitter.emit (domain.js:476:20)
at REPLServer.Interface._onLine (readline.js:316:10)
at REPLServer.Interface._line (readline.js:693:8)
undefined
>
The same stack trace if bound
was marked as "sensitive"
:
$ node
Welcome to Node.js v12.13.0.
Type ".help" for more information.
> console.log((new Error).stack)
Error
at repl:1:14
at Script.runInThisContext (vm.js:116:20)
at REPLServer.defaultEval (repl.js:404:29)
at REPLServer.runBound [as eval] (domain.js:433:12)
at REPLServer.onLine (repl.js:715:10)
at REPLServer.emit (events.js:215:7)
at REPLServer.EventEmitter.emit (domain.js:476:20)
at REPLServer.Interface._onLine (readline.js:316:10)
at REPLServer.Interface._line (readline.js:693:8)
undefined
>
The same stack trace if bound
was marked as "hide source"
:
$ node
Welcome to Node.js v12.13.0.
Type ".help" for more information.
> console.log((new Error).stack)
Error
at repl:1:14
at Script.runInThisContext (vm.js:116:20)
at REPLServer.defaultEval (repl.js:404:29)
at bound (<anonymous>)
at REPLServer.runBound [as eval] (domain.js:433:12)
at REPLServer.onLine (repl.js:715:10)
at REPLServer.emit (events.js:215:7)
at REPLServer.EventEmitter.emit (domain.js:476:20)
at REPLServer.Interface._onLine (readline.js:316:10)
at REPLServer.Interface._line (readline.js:693:8)
undefined
>
In this alternative, we introduce new functions such as Error.hideFromStackTraces
or Function.prototype.hideSource
, which permanently opt the functions into one of the new hidden behaviours. You'd use it like so:
function foo() {
// ...
}
console.assert(foo.toString().includes("..."));
foo.hideSource();
console.assert(foo.toString() === "function foo() { [ native code ] }");
This alternative seems less good than the directive:
The idea here is similar to the previous one, except that f.hideSource()
returns a new hidden function instead of modifying the function it is called on. The caller then needs to only hand out references to the clone, and not the original.
This suffers from many of the same drawbacks:
and introduces a couple of new ones:
toMethod()
, a proposal from the run up to ES2015. It similarly made function-clones-with-one-tweak, and was rejected by TC39 for its complexity.delete Function.prototype.toString
In the past, people have asked if perhaps engines could just detect the pattern of
delete Function.prototype.toString;
early in a source file, and perform appropriate optimizations.
Unfortunately this does not work in any multi-realm environment:
delete Function.prototype.toString;
function foo() {
// ...
}
const otherGlobal = frames[0];
console.assert(otherGlobal.Function.prototype.toString.call(foo).includes("..."));
It's also a very blunt tool, usable only on the realm level, and thus probably only by application developers. The directive is targeted at library developers; application developers are better served by the out-of-band solution.
This proposal has since been expanded to include hiding of more than just Function.prototype.toString
results, which makes solutions like this even less feasible.
It's tempting, given APIs like Symbol.isConcatSpreadable
or Symbol.iterator
, to think that changing the behavior of an object with regard to some language feature should always be done by installing a well-known symbol onto the object.
This does not work very well for our use case. This approach suffers from all of the same issues as the "one-time hiding function" approach. Additionally, any kind of symbol marking would be reversible: e.g.
function foo() { /* ... */ }
console.assert(foo.toString.includes("..."));
foo[Symbol.hideSource] = true;
console.assert(!foo.toString.includes("..."));
foo[Symbol.hideSource] = false;
console.assert(foo.toString.includes("...")); // oops
This basically makes the hiding toothless, so that you do not gain the desired encapsulation benefits. You could try to patch around it by saying that only setting it to true
works, and setting it to false
or deleting the property does nothing.
Some of the same arguments for hiding source text and stack trace information also apply to a function's name
and length
properties. The language already has a mechanism for hiding these via Object.defineProperty
:
function foo(a, b, c) { }
console.assert(foo.name === "foo");
console.assert(foo.length === 3);
Object.defineProperty(foo, "name", { value: "" });
Object.defineProperty(foo, "length", { value: 0 });
console.assert(foo.name === "");
console.assert(foo.length === 0);
Because it is already possible to do and it would not be universally desirable for the target use cases of the "hide source"
directive, this behaviour is not included with the directive(s). See discussion on this topic in #2.
This proposal, like all JavaScript language proposals, only impacts the behaviour of JavaScript code. Developer tools are outside the scope, and this proposal does not intend to change how they behave in any way. So, functions with their implementation hidden via the proposal's directives can still be inspected by any privileged API, such as those used by devtools, if the API chooses to expose this information.
Concretely, this proposal only impacts two things:
Function.prototype.toString()
.Error.prototype.stack
or other parts of the error stacks proposal.The proposed specification text does not necessarily impact any of the following:
console.log(function(){})
(which doesn't have to be related to the JavaScript-exposed .toString()
result).console.log(new Error)
(which doesn't have to be related to the JavaScript-exposed .stack
result)..stack
result).That said, developer tools teams are not governed by any language specification; they made independent decisions on their user experience. They could at any point decide that code from example.com
gets hidden from devtools, or code that is minified gets hidden from devtools. Or, indeed, they may decide that code whose implementation is hidden from JavaScript via this proposal gets hidden from devtools. That is their decision, and no language specification can impact their UX. All we can say is that the proposal champions hope that devtools continue to make code maximally introspectable.
We don't think so. Unlike "use strict"
, which generally makes your code better, "hide source"
is a specialized mechanism for function authors which need very high levels of encapsulation and freedom to refactor.
Encapsulation is generally a sliding scale. Some library authors are content with underscore-prefixed properties, declaring that if a consumer depends on such properties, they might get broken. Some authors go further and use symbol-keyed properties to create a speed bump. Whereas others use WeakMap
s or private fields, in order to ensure that consumers cannot take a dependency on such implementation details, or probe into secret values stored within a class.
This proposal is in the vein of the latter scenario. It ensures consumers cannot use Function.prototype.toString
or Error.prototype.stack
to create refactoring-hostile dependencies, or to expose secret values. We believe these cases are important enough to deserve a proposal, but not ubiquitous enough to fear a sprinkling of directive prologues throughout every JavaScript file.
Finally, despite historical indications that this directive may provide memory savings (and thus get adoption from everyone who cares about memory, i.e., everyone), those indications have proven false. See the appendix for more on that. Even if the situation changes, we think the out-of-band mechanisms explored in the appendix will be a more attractive mechanism for realizing those memory savings, leaving this proposal focused on the more specialized encapsulation case.
The "sensitive"
directive provides a declarative, textually-bounded opt-in with the end goal of making it more likely for JavaScript programmers of all skill levels to write security-sensitive code without introducing unwanted confidentiality violations. To understand the bounds, we must define which aspects of the program are given a confidentiality guarantee, and which parts of the program are considered "within" the confidentiality boundary. Confidentiality is provided for
"sensitive"
Program code is considered within the confidentiality boundary if and only if it is textually within the confidential region. Code that is evaluated by a direct call to eval
within the confidentiality boundary is also considered to be within the confidentiality boundary.
Note that limitations imposed on code outside of the confidentiality boundary of a confidential region may also apply to code within its confidentiality boundary due to language design/usability constraints. For example, a function marked as "sensitive"
will not appear in stack frames, regardless of whether the code doing the reflection exists within the appropriate confidentiality boundary.
There may be a legitimate need for a function to be declared within the lexical closure of another function that includes a "hide source" directive, but to not inherit the implementation hiding behaviour of its ancestor. Most code should not require this, as the function declaration could be extracted to a function and the bindings it cares about passed in. But for cases where a direct call to eval
is used alongside the kinds of reflection that are disabled by implementation hiding directives, and no assumptions can be made about the contents of the string being evaluated, it is not possible to textually extract the function declaration.
This is a niche use case, and may be evaluated for inclusion in the language in a later proposal.
Historically, there have been a number of ideas, motivations, and proposals in this space. In the January 2018 TC39 meeting, we realized that there were two related proposals:
The idea behind the latter is that in order to allow Function.prototype.toString
to give back the expected results, engines need to hold large amounts of data in memory: namely, the original source text of the application, and all its dependencies. The hope was that by allowing application authors to globally turn off source text storage, the applications would be able to consume less memory.
Since applications are boostrapped by a the host, this would most likely be done with host-specific mechanisms, e.g. a HTTP header, or a <meta>
tag, or a Node.js command line flag. The host would then tie into the JavaScript language specification via HostHasSourceTextAvailable. See previous revisions of this document for exploration of this space.
The conclusion of the January 2018 TC39 meeting was to split these efforts, focusing this proposal on the in-band mechanism, and separately pursuing out-of-band mechanisms with individual host environments. However, shortly afterward discussions with JavaScript engine implementers revealed the basic premise behind the memory-saving mechanism was flawed. In engines that preserve the source text for lazy compilation, a directive to hide the source text from user land code wouldn't, in fact, save memory!
As such, work on the out-of-band memory-saving switch for Function.prototype.toString
has not progressed. It may pick up again, perhaps if engines change their lazy compilation techniques. And in that case, this appendix will be updated to direct you toward those efforts.