Skip to content
This repository was archived by the owner on Jan 9, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,30 @@ render(html`<div>${until(request, timeout, loading)}</div>`)
// ^ renders <div>Loading...</div>
// After 2000ms will render <div>Failed to load</div>
```

### CSP Trusted Types

You can call `TemplateResult.setCSPTrustedTypesPolicy(policy: TrustedTypePolicy | Promise<TrustedTypePolicy> | 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.
19 changes: 18 additions & 1 deletion src/template-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,37 @@ import type {TemplateTypeInit} from '@github/template-parts'
const templates = new WeakMap<TemplateStringsArray, HTMLTemplateElement>()
const renderedTemplates = new WeakMap<Node | NodeTemplatePart, HTMLTemplateElement>()
const renderedTemplateInstances = new WeakMap<Node | NodeTemplatePart, TemplateInstance>()

interface CSPTrustedHTMLToStringable {
toString: () => string
}

interface CSPTrustedTypesPolicy {
createHTML: (s: string) => CSPTrustedHTMLToStringable
}

export class TemplateResult {
constructor(
public readonly strings: TemplateStringsArray,
public readonly values: unknown[],
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
}
Expand Down
Empty file added src/trusted-types.ts
Empty file.
4 changes: 3 additions & 1 deletion src/unsafe-html.ts
Original file line number Diff line number Diff line change
@@ -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)
})
23 changes: 22 additions & 1 deletion test/render.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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`<div class="${x}"></div>`
render(main('foo'), surface)
Expand Down Expand Up @@ -55,4 +59,21 @@ describe('render', () => {
expect(surface.innerHTML).to.contain('<div><span><div></div></span><span><div></div></span></div>')
})
})

describe('trusted types', () => {
it('respects a Trusted Types Policy if it is set', () => {
let policyCalled = false
const rewrittenFragment = '<div id="bar"></div>'
TemplateResult.setCSPTrustedTypesPolicy({
createHTML: (_html: string) => {
policyCalled = true
return rewrittenFragment
}
})
const main = (x: string | null = null) => html`<div class="${x}"></div>`
render(main('foo'), surface)
expect(surface.innerHTML).to.equal(rewrittenFragment)
expect(policyCalled).to.be.true
})
})
})
19 changes: 19 additions & 0 deletions test/trusted-types.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
22 changes: 21 additions & 1 deletion test/unsafe-html.ts
Original file line number Diff line number Diff line change
@@ -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`<div>${unsafeHTML('Hello World')}</div>`, surface)
Expand Down Expand Up @@ -31,4 +37,18 @@ describe('unsafeHTML', () => {
render(fn('<a href="">Universe</a>'), surface)
expect(surface.innerHTML).to.equal('<div><span>Hello</span><span><a href="">Universe</a></span></div>')
})
it('respects trusted types', async () => {
let policyCalled = false
const rewrittenFragment = '<div id="bar">This has been rewritten by Trusted Types.</div>'
TemplateResult.setCSPTrustedTypesPolicy({
createHTML: (_html: string) => {
policyCalled = true
return rewrittenFragment
}
})
const surface = document.createElement('section')
render(html`<div>${unsafeHTML('<span>Hello</span><span>World</span>')}</div>`, surface)
expect(surface.innerHTML).to.equal(rewrittenFragment)
expect(policyCalled).to.be.true
})
})