Awesome
Reflect.isTemplateObject (stage 2)
Authors: @mikesamuel, @koto Champions: @littledan, @ljharb Reviewers: @erights, @jridgewell
Provides a way for template tag functions to tell whether they were called with a template string bundle.
Table of Contents
- Use cases & Prior Discussions
- An example
- What this is not
- Possible Spec Language
- Polyfill
- Tests
- Related Work
Use cases & Prior Discussions
Distinguishing strings from a trusted developer from strings that may be attacker controlled
Issue WICG/trusted-types#96 describes a scenario where a template tag assumes that the literal strings were authored by a trusted developer but that the interpolated values may not be.
result = sensitiveOperation`trusted0 ${ untrusted } trusted1`
// Authored by dev ^^^^^^^^ ^^^^^^^^
// May come from outside ^^^^^^^^^
This proposal would provide enough context to warn or error out when this is not the case.
function (trustedStrings, ...untrustedArguments) {
if (Reflect.isTemplateObject(trustedStrings)
// instanceof provides a same-Realm guarantee for early frozen objects.
&& trustedStrings instanceof Array) {
// Proceed knowing that trustedStrings come from
// the JavaScript module's authors.
} else {
// Do not trust trustedStrings
}
}
This assumes that an attacker cannot get a string to eval
or new Function
as in
const attackerControlledString = '((x) => x)`evil string`';
// Naive code
let x = eval(attackerControlledString)
console.log(Reflect.isTemplateObject(x));
Many other security assumptions break if an attacker can execute arbitrary code, so this check is still useful.
An Example
Here's an example of how isTemplateObject
lets a tag function wisely use a sensitive operation, namely Create a Trusted Type.
The sensitive operation is not directly accessible to the tag function's callers since it's in a local scope.
This assumes that TT's first-come-first-serve name restrictions solve provisioning, letting only authorized callers access the sensitive operation.
const { Array, Reflect, TypeError } = globalThis;
const { createPolicy } = trustedTypes;
const { isTemplateObject } = Reflect;
const { error: consoleErr } = console;
/**
* A tag function that produces *TrustedHTML* or null if the
* policy name "trustedHTMLTagFunction" is not available.
*/
export trustedHTML = (() => {
// We use TrustedType's first-come-first-serve policy name restrictions
// to provision this scope with sensitiveOperation.
const policyName = 'trustedHTMLTagFunction';
let policy;
try {
policy = createPolicy(
'trustedHTMLTagFunction',
{ createHTML(s) { return s } }
);
} catch (ex) {
consoleErr(`${policyName} is not an allowed trustedTypes policy name`);
return null;
}
// This is the sensitive operation.
const { createHTML } = policy;
// This tag function uses isTemplateObject to reject strings that
// do not appear in user code in the same realm.
//
// With a reliable isTemplateObject check, the attack surface is
// <= |set of template applications in trusted code|.
//
// That set is finite.
//
// Without a reliable isTemplateObject check, the attack surface is
// <= |set of attacker controlled strings|. That is, in practice,
// unbounded.
//
// This assumes no attacker has eval.
const trustedHTMLTagFunction = (strings) => {
if (isTemplateObject(strings) && strings instanceof Array) {
return createHTML(strings.raw[0]);
}
throw new TypeError("Expected template object");
};
// With the check it's safe to export this tag function that closes
// over a sensitive operation to anyone.
return trustedHTMLTagFunction;
})()
Without isArrayTemplate
, this can be bypassed:
// A naive, but non-malicious function.
function f(x) {
// People trust trustedHTMLTagFunction.
// Our HTML is trustworthy because <bad argument> so we'll just
// piggyback off that by using a value that looks like a template object.
// What could possibly go wrong?
const s = dodgyMarkdownToHTMLConverter(x);
const pseudoTemplateObject = [s];
pseudoTemplateObject.raw = Object.freeze([s]);
return trustedHTML(Object.freeze(pseudoTemplateObject));
}
// An attacker controlled string reaches f().
const payload = '<img onerror=alert(document.origin) src=x>';
console.log(`f(${ JSON.stringify(payload) }) = ${ f(payload) }`);
The threat model here involves three actors:
- A team of first-party developers (in conjunction with security specialists) decides to trust the tag function.
- A malicious attacker controls a string in the variable
payload
. - Non-malicious but confusable third-party library tries to provide a higher level of service by forging a template object.
It assumes its clients are comfortable with trusting
dodgyMarkdownToHTMLConverter
to produce HTML for the current origin.
We've addressed this threat model when the first-party developers can be less tolerant of risk than the most risk tolerant third party dependency w.r.t. HTML injection.
This simple implementation doesn't deal with interpolations. A more thorough implementation could do contextual autoescaping.
What this is not
This is not an attempt to determine whether the current function was called as a template literal.
See the linked issue as to why that is untenable. Especially the discussion around threat models, eval
, and tail-call optimizations that weighed against alternate approaches.
Possible Spec Language
You can browse the ecmarkup output or browse the source.
<!-- ## Polyfill An es-shim API compatible polyfill available at [![npm](https://img.shields.io/npm/v/is-template-object.svg)](https://www.npmjs.com/package/is-template-object). A polyfill is available in the [core-js](https://github.com/zloirock/core-js) library. You can find it in the [ECMAScript proposals section](https://github.com/zloirock/core-js#arrayistemplateobject). -->Tests
The test262 draft tests which would be added under test/built-ins/Reflect
Related Work
If the literals proposal were to advance, this proposal would be unnecessary since they both cover the use cases from this document.