From 1b9a730d5e157b7569310e145024a24a951fd902 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Tue, 23 Jun 2026 09:03:19 +0200 Subject: [PATCH 1/2] spike(security): wire Trusted Types report-only + default policy for search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worked-example spike for /spec/security/trusted-types/. Pagefind's search UI (pagefind-ui.js) builds its results list with innerHTML — 11 assignments, the only TrustedHTML sink on the site (our own scripts use none). So enforcing require-trusted-types-for would break search unless a default policy covers it; a default policy is the only option because Pagefind's bundled code assigns raw strings and can't opt into a named policy. - Vendor DOMPurify 3.4.11 as a self-hosted first-party asset (script-src 'self'), copied from node_modules by scripts/generate-assets.mjs at prebuild. - public/trusted-types-policy.js registers the `default` policy backed by DOMPurify (strips scripts/handlers, keeps Pagefind's a/p/mark/list markup). Loaded first in so it exists before Pagefind mounts; fails closed and loud if DOMPurify is missing rather than registering a pass-through. - _headers: add Content-Security-Policy-Report-Only with require-trusted-types-for 'script'; trusted-types default, reporting to the existing /reports collector. Report-only is non-blocking — safe in prod, and measures real violations. - Ignore the vendored minified lib in eslint/prettier. To enforce: drop the `-Report-Only` suffix once reports are clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .prettierignore | 1 + eslint.config.js | 1 + package-lock.json | 17 +++++++++++++++ package.json | 1 + public/_headers | 1 + public/trusted-types-policy.js | 38 ++++++++++++++++++++++++++++++++++ public/vendor/purify.min.js | 3 +++ scripts/generate-assets.mjs | 15 ++++++++++++++ src/layouts/BaseLayout.astro | 8 +++++++ 9 files changed, 85 insertions(+) create mode 100644 public/trusted-types-policy.js create mode 100644 public/vendor/purify.min.js diff --git a/.prettierignore b/.prettierignore index f544dff9..ab5b55e0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,7 @@ dist/ .astro/ node_modules/ public/pagefind/ +public/vendor/ # Generated binary/text assets public/og/ diff --git a/eslint.config.js b/eslint.config.js index 2cec3043..b88061ee 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,7 @@ export default [ ".astro/", "node_modules/", "public/pagefind/", + "public/vendor/", "mcp/", ".github/skills/", ".claude/", diff --git a/package-lock.json b/package-lock.json index 31772d02..cdae15a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@astrojs/rss": "^4.0.18", "@tailwindcss/vite": "^4.3.1", "astro": "^6.4.6", + "dompurify": "^3.4.11", "tailwindcss": "^4.3.0" }, "devDependencies": { @@ -2673,6 +2674,13 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4337,6 +4345,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", diff --git a/package.json b/package.json index 065601ed..3240cc39 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@astrojs/rss": "^4.0.18", "@tailwindcss/vite": "^4.3.1", "astro": "^6.4.6", + "dompurify": "^3.4.11", "tailwindcss": "^4.3.0" }, "devDependencies": { diff --git a/public/_headers b/public/_headers index 03e37364..03114f41 100644 --- a/public/_headers +++ b/public/_headers @@ -16,6 +16,7 @@ Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=() Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' 'sha256-N74AzU+1FxvXAWIxrP2zNCBUxV949ZHOXXqjTvbusx0=' 'sha256-UU9xsfeOKmx3D7Lk33alkWn1rIjk46pD684u4pupy4o=' https://plausible.io; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://plausible.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; report-to csp-endpoint; upgrade-insecure-requests + Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; trusted-types default; report-to csp-endpoint Reporting-Endpoints: csp-endpoint="/reports", default="/reports" Cross-Origin-Opener-Policy: same-origin Cross-Origin-Resource-Policy: same-site diff --git a/public/trusted-types-policy.js b/public/trusted-types-policy.js new file mode 100644 index 00000000..25af46a6 --- /dev/null +++ b/public/trusted-types-policy.js @@ -0,0 +1,38 @@ +// Trusted Types default policy. +// +// Worked example for /spec/security/trusted-types/. The site's CSP can carry +// require-trusted-types-for 'script'; trusted-types default +// and, once enforcing, every string assigned to a DOM injection sink (innerHTML, +// outerHTML, document.write, …) must be a TrustedHTML value or the browser throws. +// +// Our own scripts never touch those sinks, but the Pagefind search UI +// (/pagefind/pagefind-ui.js) builds its entire results list with innerHTML — 11 +// assignments — so without a policy, enforcing Trusted Types would break search. +// A *default* policy is the only thing that can cover Pagefind, because its +// bundled code assigns raw strings and cannot opt into a named policy itself. +// +// The policy runs every such string through DOMPurify, which strips scripts and +// event handlers while preserving the structural markup Pagefind emits +// (,

