Merge pull request #81 from lgarron/lgarron/csp-trusted-types

Add `setCSPTrustedTypesPolicy()` for CSP trusted types.
This commit is contained in:
Lucas Garron 2022-11-30 18:23:23 -08:00 коммит произвёл GitHub
Родитель 919b0ea9dd 4aa007545d
Коммит c56996d868
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
3 изменённых файлов: 219 добавлений и 24 удалений

Просмотреть файл

@ -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

Просмотреть файл

@ -1,6 +1,6 @@
interface CachedData {
src: string
data: Promise<string | Error>
data: Promise<string | CSPTrustedHTMLToStringable | Error>
}
const privateData = new WeakMap<IncludeFragmentElement, CachedData>()
@ -8,7 +8,25 @@ 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']
}
@ -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
@ -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 {
@ -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> {
@ -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}})
@ -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 {
@ -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))
@ -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
@ -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

Просмотреть файл

@ -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 = {
@ -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
@ -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 () {
@ -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)
})
})
})