Merge pull request #81 from lgarron/lgarron/csp-trusted-types
Add `setCSPTrustedTypesPolicy()` for CSP trusted types.
This commit is contained in:
Коммит
c56996d868
45
README.md
45
README.md
|
@ -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
|
||||
|
||||
|
|
66
src/index.ts
66
src/index.ts
|
@ -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
|
||||
|
|
132
test/test.js
132
test/test.js
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Загрузка…
Ссылка в новой задаче