Skip to content

Commit c56996d

Browse files
authored
Merge pull request #81 from lgarron/lgarron/csp-trusted-types
Add `setCSPTrustedTypesPolicy()` for CSP trusted types.
2 parents 919b0ea + 4aa0075 commit c56996d

File tree

3 files changed

+219
-24
lines changed

3 files changed

+219
-24
lines changed

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,51 @@ Deferring the display of markup is typically done in the following usage pattern
100100

101101
- 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.
102102

103+
### CSP Trusted Types
104+
105+
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:
106+
107+
```ts
108+
import IncludeFragmentElement from "include-fragment-element";
109+
import DOMPurify from "dompurify"; // Using https://github.com/cure53/DOMPurify
110+
111+
// This policy removes all HTML markup except links.
112+
const policy = trustedTypes.createPolicy("links-only", {
113+
createHTML: (htmlText: string) => {
114+
return DOMPurify.sanitize(htmlText, {
115+
ALLOWED_TAGS: ["a"],
116+
ALLOWED_ATTR: ["href"],
117+
RETURN_TRUSTED_TYPE: true,
118+
});
119+
},
120+
});
121+
IncludeFragmentElement.setCSPTrustedTypesPolicy(policy);
122+
```
123+
124+
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:
125+
126+
```ts
127+
import IncludeFragmentElement from "include-fragment-element";
128+
129+
const policy = trustedTypes.createPolicy("require-server-header", {
130+
createHTML: (htmlText: string, response: Response) => {
131+
if (response.headers.get("X-Server-Sanitized") !== "sanitized=true") {
132+
// Note: this will reject the contents, but the error may be caught before it shows in the JS console.
133+
throw new Error("Rejecting HTML that was not marked by the server as sanitized.");
134+
}
135+
return htmlText;
136+
},
137+
});
138+
IncludeFragmentElement.setCSPTrustedTypesPolicy(policy);
139+
```
140+
141+
Note that:
142+
143+
- Only a single policy can be set, shared by all `IncludeFragmentElement` fetches.
144+
- You should call `setCSPTrustedTypesPolicy()` ahead of any other load of `include-fragment-element` in your code.
145+
- If your policy itself requires asynchronous work to construct, you can also pass a `Promise<TrustedTypePolicy>`.
146+
- Pass `null` to remove the policy.
147+
- 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.
103148

104149
## Relation to Server Side Includes
105150

src/index.ts

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
11
interface CachedData {
22
src: string
3-
data: Promise<string | Error>
3+
data: Promise<string | CSPTrustedHTMLToStringable | Error>
44
}
55
const privateData = new WeakMap<IncludeFragmentElement, CachedData>()
66

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

