From f703f29e08ca4d5bfd3a7290b6fcec6c3f13d5e0 Mon Sep 17 00:00:00 2001 From: Keith Cirkel Date: Tue, 28 Mar 2023 10:39:28 +0100 Subject: [PATCH] add csp trusted types policy --- README.md | 59 ++++++++++++++++++++++++++++++++++++ src/auto-complete-element.ts | 41 +++++++++++++++++++++++-- src/autocomplete.ts | 4 +-- src/send.ts | 33 -------------------- test/test.js | 35 +++++++++++++++++++++ 5 files changed, 134 insertions(+), 38 deletions(-) delete mode 100644 src/send.ts diff --git a/README.md b/README.md index 4731700..9108c71 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,65 @@ completer.addEventListener('auto-complete-change', function(event) { }) ``` +### CSP Trusted Types + +You can call +`setCSPTrustedTypesPolicy(policy: TrustedTypePolicy | Promise | 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 AutoCompleteElement from 'auto-complete-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 + }) + } +}) +AutoCompleteElement.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 AutoCompleteElement from 'auto-complete-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 + } +}) +AutoCompleteElement.setCSPTrustedTypesPolicy(policy) +``` + +Note that: + +- Only a single policy can be set, shared by all `AutoCompleteElement` fetches. +- You should call `setCSPTrustedTypesPolicy()` ahead of any other load of + `auto-complete` element in your code. + - If your policy itself requires asynchronous work to construct, you can also + pass a `Promise`. + - 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. + ## Browser support Browsers without native [custom element support][support] require a [polyfill][]. diff --git a/src/auto-complete-element.ts b/src/auto-complete-element.ts index 2e816fa..13993f1 100644 --- a/src/auto-complete-element.ts +++ b/src/auto-complete-element.ts @@ -1,11 +1,27 @@ import Autocomplete from './autocomplete.js' import AutocompleteEvent from './auto-complete-event.js' -import {fragment} from './send.js' const state = new WeakMap() +export 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 | null = null + // eslint-disable-next-line custom-elements/file-name-matches-element export default class AutocompleteElement extends HTMLElement { + static setCSPTrustedTypesPolicy(policy: CSPTrustedTypesPolicy | Promise | null): void { + cspTrustedTypesPolicyPromise = policy === null ? policy : Promise.resolve(policy) + } + #forElement: HTMLElement | null = null get forElement(): HTMLElement | null { if (this.#forElement?.isConnected) { @@ -87,6 +103,7 @@ export default class AutocompleteElement extends HTMLElement { } } + // HEAD get fetchOnEmpty(): boolean { return this.hasAttribute('fetch-on-empty') } @@ -95,8 +112,26 @@ export default class AutocompleteElement extends HTMLElement { this.toggleAttribute('fetch-on-empty', fetchOnEmpty) } - fetchResult = fragment - + #requestController?: AbortController + async fetchResult(url: URL): Promise { + this.#requestController?.abort() + const {signal} = (this.#requestController = new AbortController()) + const res = await fetch(url.toString(), { + signal, + headers: { + Accept: 'text/fragment+html' + } + }) + if (!res.ok) { + throw new Error(await res.text()) + } + if (cspTrustedTypesPolicyPromise) { + const cspTrustedTypesPolicy = await cspTrustedTypesPolicyPromise + return cspTrustedTypesPolicy.createHTML(await res.text(), res) + } + return await res.text() + } + //f21528e (add csp trusted types policy) static get observedAttributes(): string[] { return ['open', 'value', 'for'] } diff --git a/src/autocomplete.ts b/src/autocomplete.ts index ea26328..c9bcd1e 100644 --- a/src/autocomplete.ts +++ b/src/autocomplete.ts @@ -202,10 +202,10 @@ export default class Autocomplete { this.container.dispatchEvent(new CustomEvent('loadstart')) this.container - .fetchResult(this.input, url.toString()) + .fetchResult(url) .then(html => { // eslint-disable-next-line github/no-inner-html - this.results.innerHTML = html + this.results.innerHTML = html as string this.identifyOptions() const allNewOptions = this.results.querySelectorAll('[role="option"]') const hasResults = !!allNewOptions.length diff --git a/src/send.ts b/src/send.ts deleted file mode 100644 index d16af08..0000000 --- a/src/send.ts +++ /dev/null @@ -1,33 +0,0 @@ -const requests = new WeakMap() - -export function fragment(el: Element, url: string): Promise { - const xhr = new XMLHttpRequest() - xhr.open('GET', url, true) - xhr.setRequestHeader('Accept', 'text/fragment+html') - return request(el, xhr) -} - -function request(el: Element, xhr: XMLHttpRequest): Promise { - const pending = requests.get(el) - if (pending) pending.abort() - requests.set(el, xhr) - - const clear = () => requests.delete(el) - const result = send(xhr) - result.then(clear, clear) - return result -} - -function send(xhr: XMLHttpRequest): Promise { - return new Promise((resolve, reject) => { - xhr.onload = function () { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(xhr.responseText) - } else { - reject(new Error(xhr.responseText)) - } - } - xhr.onerror = reject - xhr.send() - }) -} diff --git a/test/test.js b/test/test.js index 1c1d368..d43859d 100644 --- a/test/test.js +++ b/test/test.js @@ -370,6 +370,41 @@ describe('auto-complete element', function () { assert.equal(5, list.children.length) }) }) + + describe('trustedHTML', () => { + beforeEach(function () { + document.body.innerHTML = ` +
+ + + + + +
+ ` + }) + + it('calls trusted types policy, passing response to it', async function () { + const calls = [] + const html = '
  • replacement
  • ' + window.AutocompleteElement.setCSPTrustedTypesPolicy({ + createHTML(str, res) { + calls.push([str, res]) + return html + } + }) + const container = document.querySelector('auto-complete') + const popup = container.querySelector('#popup') + const input = container.querySelector('input') + + triggerInput(input, 'hub') + await once(container, 'loadend') + + assert.equal(calls.length, 1) + assert.equal(popup.children.length, 1) + assert.equal(popup.innerHTML, html) + }) + }) }) function waitForElementToChange(el) {