Skip to content

Commit 8eb81da

Browse files
committed
Add setCSPTrustedTypesCallback for CSP trusted types.
[CSP trusted types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types) is an API that allows a website to reduce the possibility of XSS by controlling what kind of content can be placed in a "sink" like `.innerHTML`. This commit introduces a flexible callback that allows the calling code to provide its own validation or rejection of an server response for an `<include-fragment-element>`. For example, the site may want to allow the server to send a header to assert that certain HTML is sanitized and safe to use as-is, or the site may want to run the response through a sanitizer. Here is a snippet that looks for such a header and falls back to the `dompurify` library for extremely basic sanitization. ```ts import { setCSPTrustedTypesCallback } from "include-fragment-element"; import { default as DOMPurify } from "dompurify"; const policy = trustedTypes.createPolicy("server-sanitized", { createHTML: (s) => s }); setCSPTrustedTypesCallback(async (r: Response) => { if (r.headers.get("X-Response-Trusted-Types")?.split(",").includes("server-sanitized=true")) { return policy.createHTML(r.text()); } return DOMPurify.sanitize(await r.text(), { RETURN_TRUSTED_TYPE: true }); }); ```
1 parent 46d0d7d commit 8eb81da

File tree

2 files changed

+44
-15
lines changed

2 files changed

+44
-15
lines changed

src/index.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
import {CSPTrustedHTMLToStringable, CSPTrustedTypesPolicy} from './trusted-types'
2+
13
const privateData = new WeakMap()
24

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

9+
let cspTrustedTypesPolicy: Promise<CSPTrustedTypesPolicy> | null = null
10+
export function setCSPTrusedTypesPolicy(policy: CSPTrustedTypesPolicy | Promise<CSPTrustedTypesPolicy>): void {
11+
cspTrustedTypesPolicy = Promise.resolve(policy)
12+
}
13+
714
export default class IncludeFragmentElement extends HTMLElement {
815
static get observedAttributes(): string[] {
916
return ['src', 'loading']
@@ -41,8 +48,9 @@ export default class IncludeFragmentElement extends HTMLElement {
4148
this.setAttribute('accept', val)
4249
}
4350

51+
// TODO: Should this return a TrustedHTML if available, or always a string?
4452
get data(): Promise<string> {
45-
return this.#getData()
53+
return this.#getStringData()
4654
}
4755

4856
#busy = false
@@ -63,14 +71,10 @@ export default class IncludeFragmentElement extends HTMLElement {
6371

6472
constructor() {
6573
super()
66-
// eslint-disable-next-line github/no-inner-html
67-
this.attachShadow({mode: 'open'}).innerHTML = `
68-
<style>
69-
:host {
70-
display: block;
71-
}
72-
</style>
73-
<slot></slot>`
74+
const shadowRoot = this.attachShadow({mode: 'open'})
75+
const style = shadowRoot.appendChild(document.createElement('style'))
76+
style.textContent = `:host {display: block;}`
77+
style.appendChild(document.createElement('slot'))
7478
}
7579

7680
connectedCallback(): void {
@@ -97,8 +101,9 @@ export default class IncludeFragmentElement extends HTMLElement {
97101
})
98102
}
99103

104+
// TODO: Should this return `this.#getData()` directly?
100105
load(): Promise<string> {
101-
return this.#getData()
106+
return this.#getStringData()
102107
}
103108

104109
fetch(request: RequestInfo): Promise<Response> {
@@ -134,10 +139,14 @@ export default class IncludeFragmentElement extends HTMLElement {
134139
this.#observer.unobserve(this)
135140
try {
136141
const html = await this.#getData()
137-
142+
// Until TypeScript is natively compatible with CSP trusted types, we
143+
// have to treat this as a string here.
144+
// https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1246
145+
const htmlTreatedAsString = html as string
146+
138147
const template = document.createElement('template')
139148
// eslint-disable-next-line github/no-inner-html
140-
template.innerHTML = html
149+
template.innerHTML = htmlTreatedAsString
141150
const fragment = document.importNode(template.content, true)
142151
const canceled = !this.dispatchEvent(
143152
new CustomEvent('include-fragment-replace', {cancelable: true, detail: {fragment}})
@@ -150,7 +159,7 @@ export default class IncludeFragmentElement extends HTMLElement {
150159
}
151160
}
152161

153-
#getData(): Promise<string> {
162+
#getData(): Promise<string | CSPTrustedHTMLToStringable> {
154163
const src = this.src
155164
let data = privateData.get(this)
156165
if (data && data.src === src) {
@@ -166,6 +175,10 @@ export default class IncludeFragmentElement extends HTMLElement {
166175
}
167176
}
168177

178+
async #getStringData(): Promise<string> {
179+
return (await this.#getStringData()).toString()
180+
}
181+
169182
// Functional stand in for the W3 spec "queue a task" paradigm
170183
async #task(eventsToDispatch: string[]): Promise<void> {
171184
await new Promise(resolve => setTimeout(resolve, 0))
@@ -174,7 +187,7 @@ export default class IncludeFragmentElement extends HTMLElement {
174187
}
175188
}
176189

177-
async #fetchDataWithEvents(): Promise<string> {
190+
async #fetchDataWithEvents(): Promise<string | CSPTrustedHTMLToStringable> {
178191
// We mimic the same event order as <img>, including the spec
179192
// which states events must be dispatched after "queue a task".
180193
// https://www.w3.org/TR/html52/semantics-embedded-content.html#the-img-element
@@ -188,7 +201,14 @@ export default class IncludeFragmentElement extends HTMLElement {
188201
if (!isWildcard(this.accept) && (!ct || !ct.includes(this.accept ? this.accept : 'text/html'))) {
189202
throw new Error(`Failed to load resource: expected ${this.accept || 'text/html'} but was ${ct}`)
190203
}
191-
const data = await response.text()
204+
205+
let responseText : string = await response.text()
206+
let data: string | CSPTrustedHTMLToStringable = responseText;
207+
if (cspTrustedTypesPolicy) {
208+
data = await cspTrustedTypesPolicy.then(policy =>
209+
policy.createHTML(responseText, response)
210+
)
211+
}
192212

193213
try {
194214
// Dispatch `load` and `loadend` async to allow

src/trusted-types.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// We don't want to add `@types/trusted-types` as a dependency, so we use this stand-in.
2+
3+
export interface CSPTrustedHTMLToStringable {
4+
toString: () => string
5+
}
6+
7+
export interface CSPTrustedTypesPolicy {
8+
createHTML: (s: string, response: Response) => CSPTrustedHTMLToStringable
9+
}

0 commit comments

Comments
 (0)