, highlights, lists). Loaded before any other script in +// so the policy exists before Pagefind mounts. +(function () { + if (!window.trustedTypes || !window.trustedTypes.createPolicy) return; // unsupported browser: nothing to enforce + if (typeof window.DOMPurify === "undefined") { + // Fail closed and loud rather than registering an unsafe pass-through. + console.error( + "[trusted-types] DOMPurify not loaded; default policy not registered.", + ); + return; + } + try { + window.trustedTypes.createPolicy("default", { + createHTML: function (input) { + return window.DOMPurify.sanitize(input); + }, + }); + } catch (e) { + // createPolicy throws if a "default" policy already exists or the name is + // not in the trusted-types allowlist. Never break the page over it. + console.error("[trusted-types] default policy registration failed:", e); + } +})(); diff --git a/public/vendor/purify.min.js b/public/vendor/purify.min.js new file mode 100644 index 00000000..14c5715d --- /dev/null +++ b/public/vendor/purify.min.js @@ -0,0 +1,3 @@ +/*! @license DOMPurify 3.4.11 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.11/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,function(){"use strict";function e(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,o=Array(t);n2?n-2:0),r=2;r1?t-1:0),o=1;o1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:b;if(o&&o(e,null),!T(t))return e;let i=t.length;for(;i--;){let o=t[i];if("string"==typeof o){const e=n(o);e!==o&&(r(t)||(t[i]=e),o=e)}e[o]=!0}return e}function z(e){for(let t=0;t/g),J=c(/\${[\w\W]*/g),Q=c(/^data-[\-\w.\u00B7-\uFFFF]+$/),ee=c(/^aria-[\-\w]+$/),te=c(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),ne=c(/^(?:\w+script|data):/i),oe=c(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),re=c(/^html$/i),ie=c(/^[a-z][.\w]*(-[.\w]+)+$/i),ae=c(/<[/\w!]/g),le=c(/<[/\w]/g),ce=c(/<\/no(script|embed|frames)/i),se=c(/\/>/i),ue=1,fe=3,pe=7,me=8,de=9,he=11,ge=function(){return"undefined"==typeof window?null:window},ye=function(e,t,n,o){return R(e,t)&&T(e[t])?M(o.base?P(o.base):{},e[t],o.transform):n};var Te=function e(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:ge();const o=t=>e(t);if(o.version="3.4.11",o.removed=[],!t||!t.document||t.document.nodeType!==de||!t.Element)return o.isSupported=!1,o;let r=t.document;const i=r,a=i.currentScript;t.DocumentFragment;const u=t.HTMLTemplateElement,f=t.Node,p=t.Element,x=t.NodeFilter,L=t.NamedNodeMap;void 0===L&&(t.NamedNodeMap||t.MozNamedAttrMap),t.HTMLFormElement;const z=t.DOMParser,Te=t.trustedTypes,be=p.prototype,Se=U(be,"cloneNode"),Ee=U(be,"remove"),Ae=U(be,"nextSibling"),Ne=U(be,"childNodes"),_e=U(be,"parentNode"),we=U(be,"shadowRoot"),Oe=U(be,"attributes"),ve=f&&f.prototype?U(f.prototype,"nodeType"):null,De=f&&f.prototype?U(f.prototype,"nodeName"):null;if("function"==typeof u){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let Re,Ce,Ie="",ke=!1,xe=0;const Le=function(){if(xe>0)throw k('A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.')},Me=function(e){Le(),xe++;try{return Re.createHTML(e)}finally{xe--}},ze=function(){return ke||(Ce=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(Te,a),ke=!0),Ce},Pe=r,Ue=Pe.implementation,Fe=Pe.createNodeIterator,He=Pe.createDocumentFragment,je=Pe.getElementsByTagName,Be=i.importNode;let Ge={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof n&&"function"==typeof _e&&Ue&&void 0!==Ue.createHTMLDocument;const We=V,Ye=Z,qe=J,Xe=Q,$e=ee,Ke=ne,Ve=oe,Ze=ie;let Je=te,Qe=null;const et=M({},[...F,...H,...j,...G,...Y]);let tt=null;const nt=M({},[...q,...X,...$,...K]);let ot=Object.seal(s(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),rt=null,it=null;const at=Object.seal(s(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let lt=!0,ct=!0,st=!1,ut=!0,ft=!1,pt=!0,mt=!1,dt=!1,ht=null,gt=null,yt=!1,Tt=!1,bt=!1,St=!1,Et=!0,At=!1;const Nt="user-content-";let _t=!0,wt=!1,Ot={},vt=null;const Dt=M({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","selectedcontent","style","svg","template","thead","title","video","xmp"]);let Rt=null;const Ct=M({},["audio","video","img","source","image","track"]);let It=null;const kt=M({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),xt="http://www.w3.org/1998/Math/MathML",Lt="http://www.w3.org/2000/svg",Mt="http://www.w3.org/1999/xhtml";let zt=Mt,Pt=!1,Ut=null;const Ft=M({},[xt,Lt,Mt],S),Ht=l(["mi","mo","mn","ms","mtext"]);let jt=M({},Ht);const Bt=l(["annotation-xml"]);let Gt=M({},Bt);const Wt=M({},["title","style","font","a","script"]);let Yt=null;const qt=["application/xhtml+xml","text/html"];let Xt=null,$t=null;const Kt=r.createElement("form"),Vt=function(e){return e instanceof RegExp||e instanceof Function},Zt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if($t&&$t===e)return;e&&"object"==typeof e||(e={}),e=P(e),Yt=-1===qt.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,Xt="application/xhtml+xml"===Yt?S:b,Qe=ye(e,"ALLOWED_TAGS",et,{transform:Xt}),tt=ye(e,"ALLOWED_ATTR",nt,{transform:Xt}),Ut=ye(e,"ALLOWED_NAMESPACES",Ft,{transform:S}),It=ye(e,"ADD_URI_SAFE_ATTR",kt,{transform:Xt,base:kt}),Rt=ye(e,"ADD_DATA_URI_TAGS",Ct,{transform:Xt,base:Ct}),vt=ye(e,"FORBID_CONTENTS",Dt,{transform:Xt}),rt=ye(e,"FORBID_TAGS",P({}),{transform:Xt}),it=ye(e,"FORBID_ATTR",P({}),{transform:Xt}),Ot=!!R(e,"USE_PROFILES")&&(e.USE_PROFILES&&"object"==typeof e.USE_PROFILES?P(e.USE_PROFILES):e.USE_PROFILES),lt=!1!==e.ALLOW_ARIA_ATTR,ct=!1!==e.ALLOW_DATA_ATTR,st=e.ALLOW_UNKNOWN_PROTOCOLS||!1,ut=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,ft=e.SAFE_FOR_TEMPLATES||!1,pt=!1!==e.SAFE_FOR_XML,mt=e.WHOLE_DOCUMENT||!1,Tt=e.RETURN_DOM||!1,bt=e.RETURN_DOM_FRAGMENT||!1,St=e.RETURN_TRUSTED_TYPE||!1,yt=e.FORCE_BODY||!1,Et=!1!==e.SANITIZE_DOM,At=e.SANITIZE_NAMED_PROPS||!1,_t=!1!==e.KEEP_CONTENT,wt=e.IN_PLACE||!1,Je=function(e){try{return I(e,""),!0}catch(e){return!1}}(e.ALLOWED_URI_REGEXP)?e.ALLOWED_URI_REGEXP:te,zt="string"==typeof e.NAMESPACE?e.NAMESPACE:Mt,jt=R(e,"MATHML_TEXT_INTEGRATION_POINTS")&&e.MATHML_TEXT_INTEGRATION_POINTS&&"object"==typeof e.MATHML_TEXT_INTEGRATION_POINTS?P(e.MATHML_TEXT_INTEGRATION_POINTS):M({},Ht),Gt=R(e,"HTML_INTEGRATION_POINTS")&&e.HTML_INTEGRATION_POINTS&&"object"==typeof e.HTML_INTEGRATION_POINTS?P(e.HTML_INTEGRATION_POINTS):M({},Bt);const t=R(e,"CUSTOM_ELEMENT_HANDLING")&&e.CUSTOM_ELEMENT_HANDLING&&"object"==typeof e.CUSTOM_ELEMENT_HANDLING?P(e.CUSTOM_ELEMENT_HANDLING):s(null);if(ot=s(null),R(t,"tagNameCheck")&&Vt(t.tagNameCheck)&&(ot.tagNameCheck=t.tagNameCheck),R(t,"attributeNameCheck")&&Vt(t.attributeNameCheck)&&(ot.attributeNameCheck=t.attributeNameCheck),R(t,"allowCustomizedBuiltInElements")&&"boolean"==typeof t.allowCustomizedBuiltInElements&&(ot.allowCustomizedBuiltInElements=t.allowCustomizedBuiltInElements),c(ot),ft&&(ct=!1),bt&&(Tt=!0),Ot&&(Qe=M({},Y),tt=s(null),!0===Ot.html&&(M(Qe,F),M(tt,q)),!0===Ot.svg&&(M(Qe,H),M(tt,X),M(tt,K)),!0===Ot.svgFilters&&(M(Qe,j),M(tt,X),M(tt,K)),!0===Ot.mathMl&&(M(Qe,G),M(tt,$),M(tt,K))),at.tagCheck=null,at.attributeCheck=null,R(e,"ADD_TAGS")&&("function"==typeof e.ADD_TAGS?at.tagCheck=e.ADD_TAGS:T(e.ADD_TAGS)&&(Qe===et&&(Qe=P(Qe)),M(Qe,e.ADD_TAGS,Xt))),R(e,"ADD_ATTR")&&("function"==typeof e.ADD_ATTR?at.attributeCheck=e.ADD_ATTR:T(e.ADD_ATTR)&&(tt===nt&&(tt=P(tt)),M(tt,e.ADD_ATTR,Xt))),R(e,"ADD_URI_SAFE_ATTR")&&T(e.ADD_URI_SAFE_ATTR)&&M(It,e.ADD_URI_SAFE_ATTR,Xt),R(e,"FORBID_CONTENTS")&&T(e.FORBID_CONTENTS)&&(vt===Dt&&(vt=P(vt)),M(vt,e.FORBID_CONTENTS,Xt)),R(e,"ADD_FORBID_CONTENTS")&&T(e.ADD_FORBID_CONTENTS)&&(vt===Dt&&(vt=P(vt)),M(vt,e.ADD_FORBID_CONTENTS,Xt)),_t&&(Qe["#text"]=!0),mt&&M(Qe,["html","head","body"]),Qe.table&&(M(Qe,["tbody"]),delete rt.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw k('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw k('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');const t=Re;Re=e.TRUSTED_TYPES_POLICY;try{Ie=Me("")}catch(e){throw Re=t,e}}else null===e.TRUSTED_TYPES_POLICY?(Re=void 0,Ie=""):(void 0===Re&&(Re=ze()),Re&&"string"==typeof Ie&&(Ie=Me("")));l&&l(e),$t=e},Jt=M({},[...H,...j,...B]),Qt=M({},[...G,...W]),en=function(e){let t=_e(e);t&&t.tagName||(t={namespaceURI:zt,tagName:"template"});const n=b(e.tagName),o=b(t.tagName);return!!Ut[e.namespaceURI]&&(e.namespaceURI===Lt?function(e,t,n){return t.namespaceURI===Mt?"svg"===e:t.namespaceURI===xt?"svg"===e&&("annotation-xml"===n||jt[n]):Boolean(Jt[e])}(n,t,o):e.namespaceURI===xt?function(e,t,n){return t.namespaceURI===Mt?"math"===e:t.namespaceURI===Lt?"math"===e&&Gt[n]:Boolean(Qt[e])}(n,t,o):e.namespaceURI===Mt?function(e,t,n){return!(t.namespaceURI===Lt&&!Gt[n])&&!(t.namespaceURI===xt&&!jt[n])&&!Qt[e]&&(Wt[e]||!Jt[e])}(n,t,o):!("application/xhtml+xml"!==Yt||!Ut[e.namespaceURI]))},tn=function(e){g(o.removed,{element:e});try{_e(e).removeChild(e)}catch(t){if(Ee(e),!_e(e))throw k("a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place")}},nn=function(e){const t=Ne(e);if(t){const e=[];m(t,t=>{g(e,t)}),m(e,e=>{try{Ee(e)}catch(e){}})}const n=Oe(e);if(n)for(let t=n.length-1;t>=0;--t){const o=n[t],r=o&&o.name;if("string"==typeof r)try{e.removeAttribute(r)}catch(e){}}},on=function(e,t){try{g(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){g(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e)if(Tt||bt)try{tn(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},rn=function(e){const t=Oe(e);if(t)for(let n=t.length-1;n>=0;--n){const o=t[n],r=o&&o.name;if("string"==typeof r&&!tt[Xt(r)])try{e.removeAttribute(r)}catch(e){}}},an=function(e){let t=null,n=null;if(yt)e=""+e;else{const t=E(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===Yt&&zt===Mt&&(e=''+e+"");const o=Re?Me(e):e;if(zt===Mt)try{t=(new z).parseFromString(o,Yt)}catch(e){}if(!t||!t.documentElement){t=Ue.createDocument(zt,"template",null);try{t.documentElement.innerHTML=Pt?Ie:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),zt===Mt?je.call(t,mt?"html":"body")[0]:mt?t.documentElement:i},ln=function(e){return Fe.call(e.ownerDocument||e,e,x.SHOW_ELEMENT|x.SHOW_COMMENT|x.SHOW_TEXT|x.SHOW_PROCESSING_INSTRUCTION|x.SHOW_CDATA_SECTION,null)},cn=function(e){return e=A(e,We," "),e=A(e,Ye," "),e=A(e,qe," ")},sn=function(e){var t;e.normalize();const n=Fe.call(e.ownerDocument||e,e,x.SHOW_TEXT|x.SHOW_COMMENT|x.SHOW_CDATA_SECTION|x.SHOW_PROCESSING_INSTRUCTION,null);let o=n.nextNode();for(;o;)o.data=cn(o.data),o=n.nextNode();const r=null===(t=e.querySelectorAll)||void 0===t?void 0:t.call(e,"template");r&&m(r,e=>{fn(e.content)&&sn(e.content)})},un=function(e){const t=De?De(e):null;return"string"==typeof t&&("form"===Xt(t)&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||e.attributes!==Oe(e)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes||e.nodeType!==ve(e)||e.childNodes!==Ne(e)))},fn=function(e){if(!ve||"object"!=typeof e||null===e)return!1;try{return ve(e)===he}catch(e){return!1}},pn=function(e){if(!ve||"object"!=typeof e||null===e)return!1;try{return"number"==typeof ve(e)}catch(e){return!1}};function mn(e,t,n){0!==e.length&&m(e,e=>{e.call(o,t,n,$t)})}const dn=function(e){if(mn(Ge.beforeSanitizeElements,e,null),un(e))return tn(e),!0;const t=Xt(De?De(e):e.nodeName);if(mn(Ge.uponSanitizeElement,e,{tagName:t,allowedTags:Qe}),function(e,t){return!!(pt&&e.hasChildNodes()&&!pn(e.firstElementChild)&&I(ae,e.textContent)&&I(ae,e.innerHTML))||!(!pt||e.namespaceURI!==Mt||"style"!==t||!pn(e.firstElementChild))||e.nodeType===pe||!(!pt||e.nodeType!==me||!I(le,e.data))}(e,t))return tn(e),!0;if(rt[t]||!(at.tagCheck instanceof Function&&at.tagCheck(t))&&!Qe[t])return function(e,t){if(!rt[t]&&yn(t)){if(ot.tagNameCheck instanceof RegExp&&I(ot.tagNameCheck,t))return!1;if(ot.tagNameCheck instanceof Function&&ot.tagNameCheck(t))return!1}if(_t&&!vt[t]){const t=_e(e),n=Ne(e);if(n&&t)for(let o=n.length-1;o>=0;--o){const r=wt?n[o]:Se(n[o],!0);t.insertBefore(r,Ae(e))}}return tn(e),!0}(e,t);if((ve?ve(e):e.nodeType)===ue&&!en(e))return tn(e),!0;if(("noscript"===t||"noembed"===t||"noframes"===t)&&I(ce,e.innerHTML))return tn(e),!0;if(ft&&e.nodeType===fe){const t=cn(e.textContent);e.textContent!==t&&(g(o.removed,{element:e.cloneNode()}),e.textContent=t)}return mn(Ge.afterSanitizeElements,e,null),!1},hn=function(e,t,n){if(it[t])return!1;if(Et&&("id"===t||"name"===t)&&(n in r||n in Kt))return!1;const o=tt[t]||at.attributeCheck instanceof Function&&at.attributeCheck(t,e);if(ct&&I(Xe,t));else if(lt&&I($e,t));else if(o)if(It[t]);else if(I(Je,A(n,Ve,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==N(n,"data:")||!Rt[e]){if(st&&!I(Ke,A(n,Ve,"")));else if(n)return!1}else;else if(!(yn(e)&&(ot.tagNameCheck instanceof RegExp&&I(ot.tagNameCheck,e)||ot.tagNameCheck instanceof Function&&ot.tagNameCheck(e))&&(ot.attributeNameCheck instanceof RegExp&&I(ot.attributeNameCheck,t)||ot.attributeNameCheck instanceof Function&&ot.attributeNameCheck(t,e))||"is"===t&&ot.allowCustomizedBuiltInElements&&(ot.tagNameCheck instanceof RegExp&&I(ot.tagNameCheck,n)||ot.tagNameCheck instanceof Function&&ot.tagNameCheck(n))))return!1;return!0},gn=M({},["annotation-xml","color-profile","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","missing-glyph"]),yn=function(e){return!gn[b(e)]&&I(Ze,e)},Tn=function(e,t,n,o){if(Re&&"object"==typeof Te&&"function"==typeof Te.getAttributeType&&!n)switch(Te.getAttributeType(e,t)){case"TrustedHTML":return Me(o);case"TrustedScriptURL":return function(e){Le(),xe++;try{return Re.createScriptURL(e)}finally{xe--}}(o)}return o},bn=function(e,t,n,r){try{n?e.setAttributeNS(n,t,r):e.setAttribute(t,r),un(e)?tn(e):h(o.removed)}catch(n){on(t,e)}},Sn=function(e){mn(Ge.beforeSanitizeAttributes,e,null);const t=e.attributes;if(!t||un(e))return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:tt,forceKeepAttr:void 0};let o=t.length;const r=Xt(e.nodeName);for(;o--;){const i=t[o],a=i.name,l=i.namespaceURI,c=i.value,s=Xt(a),u=c;let f="value"===a?u:_(u);n.attrName=s,n.attrValue=f,n.keepAttr=!0,n.forceKeepAttr=void 0,mn(Ge.uponSanitizeAttribute,e,n),f=n.attrValue,!At||"id"!==s&&"name"!==s||0===N(f,Nt)||(on(a,e),f=Nt+f),pt&&I(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,f)?on(a,e):"attributename"===s&&E(f,"href")?on(a,e):n.forceKeepAttr||(n.keepAttr&&(ut||!I(se,f))?(ft&&(f=cn(f)),hn(r,s,f)?(f=Tn(r,s,l,f),f!==u&&bn(e,a,l,f)):on(a,e)):on(a,e))}mn(Ge.afterSanitizeAttributes,e,null)},En=function(e){let t=null;const n=ln(e);for(mn(Ge.beforeSanitizeShadowDOM,e,null);t=n.nextNode();){mn(Ge.uponSanitizeShadowNode,t,null),dn(t),Sn(t),fn(t.content)&&En(t.content);if((ve?ve(t):t.nodeType)===ue){const e=we(t);fn(e)&&(An(e),En(e))}}mn(Ge.afterSanitizeShadowDOM,e,null)},An=function(e){const t=[{node:e,shadow:null}];for(;t.length>0;){const e=t.pop();if(e.shadow){En(e.shadow);continue}const n=e.node,o=(ve?ve(n):n.nodeType)===ue,r=Ne(n);if(r)for(let e=r.length-1;e>=0;--e)t.push({node:r[e],shadow:null});if(o){const e=De?De(n):null;if("string"==typeof e&&"template"===Xt(e)){const e=n.content;fn(e)&&t.push({node:e,shadow:null})}}if(o){const e=we(n);fn(e)&&t.push({node:null,shadow:e},{node:e,shadow:null})}}};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,a=null,l=null;if(Pt=!e,Pt&&(e="\x3c!--\x3e"),"string"!=typeof e&&!pn(e)&&"string"!=typeof(e=function(e){switch(typeof e){case"string":return e;case"number":return w(e);case"boolean":return O(e);case"bigint":return v?v(e):"0";case"symbol":return D?D(e):"Symbol()";case"undefined":default:return C(e);case"function":case"object":{if(null===e)return C(e);const t=e,n=U(t,"toString");if("function"==typeof n){const e=n(t);return"string"==typeof e?e:C(e)}return C(e)}}}(e)))throw k("dirty is not a string, aborting");if(!o.isSupported)return e;dt?(Qe=ht,tt=gt):Zt(t),(Ge.uponSanitizeElement.length>0||Ge.uponSanitizeAttribute.length>0)&&(Qe=P(Qe)),Ge.uponSanitizeAttribute.length>0&&(tt=P(tt)),o.removed=[];const c=wt&&"string"!=typeof e&&pn(e);if(c){const t=De?De(e):e.nodeName;if("string"==typeof t){const e=Xt(t);if(!Qe[e]||rt[e])throw k("root node is forbidden and cannot be sanitized in-place")}if(un(e))throw k("root node is clobbered and cannot be sanitized in-place");try{An(e)}catch(t){throw nn(e),t}}else if(pn(e))n=an("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===ue&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r),An(r);else{if(!Tt&&!ft&&!mt&&-1===e.indexOf("<"))return Re&&St?Me(e):e;if(n=an(e),!n)return Tt?null:St?Ie:""}n&&yt&&tn(n.firstChild);const s=ln(c?e:n);try{for(;a=s.nextNode();)dn(a),Sn(a),fn(a.content)&&En(a.content)}catch(t){throw c&&nn(e),t}if(c)return m(o.removed,e=>{e.element&&function(e){const t=[e];for(;t.length>0;){const e=t.pop();(ve?ve(e):e.nodeType)===ue&&rn(e);const n=Ne(e);if(n)for(let e=n.length-1;e>=0;--e)t.push(n[e])}}(e.element)}),ft&&sn(e),e;if(Tt){if(ft&&sn(n),bt)for(l=He.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(tt.shadowroot||tt.shadowrootmode)&&(l=Be.call(i,l,!0)),l}let u=mt?n.outerHTML:n.innerHTML;return mt&&Qe["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&I(re,n.ownerDocument.doctype.name)&&(u="\n"+u),ft&&(u=cn(u)),Re&&St?Me(u):u},o.setConfig=function(){Zt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),dt=!0,ht=Qe,gt=tt},o.clearConfig=function(){$t=null,dt=!1,ht=null,gt=null,Re=Ce,Ie=""},o.isValidAttribute=function(e,t,n){$t||Zt({});const o=Xt(e),r=Xt(t);return hn(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&R(Ge,e)&&g(Ge[e],t)},o.removeHook=function(e,t){if(R(Ge,e)){if(void 0!==t){const n=d(Ge[e],t);return-1===n?void 0:y(Ge[e],n,1)[0]}return h(Ge[e])}},o.removeHooks=function(e){R(Ge,e)&&(Ge[e]=[])},o.removeAllHooks=function(){Ge={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return Te}); +//# sourceMappingURL=purify.min.js.map diff --git a/scripts/generate-assets.mjs b/scripts/generate-assets.mjs index 17dbcfb5..3aaae2cb 100644 --- a/scripts/generate-assets.mjs +++ b/scripts/generate-assets.mjs @@ -618,4 +618,19 @@ console.log( ` wrote ${marketingPages.length} marketing OG images → /og/.png`, ); +// Vendor third-party runtime libraries we want self-hosted (script-src 'self'). +// DOMPurify backs the Trusted Types default policy (see public/trusted-types-policy.js) +// so the strict CSP can require trusted values without pulling in a remote script. +const vendorOut = join(out, "vendor"); +await mkdir(vendorOut, { recursive: true }); +const dompurifySrc = join( + root, + "node_modules", + "dompurify", + "dist", + "purify.min.js", +); +await writeFile(join(vendorOut, "purify.min.js"), await readFile(dompurifySrc)); +console.log(" vendored DOMPurify → /vendor/purify.min.js"); + console.log("Done."); diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index ad465588..1166bd96 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -71,6 +71,14 @@ const finalJsonLd = jsonLd pattern documented in /spec/foundations/color-scheme/. */ } + { + /* Trusted Types default policy, registered before any other script so it + exists before Pagefind's search UI mounts. DOMPurify (self-hosted, vendored + by scripts/generate-assets.mjs) backs it. Worked example for + /spec/security/trusted-types/. Both are script-src 'self'. */ + } + + Date: Tue, 23 Jun 2026 13:16:09 +0200 Subject: [PATCH 2/2] spike(security): cover Pagefind's TrustedScriptURL + DOMPurify's own policy Report-only testing surfaced two sinks the static innerHTML grep missed: - pagefind.js loads its own JS/WASM chunks via a TrustedScriptURL sink, not just innerHTML. The default policy now implements createScriptURL (same-origin allowlist; script-src 'self' is the backstop) alongside createHTML. - DOMPurify registers its own Trusted Types policy named "dompurify"; add it to the trusted-types allowlist next to default. Co-Authored-By: Claude Opus 4.8 (1M context) --- public/_headers | 2 +- public/trusted-types-policy.js | 36 +++++++++++++++++++++++----------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/public/_headers b/public/_headers index 03114f41..3baad65d 100644 --- a/public/_headers +++ b/public/_headers @@ -16,7 +16,7 @@ Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=() Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' 'sha256-N74AzU+1FxvXAWIxrP2zNCBUxV949ZHOXXqjTvbusx0=' 'sha256-UU9xsfeOKmx3D7Lk33alkWn1rIjk46pD684u4pupy4o=' https://plausible.io; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://plausible.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; report-to csp-endpoint; upgrade-insecure-requests - Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; trusted-types default; report-to csp-endpoint + Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; trusted-types default dompurify; report-to csp-endpoint Reporting-Endpoints: csp-endpoint="/reports", default="/reports" Cross-Origin-Opener-Policy: same-origin Cross-Origin-Resource-Policy: same-site diff --git a/public/trusted-types-policy.js b/public/trusted-types-policy.js index 25af46a6..a3cbece9 100644 --- a/public/trusted-types-policy.js +++ b/public/trusted-types-policy.js @@ -1,20 +1,20 @@ // Trusted Types default policy. // // Worked example for /spec/security/trusted-types/. The site's CSP can carry -// require-trusted-types-for 'script'; trusted-types default -// and, once enforcing, every string assigned to a DOM injection sink (innerHTML, -// outerHTML, document.write, …) must be a TrustedHTML value or the browser throws. +// require-trusted-types-for 'script'; trusted-types default dompurify +// and, once enforcing, every string assigned to a DOM injection sink must be a +// trusted typed value or the browser throws. // -// Our own scripts never touch those sinks, but the Pagefind search UI -// (/pagefind/pagefind-ui.js) builds its entire results list with innerHTML — 11 -// assignments — so without a policy, enforcing Trusted Types would break search. +// Our own scripts touch no sinks, but the Pagefind search bundle does, in two ways: +// - pagefind-ui.js builds its results list with innerHTML (TrustedHTML sink). +// - pagefind.js loads its own JS/WASM chunks by assigning a script URL +// (TrustedScriptURL sink). // A *default* policy is the only thing that can cover Pagefind, because its -// bundled code assigns raw strings and cannot opt into a named policy itself. +// bundled code assigns raw strings/URLs and cannot opt into a named policy itself. +// So the default policy implements both createHTML and createScriptURL. // -// The policy runs every such string through DOMPurify, which strips scripts and -// event handlers while preserving the structural markup Pagefind emits -// (,

, highlights, lists). Loaded before any other script in -// so the policy exists before Pagefind mounts. +// (DOMPurify registers its own policy named "dompurify"; the trusted-types +// allowlist in _headers names it too.) (function () { if (!window.trustedTypes || !window.trustedTypes.createPolicy) return; // unsupported browser: nothing to enforce if (typeof window.DOMPurify === "undefined") { @@ -26,9 +26,23 @@ } try { window.trustedTypes.createPolicy("default", { + // HTML sinks (Pagefind results UI): sanitise, keeping its a/p/mark/list markup. createHTML: function (input) { return window.DOMPurify.sanitize(input); }, + // Script-URL sinks (Pagefind loading its own JS/WASM): allow same-origin + // URLs only. script-src 'self' is the backstop; this just stops a + // cross-origin URL ever reaching a script-loading sink. + createScriptURL: function (input) { + try { + if (new URL(input, document.baseURI).origin === location.origin) { + return input; + } + } catch { + /* malformed URL — fall through to the throw below */ + } + throw new TypeError("[trusted-types] blocked script URL: " + input); + }, }); } catch (e) { // createPolicy throws if a "default" policy already exists or the name is