Skip to content

Add setCSPTrustedTypesPolicy() for CSP trusted types. #81

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Dec 1, 2022
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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,51 @@ Deferring the display of markup is typically done in the following usage pattern

- The first time a user visits a page that contains a time-consuming piece of markup to generate, a loading indicator is displayed. When the markup is finished building on the server, it's stored in memcache and sent to the browser to replace the include-fragment loader. Subsequent visits to the page render the cached markup directly, without going through a include-fragment element.

### CSP Trusted Types

You can call `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 `fetch` response before it is inserted into the page:

```ts
import IncludeFragmentElement from "include-fragment-element";
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,
});
},
});
IncludeFragmentElement.setCSPTrustedTypesPolicy(policy);
```

The policy has access to the `fetch` response object. Due to platform constraints, only synchronous information from the response (in addition to the HTML text body) can be used in the policy:

```ts
import IncludeFragmentElement from "include-fragment-element";

const policy = trustedTypes.createPolicy("require-server-header", {
createHTML: (htmlText: string, response: Response) => {
if (response.headers.get("X-Server-Sanitized") !== "sanitized=true") {
// Note: this will reject the contents, but the error may be caught before it shows in the JS console.
throw new Error("Rejecting HTML that was not marked by the server as sanitized.");
}
return htmlText;
},
});
IncludeFragmentElement.setCSPTrustedTypesPolicy(policy);
```

Note that:

- Only a single policy can be set, shared by all `IncludeFragmentElement` fetches.
- You should call `setCSPTrustedTypesPolicy()` ahead of any other load of `include-fragment-element` in your code.
- If your policy itself requires asynchronous work to construct, you can also pass a `Promise<TrustedTypePolicy>`.
- Pass `null` to remove the policy.
- 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.

## Relation to Server Side Includes

Expand Down
66 changes: 50 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
interface CachedData {
src: string
data: Promise<string | Error>
data: Promise<string | CSPTrustedHTMLToStringable | Error>
}
const privateData = new WeakMap<IncludeFragmentElement, CachedData>()

function isWildcard(accept: string | null) {
return accept && !!accept.split(',').find(x => x.match(/^\s*\*\/\*/))
}

// CSP trusted types: We don't want to add `@types/trusted-types` as a
// dependency, so we use the following types as a stand-in.
interface CSPTrustedTypesPolicy {
createHTML: (s: string, response: Response) => CSPTrustedHTMLToStringable
}
// Note: basically every object (and some primitives) in JS satisfy this
// `CSPTrustedHTMLToStringable` interface, but this is the most compatible shape
// we can use.
interface CSPTrustedHTMLToStringable {
toString: () => string
}
let cspTrustedTypesPolicyPromise: Promise<CSPTrustedTypesPolicy> | null = null

export default class IncludeFragmentElement extends HTMLElement {
// Passing `null` clears the policy.
static setCSPTrustedTypesPolicy(policy: CSPTrustedTypesPolicy | Promise<CSPTrustedTypesPolicy> | null): void {
cspTrustedTypesPolicyPromise = policy === null ? policy : Promise.resolve(policy)
}

static get observedAttributes(): string[] {
return ['src', 'loading']
}
Expand Down Expand Up @@ -45,8 +63,10 @@ export default class IncludeFragmentElement extends HTMLElement {
this.setAttribute('accept', val)
}

// We will return string or error for API backwards compatibility. We can consider
// returning TrustedHTML in the future.
get data(): Promise<string | Error> {
return this.#getData()
return this.#getStringOrErrorData()
}

#busy = false
Expand All @@ -67,14 +87,10 @@ export default class IncludeFragmentElement extends HTMLElement {

constructor() {
super()
// eslint-disable-next-line github/no-inner-html
this.attachShadow({mode: 'open'}).innerHTML = `
<style>
:host {
display: block;
}
</style>
<slot></slot>`
const shadowRoot = this.attachShadow({mode: 'open'})
const style = document.createElement('style')
style.textContent = `:host {display: block;}`
shadowRoot.append(style, document.createElement('slot'))
}

connectedCallback(): void {
Expand Down Expand Up @@ -102,7 +118,7 @@ export default class IncludeFragmentElement extends HTMLElement {
}

load(): Promise<string | Error> {
return this.#getData()
return this.#getStringOrErrorData()
}

fetch(request: RequestInfo): Promise<Response> {
Expand Down Expand Up @@ -141,10 +157,14 @@ export default class IncludeFragmentElement extends HTMLElement {
if (data instanceof Error) {
throw data
}
// Until TypeScript is natively compatible with CSP trusted types, we
// have to treat this as a string here.
// https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1246
const dataTreatedAsString = data as string

const template = document.createElement('template')
// eslint-disable-next-line github/no-inner-html
template.innerHTML = data
template.innerHTML = dataTreatedAsString
const fragment = document.importNode(template.content, true)
const canceled = !this.dispatchEvent(
new CustomEvent('include-fragment-replace', {cancelable: true, detail: {fragment}})
Expand All @@ -157,13 +177,13 @@ export default class IncludeFragmentElement extends HTMLElement {
}
}

async #getData(): Promise<string | Error> {
async #getData(): Promise<string | CSPTrustedHTMLToStringable | Error> {
const src = this.src
const cachedData = privateData.get(this)
if (cachedData && cachedData.src === src) {
return cachedData.data
} else {
let data: Promise<string | Error>
let data: Promise<string | CSPTrustedHTMLToStringable | Error>
if (src) {
data = this.#fetchDataWithEvents()
} else {
Expand All @@ -174,6 +194,14 @@ export default class IncludeFragmentElement extends HTMLElement {
}
}

async #getStringOrErrorData(): Promise<string | Error> {
const data = await this.#getData()
if (data instanceof Error) {
return data
}
return data.toString()
}

// Functional stand in for the W3 spec "queue a task" paradigm
async #task(eventsToDispatch: string[]): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
Expand All @@ -182,7 +210,7 @@ export default class IncludeFragmentElement extends HTMLElement {
}
}

async #fetchDataWithEvents(): Promise<string> {
async #fetchDataWithEvents(): Promise<string | CSPTrustedHTMLToStringable> {
// We mimic the same event order as <img>, including the spec
// which states events must be dispatched after "queue a task".
// https://www.w3.org/TR/html52/semantics-embedded-content.html#the-img-element
Expand All @@ -196,7 +224,13 @@ export default class IncludeFragmentElement extends HTMLElement {
if (!isWildcard(this.accept) && (!ct || !ct.includes(this.accept ? this.accept : 'text/html'))) {
throw new Error(`Failed to load resource: expected ${this.accept || 'text/html'} but was ${ct}`)
}
const data = await response.text()

const responseText: string = await response.text()
let data: string | CSPTrustedHTMLToStringable = responseText
if (cspTrustedTypesPolicyPromise) {
const cspTrustedTypesPolicy = await cspTrustedTypesPolicyPromise
data = cspTrustedTypesPolicy.createHTML(responseText, response)
}

// Dispatch `load` and `loadend` async to allow
// the `load()` promise to resolve _before_ these
Expand Down
132 changes: 124 additions & 8 deletions test/test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {assert} from '@open-wc/testing'
import '../src/index.ts'
import {default as IncludeFragmentElement} from '../src/index.ts'

let count
const responses = {
Expand Down Expand Up @@ -32,6 +32,15 @@ const responses = {
}
})
},
'/x-server-sanitized': function () {
return new Response('This response should be marked as sanitized using a custom header!', {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'X-Server-Sanitized': 'sanitized=true'
}
})
},
'/boom': function () {
return new Response('boom', {
status: 500
Expand Down Expand Up @@ -608,13 +617,13 @@ suite('include-fragment-element', function () {
div.hidden = false
}, 0)

return load
.then(() => when(div.firstChild, 'include-fragment-replaced'))
.then(() => {
assert.equal(loadCount, 1, 'Load occured too many times')
assert.equal(document.querySelector('include-fragment'), null)
assert.equal(document.querySelector('#replaced').textContent, 'hello')
})
const replacedPromise = when(div.firstChild, 'include-fragment-replaced')

return load.then(replacedPromise).then(() => {
assert.equal(loadCount, 1, 'Load occured too many times')
assert.equal(document.querySelector('include-fragment'), null)
assert.equal(document.querySelector('#replaced').textContent, 'hello')
})
})

test('include-fragment-replaced is only called once', function () {
Expand All @@ -636,4 +645,111 @@ suite('include-fragment-element', function () {
assert.equal(document.querySelector('#replaced').textContent, 'hello')
})
})

suite('CSP trusted types', () => {
teardown(() => {
IncludeFragmentElement.setCSPTrustedTypesPolicy(null)
})

test('can set a pass-through mock CSP trusted types policy', async function () {
let policyCalled = false
IncludeFragmentElement.setCSPTrustedTypesPolicy({
createHTML: htmlText => {
policyCalled = true
return htmlText
}
})

const el = document.createElement('include-fragment')
el.src = '/hello'

const data = await el.data
assert.equal('<div id="replaced">hello</div>', data)
assert.ok(policyCalled)
})

test('can set and clear a mutating mock CSP trusted types policy', async function () {
let policyCalled = false
IncludeFragmentElement.setCSPTrustedTypesPolicy({
createHTML: () => {
policyCalled = true
return '<b>replacement</b>'
}
})

const el = document.createElement('include-fragment')
el.src = '/hello'
const data = await el.data
assert.equal('<b>replacement</b>', data)
assert.ok(policyCalled)

IncludeFragmentElement.setCSPTrustedTypesPolicy(null)
const el2 = document.createElement('include-fragment')
el2.src = '/hello'
const data2 = await el2.data
assert.equal('<div id="replaced">hello</div>', data2)
})

test('can set a real CSP trusted types policy in Chromium', async function () {
let policyCalled = false
// eslint-disable-next-line no-undef
const policy = globalThis.trustedTypes.createPolicy('test1', {
createHTML: htmlText => {
policyCalled = true
return htmlText
}
})
IncludeFragmentElement.setCSPTrustedTypesPolicy(policy)

const el = document.createElement('include-fragment')
el.src = '/hello'
const data = await el.data
assert.equal('<div id="replaced">hello</div>', data)
assert.ok(policyCalled)
})

test('can reject data using a mock CSP trusted types policy', async function () {
IncludeFragmentElement.setCSPTrustedTypesPolicy({
createHTML: () => {
throw new Error('Rejected data!')
}
})

const el = document.createElement('include-fragment')
el.src = '/hello'
try {
await el.data
assert.ok(false)
} catch (error) {
assert.match(error, /Rejected data!/)
}
})

test('can access headers using a mock CSP trusted types policy', async function () {
IncludeFragmentElement.setCSPTrustedTypesPolicy({
createHTML: (htmlText, response) => {
if (response.headers.get('X-Server-Sanitized') !== 'sanitized=true') {
// Note: this will reject the contents, but the error may be caught before it shows in the JS console.
throw new Error('Rejecting HTML that was not marked by the server as sanitized.')
}
return htmlText
}
})

const el = document.createElement('include-fragment')
el.src = '/hello'
try {
await el.data
assert.ok(false)
} catch (error) {
assert.match(error, /Rejecting HTML that was not marked by the server as sanitized./)
}

const el2 = document.createElement('include-fragment')
el2.src = '/x-server-sanitized'

const data2 = await el2.data
assert.equal('This response should be marked as sanitized using a custom header!', data2)
})
})
})