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('
HelloUniverse
') }) + 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 + }) })