diff --git a/.eslintrc.json b/.eslintrc.json
index f726865..c046913 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -7,7 +7,8 @@
"no-invalid-this": "off",
"@typescript-eslint/no-invalid-this": ["error"],
"import/extensions": ["error", "always"],
- "github/no-inner-html": "off"
+ "github/no-inner-html": "off",
+ "@typescript-eslint/no-unused-vars": ["error", { "vars": "all", "argsIgnorePattern": "^_" }]
},
"overrides": [
{
diff --git a/README.md b/README.md
index 4bb8ec2..6a89066 100644
--- a/README.md
+++ b/README.md
@@ -245,3 +245,30 @@ render(html`
${until(request, timeout, loading)}
`)
// ^ renders Loading...
// After 2000ms will render Failed to load
```
+
+### CSP Trusted Types
+
+You can call `TemplateResult.setCSPTrustedTypesPolicy(policy: TrustedTypePolicy | Promise | null)` from JavaScript to set a [CSP trusted types policy](https://web.dev/trusted-types/), which can perform (synchronous) filtering or rejection of the rendered template:
+
+```ts
+import {TemplateResult} from "@github/jtml";
+import DOMPurify from "dompurify"; // Using https://github.com/cure53/DOMPurify
+
+// This policy removes all HTML markup except links.
+const policy = trustedTypes.createPolicy("links-only", {
+ createHTML: (htmlText: string) => {
+ return DOMPurify.sanitize(htmlText, {
+ ALLOWED_TAGS: ["a"],
+ ALLOWED_ATTR: ["href"],
+ RETURN_TRUSTED_TYPE: true,
+ });
+ },
+});
+TemplateResult.setCSPTrustedTypesPolicy(policy);
+```
+
+Note that:
+
+- Only a single policy can be set, shared by all `render` and `unsafeHTML` calls.
+- You should call `TemplateResult.setCSPTrustedTypesPolicy()` ahead of any other call of `@github/jtml` in your code.
+- Not all browsers [support the trusted types API in JavaScript](https://caniuse.com/mdn-api_trustedtypes). You may want to use the [recommended tinyfill](https://github.com/w3c/trusted-types#tinyfill) to construct a policy without causing issues in other browsers.
diff --git a/src/template-result.ts b/src/template-result.ts
index bf8a666..6b17fc7 100644
--- a/src/template-result.ts
+++ b/src/template-result.ts
@@ -4,6 +4,15 @@ import type {TemplateTypeInit} from '@github/template-parts'
const templates = new WeakMap()
const renderedTemplates = new WeakMap()
const renderedTemplateInstances = new WeakMap()
+
+interface CSPTrustedHTMLToStringable {
+ toString: () => string
+}
+
+interface CSPTrustedTypesPolicy {
+ createHTML: (s: string) => CSPTrustedHTMLToStringable
+}
+
export class TemplateResult {
constructor(
public readonly strings: TemplateStringsArray,
@@ -11,13 +20,21 @@ export class TemplateResult {
public processor: TemplateTypeInit
) {}
+ static cspTrustedTypesPolicy: CSPTrustedTypesPolicy | null = null
+
+ static setCSPTrustedTypesPolicy(policy: CSPTrustedTypesPolicy | null) {
+ TemplateResult.cspTrustedTypesPolicy = policy
+ }
+
get template(): HTMLTemplateElement {
if (templates.has(this.strings)) {
return templates.get(this.strings)!
} else {
const template = document.createElement('template')
const end = this.strings.length - 1
- template.innerHTML = this.strings.reduce((str, cur, i) => str + cur + (i < end ? `{{ ${i} }}` : ''), '')
+ const html = this.strings.reduce((str, cur, i) => str + cur + (i < end ? `{{ ${i} }}` : ''), '')
+ const trustedHtml = (TemplateResult.cspTrustedTypesPolicy?.createHTML(html) as string | undefined) ?? html
+ template.innerHTML = trustedHtml
templates.set(this.strings, template)
return template
}
diff --git a/src/trusted-types.ts b/src/trusted-types.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/unsafe-html.ts b/src/unsafe-html.ts
index f35f714..ff757b5 100644
--- a/src/unsafe-html.ts
+++ b/src/unsafe-html.ts
@@ -1,11 +1,13 @@
import {directive} from './directive.js'
import {NodeTemplatePart} from '@github/template-parts'
import type {TemplatePart} from '@github/template-parts'
+import {TemplateResult} from './template-result.js'
export const unsafeHTML = directive((value: string) => (part: TemplatePart) => {
if (!(part instanceof NodeTemplatePart)) return
const template = document.createElement('template')
- template.innerHTML = value
+ const trustedValue = (TemplateResult.cspTrustedTypesPolicy?.createHTML(value) as string | undefined) ?? value
+ template.innerHTML = trustedValue
const fragment = document.importNode(template.content, true)
part.replace(...fragment.childNodes)
})
diff --git a/test/render.ts b/test/render.ts
index 1263a93..519e1e1 100644
--- a/test/render.ts
+++ b/test/render.ts
@@ -1,5 +1,5 @@
import {expect} from 'chai'
-import {html, render} from '../lib/index.js'
+import {html, render, TemplateResult} from '../lib/index.js'
import type {TemplateResult} from '../lib/index.js'
describe('render', () => {
@@ -8,6 +8,10 @@ describe('render', () => {
surface = document.createElement('section')
})
+ afterEach(() => {
+ TemplateResult.setCSPTrustedTypesPolicy(null)
+ })
+
it('memoizes by TemplateResult#template, updating old templates with new values', () => {
const main = (x: string | null = null) => html``
render(main('foo'), surface)
@@ -55,4 +59,21 @@ describe('render', () => {
expect(surface.innerHTML).to.contain('')
})
})
+
+ describe('trusted types', () => {
+ it('respects a Trusted Types Policy if it is set', () => {
+ let policyCalled = false
+ const rewrittenFragment = ''
+ TemplateResult.setCSPTrustedTypesPolicy({
+ createHTML: (_html: string) => {
+ policyCalled = true
+ return rewrittenFragment
+ }
+ })
+ const main = (x: string | null = null) => html``
+ render(main('foo'), surface)
+ expect(surface.innerHTML).to.equal(rewrittenFragment)
+ expect(policyCalled).to.be.true
+ })
+ })
})
diff --git a/test/trusted-types.ts b/test/trusted-types.ts
new file mode 100644
index 0000000..3045d1f
--- /dev/null
+++ b/test/trusted-types.ts
@@ -0,0 +1,19 @@
+import {expect} from 'chai'
+import {TemplateResult} from '../lib/index.js'
+
+describe('trusted types', () => {
+ after(() => {
+ TemplateResult.setCSPTrustedTypesPolicy(null)
+ })
+
+ it('can set a CSP Trusted Types policy', () => {
+ const dummyPolicy = {
+ createHTML: (htmlText: string) => {
+ return htmlText
+ }
+ }
+ expect(TemplateResult.cspTrustedTypesPolicy).to.equal(null)
+ TemplateResult.setCSPTrustedTypesPolicy(dummyPolicy)
+ expect(TemplateResult.cspTrustedTypesPolicy).to.equal(dummyPolicy)
+ })
+})
diff --git a/test/unsafe-html.ts b/test/unsafe-html.ts
index b17db05..997ff8a 100644
--- a/test/unsafe-html.ts
+++ b/test/unsafe-html.ts
@@ -1,7 +1,13 @@
import {expect} from 'chai'
-import {html, render, unsafeHTML} from '../lib/index.js'
+import {html, render, TemplateResult, unsafeHTML} from '../lib/index.js'
describe('unsafeHTML', () => {
+ beforeEach(() => {
+ TemplateResult.setCSPTrustedTypesPolicy(null)
+ })
+ afterEach(() => {
+ TemplateResult.setCSPTrustedTypesPolicy(null)
+ })
it('renders basic text', async () => {
const surface = document.createElement('section')
render(html`${unsafeHTML('Hello World')}
`, surface)
@@ -31,4 +37,18 @@ describe('unsafeHTML', () => {
render(fn('Universe'), surface)
expect(surface.innerHTML).to.equal('')
})
+ it('respects trusted types', async () => {
+ let policyCalled = false
+ const rewrittenFragment = 'This has been rewritten by Trusted Types.
'
+ TemplateResult.setCSPTrustedTypesPolicy({
+ createHTML: (_html: string) => {
+ policyCalled = true
+ return rewrittenFragment
+ }
+ })
+ const surface = document.createElement('section')
+ render(html`${unsafeHTML('HelloWorld')}
`, surface)
+ expect(surface.innerHTML).to.equal(rewrittenFragment)
+ expect(policyCalled).to.be.true
+ })
})