11+
// CSP trusted types: We don't want to add `@types/trusted-types` as a
12+
// dependency, so we use the following types as a stand-in.
13+
interface CSPTrustedTypesPolicy {
14+
createHTML: (s: string, response: Response) => CSPTrustedHTMLToStringable
15+
}
16+
// Note: basically every object (and some primitives) in JS satisfy this
17+
// `CSPTrustedHTMLToStringable` interface, but this is the most compatible shape
18+
// we can use.
19+
interface CSPTrustedHTMLToStringable {
20+
toString: () => string
21+
}
22+
let cspTrustedTypesPolicyPromise: Promise<CSPTrustedTypesPolicy> | null = null
23+
1124
export default class IncludeFragmentElement extends HTMLElement {
25+
// Passing `null` clears the policy.
26+
static setCSPTrustedTypesPolicy(policy: CSPTrustedTypesPolicy | Promise<CSPTrustedTypesPolicy> | null): void {
27+
cspTrustedTypesPolicyPromise = policy === null ? policy : Promise.resolve(policy)
28+
}
29+
1230
static get observedAttributes(): string[] {
1331
return ['src', 'loading']
1432
}
@@ -45,8 +63,10 @@ export default class IncludeFragmentElement extends HTMLElement {
4563
this.setAttribute('accept', val)
4664
}
4765

66+
// We will return string or error for API backwards compatibility. We can consider
67+
// returning TrustedHTML in the future.
4868
get data(): Promise<string | Error> {
49-
return this.#getData()
69+
return this.#getStringOrErrorData()
5070
}
5171

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

6888
constructor() {
6989
super()
70-
// eslint-disable-next-line github/no-inner-html
71-
this.attachShadow({mode: 'open'}).innerHTML = `
72-
<style>
73-
:host {
74-
display: block;
75-
}
76-
</style>
77-
<slot></slot>`
90+
const shadowRoot = this.attachShadow({mode: 'open'})
91+
const style = document.createElement('style')
92+
style.textContent = `:host {display: block;}`
93+
shadowRoot.append(style, document.createElement('slot'))
7894
}
7995

8096
connectedCallback(): void {
@@ -102,7 +118,7 @@ export default class IncludeFragmentElement extends HTMLElement {
102118
}
103119

104120
load(): Promise<string | Error> {
105-
return this.#getData()
121+
return this.#getStringOrErrorData()
106122
}
107123

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

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

160-
async #getData(): Promise<string | Error> {
180+
async #getData(): Promise<string | CSPTrustedHTMLToStringable | Error> {
161181
const src = this.src
162182
const cachedData = privateData.get(this)
163183
if (cachedData && cachedData.src === src) {
164184
return cachedData.data
165185
} else {
166-
let data: Promise<string | Error>
186+
let data: Promise<string | CSPTrustedHTMLToStringable | Error>
167187
if (src) {
168188
data = this.#fetchDataWithEvents()
169189
} else {
@@ -174,6 +194,14 @@ export default class IncludeFragmentElement extends HTMLElement {
174194
}
175195
}
176196

197+
async #getStringOrErrorData(): Promise<string | Error> {
198+
const data = await this.#getData()
199+
if (data instanceof Error) {
200+
return data
201+
}
202+
return data.toString()
203+
}
204+
177205
// Functional stand in for the W3 spec "queue a task" paradigm
178206
async #task(eventsToDispatch: string[]): Promise<void> {
179207
await new Promise(resolve => setTimeout(resolve, 0))
@@ -182,7 +210,7 @@ export default class IncludeFragmentElement extends HTMLElement {
182210
}
183211
}
184212

185-
async #fetchDataWithEvents(): Promise<string> {
213+
async #fetchDataWithEvents(): Promise<string | CSPTrustedHTMLToStringable> {
186214
// We mimic the same event order as <img>, including the spec
187215
// which states events must be dispatched after "queue a task".
188216
// https://www.w3.org/TR/html52/semantics-embedded-content.html#the-img-element
@@ -196,7 +224,13 @@ export default class IncludeFragmentElement extends HTMLElement {
196224
if (!isWildcard(this.accept) && (!ct || !ct.includes(this.accept ? this.accept : 'text/html'))) {
197225
throw new Error(`Failed to load resource: expected ${this.accept || 'text/html'} but was ${ct}`)
198226
}
199-
const data = await response.text()
227+
228+
const responseText: string = await response.text()
229+
let data: string | CSPTrustedHTMLToStringable = responseText
230+
if (cspTrustedTypesPolicyPromise) {
231+
const cspTrustedTypesPolicy = await cspTrustedTypesPolicyPromise
232+
data = cspTrustedTypesPolicy.createHTML(responseText, response)
233+
}
200234

201235
// Dispatch `load` and `loadend` async to allow
202236
// the `load()` promise to resolve _before_ these

test/test.js

Lines changed: 124 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {assert} from '@open-wc/testing'
2-
import '../src/index.ts'
2+
import {default as IncludeFragmentElement} from '../src/index.ts'
33

44
let count
55
const responses = {
@@ -32,6 +32,15 @@ const responses = {
3232
}
3333
})
3434
},
35+
'/x-server-sanitized': function () {
36+
return new Response('This response should be marked as sanitized using a custom header!', {
37+
status: 200,
38+
headers: {
39+
'Content-Type': 'text/html; charset=utf-8',
40+
'X-Server-Sanitized': 'sanitized=true'
41+
}
42+
})
43+
},
3544
'/boom': function () {
3645
return new Response('boom', {
3746
status: 500
@@ -608,13 +617,13 @@ suite('include-fragment-element', function () {
608617
div.hidden = false
609618
}, 0)
610619

611-
return load
612-
.then(() => when(div.firstChild, 'include-fragment-replaced'))
613-
.then(() => {
614-
assert.equal(loadCount, 1, 'Load occured too many times')
615-
assert.equal(document.querySelector('include-fragment'), null)
616-
assert.equal(document.querySelector('#replaced').textContent, 'hello')
617-
})
620+
const replacedPromise = when(div.firstChild, 'include-fragment-replaced')
621+
622+
return load.then(replacedPromise).then(() => {
623+
assert.equal(loadCount, 1, 'Load occured too many times')
624+
assert.equal(document.querySelector('include-fragment'), null)
625+
assert.equal(document.querySelector('#replaced').textContent, 'hello')
626+
})
618627
})
619628

620629
test('include-fragment-replaced is only called once', function () {
@@ -636,4 +645,111 @@ suite('include-fragment-element', function () {
636645
assert.equal(document.querySelector('#replaced').textContent, 'hello')
637646
})
638647
})
648+
649+
suite('CSP trusted types', () => {
650+
teardown(() => {
651+
IncludeFragmentElement.setCSPTrustedTypesPolicy(null)
652+
})
653+
654+
test('can set a pass-through mock CSP trusted types policy', async function () {
655+
let policyCalled = false
656+
IncludeFragmentElement.setCSPTrustedTypesPolicy({
657+
createHTML: htmlText => {
658+
policyCalled = true
659+
return htmlText
660+
}
661+
})
662+
663+
const el = document.createElement('include-fragment')
664+
el.src = '/hello'
665+
666+
const data = await el.data
667+
assert.equal('<div id="replaced">hello</div>', data)
668+
assert.ok(policyCalled)
669+
})
670+
671+
test('can set and clear a mutating mock CSP trusted types policy', async function () {
672+
let policyCalled = false
673+
IncludeFragmentElement.setCSPTrustedTypesPolicy({
674+
createHTML: () => {
675+
policyCalled = true
676+
return '<b>replacement</b>'
677+
}
678+
})
679+
680+
const el = document.createElement('include-fragment')
681+
el.src = '/hello'
682+
const data = await el.data
683+
assert.equal('<b>replacement</b>', data)
684+
assert.ok(policyCalled)
685+
686+
IncludeFragmentElement.setCSPTrustedTypesPolicy(null)
687+
const el2 = document.createElement('include-fragment')
688+
el2.src = '/hello'
689+
const data2 = await el2.data
690+
assert.equal('<div id="replaced">hello</div>', data2)
691+
})
692+
693+
test('can set a real CSP trusted types policy in Chromium', async function () {
694+
let policyCalled = false
695+
// eslint-disable-next-line no-undef
696+
const policy = globalThis.trustedTypes.createPolicy('test1', {
697+
createHTML: htmlText => {
698+
policyCalled = true
699+
return htmlText
700+
}
701+
})
702+
IncludeFragmentElement.setCSPTrustedTypesPolicy(policy)
703+
704+
const el = document.createElement('include-fragment')
705+
el.src = '/hello'
706+
const data = await el.data
707+
assert.equal('<div id="replaced">hello</div>', data)
708+
assert.ok(policyCalled)
709+
})
710+
711+
test('can reject data using a mock CSP trusted types policy', async function () {
712+
IncludeFragmentElement.setCSPTrustedTypesPolicy({
713+
createHTML: () => {
714+
throw new Error('Rejected data!')
715+
}
716+
})
717+
718+
const el = document.createElement('include-fragment')
719+
el.src = '/hello'
720+
try {
721+
await el.data
722+
assert.ok(false)
723+
} catch (error) {
724+
assert.match(error, /Rejected data!/)
725+
}
726+
})
727+
728+
test('can access headers using a mock CSP trusted types policy', async function () {
729+
IncludeFragmentElement.setCSPTrustedTypesPolicy({
730+
createHTML: (htmlText, response) => {
731+
if (response.headers.get('X-Server-Sanitized') !== 'sanitized=true') {
732+
// Note: this will reject the contents, but the error may be caught before it shows in the JS console.
733+
throw new Error('Rejecting HTML that was not marked by the server as sanitized.')
734+
}
735+
return htmlText
736+
}
737+
})
738+
739+
const el = document.createElement('include-fragment')
740+
el.src = '/hello'
741+
try {
742+
await el.data
743+
assert.ok(false)
744+
} catch (error) {
745+
assert.match(error, /Rejecting HTML that was not marked by the server as sanitized./)
746+
}
747+
748+
const el2 = document.createElement('include-fragment')
749+
el2.src = '/x-server-sanitized'
750+
751+
const data2 = await el2.data
752+
assert.equal('This response should be marked as sanitized using a custom header!', data2)
753+
})
754+
})
639755
})

0 commit comments

Comments
 (0)