From 4b9e9fdc41cf721d04d906bb712c0759e2d90cdf Mon Sep 17 00:00:00 2001 From: Rick Viscomi Date: Thu, 18 Apr 2024 11:22:52 -0400 Subject: [PATCH] `http-equiv` validators (#102) --- crx/capo.js | 5 +- crx/manifest.json | 2 +- docs/src/content/docs/user/validation.mdx | 72 ++++- docs/src/lib/capo.js | 195 +++++++++++++- package.json | 2 +- snippet/capo.js | 195 +++++++++++++- src/lib/io.js | 55 +--- src/lib/validation.js | 306 +++++++++++++++++++--- 8 files changed, 733 insertions(+), 99 deletions(-) diff --git a/crx/capo.js b/crx/capo.js index 8220aba..4078535 100644 --- a/crx/capo.js +++ b/crx/capo.js @@ -1,2 +1,3 @@ -(()=>{function e(e,t,i,n){Object.defineProperty(e,t,{get:i,set:n,enumerable:!0,configurable:!0})}function t(e){return[`oklch(5% .1 ${e})`,`oklch(13% .2 ${e})`,`oklch(25% .2 ${e})`,`oklch(35% .25 ${e})`,`oklch(50% .27 ${e})`,`oklch(67% .31 ${e})`,`oklch(72% .25 ${e})`,`oklch(80% .2 ${e})`,`oklch(90% .1 ${e})`,`oklch(99% .05 ${e})`,"#ccc"]}let i=["#9e0142","#d53e4f","#f46d43","#fdae61","#fee08b","#e6f598","#abdda4","#66c2a5","#3288bd","#5e4fa2","#cccccc"],n=t(320),r=t(200),s={DEFAULT:i,PINK:n,BLUE:r};var a={};e(a,"IO",()=>o);class o{constructor(e,t,i=window.console){this.document=e,this.options=t,this.console=i,this.isStaticHead=!1,this.head=null}async init(){if(!this.head){if(this.options.prefersDynamicAssessment()){this.head=this.document.querySelector("head");return}try{let e=await this.getStaticHTML();e=e.replace(/(\<\/?)(head)/gi,"$1static-head");let t=this.document.implementation.createHTMLDocument("New Document");t.documentElement.innerHTML=e,this.head=t.querySelector("static-head"),this.head?this.isStaticHead=!0:this.head=this.document.head}catch(e){this.console.error(`${this.options.loggingPrefix}An exception occurred while getting the static :`,e),this.head=this.document.head}this.isStaticHead||this.console.warn(`${this.options.loggingPrefix}Unable to parse the static (server-rendered) . Falling back to document.head`,this.head)}}async getStaticHTML(){let e=this.document.location.href,t=await fetch(e);return await t.text()}getHead(){return this.head}stringifyElement(e){return e.getAttributeNames().reduce((t,i)=>t+=`[${CSS.escape(i)}=${JSON.stringify(e.getAttribute(i))}]`,e.nodeName)}getLoggableElement(e){if(!this.isStaticHead)return e;let t=this.stringifyElement(e),i=Array.from(this.document.head.querySelectorAll(t));if(0==i.length)return e;if(1==i.length)return i[0];let n=this.document.createElement("div"),r=this.document.createElement("div");r.innerHTML=e.innerHTML;let s=i.find(e=>(n.innerHTML=e.innerHTML,n.innerHTML==r.innerHTML));return s||e}createElementFromSelector(e){let t=e.match(/^[A-Za-z]+/)[0];if(!t)return;let i=document.createElement(t),n=e.match(/\[([A-Za-z-]+)="([^"]+)"\]/g)||[];return n.forEach(e=>{e=e.slice(1,-1);let t=e.indexOf("="),n=e.slice(0,t),r=e.slice(t+1).slice(1,-1);i.setAttribute(n,r)}),i}logElementFromSelector({weight:e,selector:t,innerHTML:i,isValid:n,customValidations:r={}}){e=+e;let s=this.getElementVisualization(e),a=this.createElementFromSelector(t);a.innerHTML=i,a=this.getLoggableElement(a),this.logElement({viz:s,weight:e,element:a,isValid:n,customValidations:r})}logElement({viz:e,weight:t,element:i,isValid:n,customValidations:r,omitPrefix:s=!1}){s||(e.visual=`${this.options.loggingPrefix}${e.visual}`);let a="log",o=[e.visual,e.style,t+1,i];if(!this.options.isValidationEnabled()){this.console[a](...o);return}let{payload:l,warnings:c}=r;l&&("string"==typeof l.expiry&&(l.expiry=new Date(l.expiry)),o.push(l)),c?.length?(a="warn",c.forEach(e=>o.push(`❌ ${e}`))):!n&&(this.options.prefersDynamicAssessment()||this.isStaticHead)&&(a="warn",o.push(`❌ invalid element (${i.tagName})`)),this.console[a](...o)}logValidationWarnings(e){this.options.isValidationEnabled()&&e.forEach(({warning:e,elements:t=[],element:i})=>{t=t.map(this.getLoggableElement.bind(this)),this.console.warn(`${this.options.loggingPrefix}${e}`,...t,i||"")})}getColor(e){return this.options.palette[10-e]}getHeadVisualization(e){let t="",i=[];return e.forEach(({weight:e,isValid:n})=>{t+="%c ";let r=this.getColor(e),s="padding: 5px; margin: 0 -1px; ";if(n)s+=`background-color: ${r};`;else{let e;s+=`background-image: ${r==(e="#cccccc")&&(e="red"),`repeating-linear-gradient(45deg, ${r}, ${r} 3px, ${e} 3px, ${e} 6px)`}`}i.push(s)}),{visual:t,styles:i}}getElementVisualization(e){let t=`%c${Array(e+1).fill("█").join("")}`,i=this.getColor(e),n=`color: ${i}`;return{visual:t,style:n}}visualizeHead(e,t,i){let n=this.getHeadVisualization(i);this.console.groupCollapsed(`${this.options.loggingPrefix}${e} %chead%c order -${n.visual}`,"font-family: monospace","font-family: inherit",...n.styles),i.forEach(({weight:e,element:t,isValid:i,customValidations:n})=>{let r=this.getElementVisualization(e);this.logElement({viz:r,weight:e,element:t,isValid:i,customValidations:n,omitPrefix:!0})}),this.console.log(`${e} %chead%c element`,"font-family: monospace","font-family: inherit",t),this.console.groupEnd()}}var l={};e(l,"Options",()=>c);class c{constructor({preferredAssessmentMode:e=c.AssessmentMode.STATIC,validation:t=!0,palette:n=i,loggingPrefix:r="Capo: "}={}){this.setPreferredAssessmentMode(e),this.setValidation(t),this.setPalette(n),this.setLoggingPrefix(r)}static get AssessmentMode(){return{STATIC:"static",DYNAMIC:"dynamic"}}static get Palettes(){return s}prefersStaticAssessment(){return this.preferredAssessmentMode===c.AssessmentMode.STATIC}prefersDynamicAssessment(){return this.preferredAssessmentMode===c.AssessmentMode.DYNAMIC}isValidationEnabled(){return this.validation}setPreferredAssessmentMode(e){if(!this.isValidAssessmentMode(e))throw Error(`Invalid option: preferred assessment mode, expected AssessmentMode.STATIC or AssessmentMode.DYNAMIC, got "${e}".`);this.preferredAssessmentMode=e}setPreferredAssessmentModeToStatic(e){let t=c.AssessmentMode.STATIC;e||(t=c.AssessmentMode.DYNAMIC),this.setPreferredAssessmentMode(t)}setValidation(e){if(!this.isValidValidation(e))throw Error(`Invalid option: validation, expected boolean, got "${e}".`);this.validation=e}setPalette(e){if(!this.isValidPalette(e))throw Error(`Invalid option: palette, expected [${Object.keys(s).join("|")}] or an array of colors, got "${e}".`);if("string"==typeof e){this.palette=s[e];return}this.palette=e}setLoggingPrefix(e){if(!this.isValidLoggingPrefix(e))throw Error(`Invalid option: logging prefix, expected string, got "${e}".`);this.loggingPrefix=e}isValidAssessmentMode(e){return Object.values(c.AssessmentMode).includes(e)}isValidValidation(e){return"boolean"==typeof e}isValidPalette(e){return"string"==typeof e?Object.keys(s).includes(e):!!Array.isArray(e)&&11===e.length&&e.every(e=>"string"==typeof e)}isValidLoggingPrefix(e){return"string"==typeof e}isPreferredPalette(e){return JSON.stringify(this.palette)==JSON.stringify(e)}valueOf(){return{preferredAssessmentMode:this.preferredAssessmentMode,validation:this.validation,palette:this.palette,loggingPrefix:this.loggingPrefix}}}var d={};e(d,"ElementWeights",()=>h),e(d,"ElementDetectors",()=>u),e(d,"isMeta",()=>m),e(d,"isTitle",()=>p),e(d,"isPreconnect",()=>f),e(d,"isAsyncScript",()=>y),e(d,"isImportStyles",()=>E),e(d,"isSyncScript",()=>S),e(d,"isSyncStyles",()=>A),e(d,"isPreload",()=>w),e(d,"isDeferScript",()=>P),e(d,"isPrefetchPrerender",()=>T),e(d,"META_HTTP_EQUIV_KEYWORDS",()=>g),e(d,"isOriginTrial",()=>b),e(d,"isMetaCSP",()=>C),e(d,"getWeight",()=>v),e(d,"getHeadWeights",()=>V);let h={META:10,TITLE:9,PRECONNECT:8,ASYNC_SCRIPT:7,IMPORT_STYLES:6,SYNC_SCRIPT:5,SYNC_STYLES:4,PRELOAD:3,DEFER_SCRIPT:2,PREFETCH_PRERENDER:1,OTHER:0},u={META:m,TITLE:p,PRECONNECT:f,ASYNC_SCRIPT:y,IMPORT_STYLES:E,SYNC_SCRIPT:S,SYNC_STYLES:A,PRELOAD:w,DEFER_SCRIPT:P,PREFETCH_PRERENDER:T},g=["accept-ch","content-security-policy","content-type","default-style","delegate-ch","origin-trial","x-dns-prefetch-control"];function m(e){let t=g.map(e=>`[http-equiv="${e}" i]`).join(", ");return e.matches(`meta:is([charset], ${t}, [name=viewport]), base`)}function p(e){return e.matches("title")}function f(e){return e.matches("link[rel=preconnect]")}function y(e){return e.matches("script[src][async]")}function E(e){return!!e.matches("style")&&/@import/.test(e.textContent)}function S(e){return e.matches("script:not([src][defer],[src][type=module],[src][async],[type*=json])")}function A(e){return e.matches("link[rel=stylesheet],style")}function w(e){return e.matches("link:is([rel=preload], [rel=modulepreload])")}function P(e){return e.matches("script[src][defer], script:not([src][async])[src][type=module]")}function T(e){return e.matches("link:is([rel=prefetch], [rel=dns-prefetch], [rel=prerender])")}function b(e){return e.matches('meta[http-equiv="origin-trial"i]')}function C(e){return e.matches('meta[http-equiv="Content-Security-Policy" i], meta[http-equiv="Content-Security-Policy-Report-Only" i]')}function v(e){for(let[t,i]of Object.entries(u))if(i(e))return h[t];return h.OTHER}function V(e){let t=Array.from(e.children);return t.map(e=>({element:e,weight:v(e)}))}var $={};e($,"VALID_HEAD_ELEMENTS",()=>M),e($,"PRELOAD_SELECTOR",()=>x),e($,"isValidElement",()=>L),e($,"hasValidationWarning",()=>R),e($,"getValidationWarnings",()=>H),e($,"getCustomValidations",()=>O);let M=new Set(["base","link","meta","noscript","script","style","template","title"]),x='link:is([rel="preload" i], [rel="modulepreload" i])';function L(e){return M.has(e.tagName.toLowerCase())}function R(e){return!!(!L(e)||e.matches(`:has(:not(${Array.from(M).join(", ")}))`)||e.matches("title:is(:nth-of-type(n+2))")||e.matches("base:has(~ base), base ~ base")||C(e)||function(e){if(!b(e))return!1;let{warnings:t}=I(e);return t.length>0}(e)||N(e))}function H(e){let t=[],i=Array.from(e.querySelectorAll("title")),n=i.length;1!=n&&t.push({warning:`Expected exactly 1 element, found ${n}`,elements:i});let r=Array.from(e.querySelectorAll("base")),s=r.length;s>1&&t.push({warning:`Expected at most 1 <base> element, found ${s}`,elements:r});let a=e.querySelector('meta[http-equiv="Content-Security-Policy" i]');a&&t.push({warning:"CSP meta tags disable the preload scanner due to a bug in Chrome. Use the CSP header instead. Learn more: https://crbug.com/1458493",element:a}),e.querySelectorAll("*").forEach(i=>{if(L(i))return;let n=i;for(;n.parentElement!=e;)n=n.parentElement;t.push({warning:`${i.tagName} elements are not allowed in the <head>`,element:n})});let o=Array.from(e.querySelectorAll('meta[http-equiv="Origin-Trial" i]'));return o.forEach(e=>{let i=I(e);0!=i.warnings.length&&t.push({warning:`Invalid origin trial token: ${i.warnings.join(", ")}`,elements:[e],element:i.payload})}),t}function O(e){return b(e)?I(e):C(e)?function(e){let t=[];return e.matches('meta[http-equiv="Content-Security-Policy-Report-Only" i]')?t.push("CSP Report-Only is forbidden in meta tags"):e.matches('meta[http-equiv="Content-Security-Policy" i]')&&t.push("meta CSP discouraged. See https://crbug.com/1458493."),{warnings:t}}(e):N(e)?function(e){let t=e.getAttribute("href"),i=D(t),n=k(e.parentElement,i);if(!n)throw Error("Expected an invalid preload, but none found.");return{warnings:[`This preload has little to no effect. ${t} is already discoverable by another ${n.tagName} element.`]}}(e):{}}function I(e){var t,i,n,r;let s={payload:null,warnings:[]},a=e.getAttribute("content");try{s.payload=function(e){let t=new Uint8Array([...atob(e)].map(e=>e.charCodeAt(0))),i=new DataView(t.buffer),n=i.getUint32(65,!1),r=JSON.parse(new TextDecoder().decode(t.slice(69,69+n)));return r.expiry=new Date(1e3*r.expiry),r}(a)}catch{return s.warnings.push("invalid token"),s}if(s.payload.expiry<new Date&&s.warnings.push("expired"),t=s.payload.origin,i=document.location.href,new URL(t).origin!==new URL(i).origin){let e=(n=s.payload.origin,r=document.location.href,n=new URL(n),(r=new URL(r)).host.endsWith(`.${n.host}`));e&&!s.payload.isSubdomain?s.warnings.push("invalid subdomain"):e||s.payload.isThirdParty||s.warnings.push("invalid origin")}return s}function N(e){if(!e.matches(x))return!1;let t=e.getAttribute("href");if(!t)return!1;let i=D(t);return null!=k(e.parentElement,i)}function k(e,t){let i=Array.from(e.querySelectorAll(`link:not(${x}), script`));return i.find(e=>{let i=e.getAttribute("href")||e.getAttribute("src");return!!i&&t==D(i)})}function D(e){return new URL(e,document.baseURI).href}async function _(e){await e.init(),function(e,t){let i=t.getValidationWarnings(e.getHead());e.logValidationWarnings(i)}(e,$);let t=function(e,t,i){let n=e.getHead(),r=i.getHeadWeights(n).map(({element:i,weight:n})=>({weight:n,element:e.getLoggableElement(i),isValid:!t.hasValidationWarning(i),customValidations:t.getCustomValidations(i)}));e.visualizeHead("Actual",n,r);let s=Array.from(r).sort((e,t)=>t.weight-e.weight),a=document.createElement("head");return s.forEach(({element:e})=>{a.appendChild(e.cloneNode(!0))}),e.visualizeHead("Sorted",a,s),r}(e,$,d);return{actual:t.map(({element:t,weight:i,isValid:n,customValidations:r})=>(r?.payload?.expiry&&(r.payload.expiry=r.payload.expiry.toString()),{weight:i,color:e.getColor(i),selector:e.stringifyElement(t),innerHTML:t.innerHTML,isValid:n,customValidations:r}))}}async function q(){let{options:e}=await chrome.storage.sync.get("options");return new l.Options(e)}!async function(){let e=await q(),t=new a.IO(document,e),{click:i}=await chrome.storage.local.get("click");if(i)t.logElementFromSelector(JSON.parse(i)),await chrome.storage.local.remove("click");else{let e=await _(t);await chrome.storage.local.set({data:e})}}()})(); \ No newline at end of file +(()=>{function e(e,t,n,i){Object.defineProperty(e,t,{get:n,set:i,enumerable:!0,configurable:!0})}function t(e){return[`oklch(5% .1 ${e})`,`oklch(13% .2 ${e})`,`oklch(25% .2 ${e})`,`oklch(35% .25 ${e})`,`oklch(50% .27 ${e})`,`oklch(67% .31 ${e})`,`oklch(72% .25 ${e})`,`oklch(80% .2 ${e})`,`oklch(90% .1 ${e})`,`oklch(99% .05 ${e})`,"#ccc"]}let n=["#9e0142","#d53e4f","#f46d43","#fdae61","#fee08b","#e6f598","#abdda4","#66c2a5","#3288bd","#5e4fa2","#cccccc"],i=t(320),a=t(200),s={DEFAULT:n,PINK:i,BLUE:a};var r={};e(r,"IO",()=>o);class o{constructor(e,t,n=window.console){this.document=e,this.options=t,this.console=n,this.isStaticHead=!1,this.head=null}async init(){if(!this.head){if(this.options.prefersDynamicAssessment()){this.head=this.document.querySelector("head");return}try{let e=await this.getStaticHTML();e=e.replace(/(\<\/?)(head)/gi,"$1static-head");let t=this.document.implementation.createHTMLDocument("New Document");t.documentElement.innerHTML=e,this.head=t.querySelector("static-head"),this.head?this.isStaticHead=!0:this.head=this.document.head}catch(e){this.console.error(`${this.options.loggingPrefix}An exception occurred while getting the static <head>:`,e),this.head=this.document.head}this.isStaticHead||this.console.warn(`${this.options.loggingPrefix}Unable to parse the static (server-rendered) <head>. Falling back to document.head`,this.head)}}async getStaticHTML(){let e=this.document.location.href,t=await fetch(e);return await t.text()}getHead(){return this.head}stringifyElement(e){return e.getAttributeNames().reduce((t,n)=>t+=`[${CSS.escape(n)}=${JSON.stringify(e.getAttribute(n))}]`,e.nodeName)}getLoggableElement(e){if(!this.isStaticHead)return e;let t=this.stringifyElement(e),n=Array.from(this.document.head.querySelectorAll(t));if(0==n.length)return e;if(1==n.length)return n[0];let i=this.document.createElement("div"),a=this.document.createElement("div");a.innerHTML=e.innerHTML;let s=n.find(e=>(i.innerHTML=e.innerHTML,i.innerHTML==a.innerHTML));return s||e}createElementFromSelector(e){let t=e.match(/^[A-Za-z]+/)[0];if(!t)return;let n=document.createElement(t),i=e.match(/\[([A-Za-z-]+)="([^"]+)"\]/g)||[];return i.forEach(e=>{e=e.slice(1,-1);let t=e.indexOf("="),i=e.slice(0,t),a=e.slice(t+1).slice(1,-1);n.setAttribute(i,a)}),n}logElementFromSelector({weight:e,selector:t,innerHTML:n,isValid:i,customValidations:a={}}){e=+e;let s=this.getElementVisualization(e),r=this.createElementFromSelector(t);r.innerHTML=n,r=this.getLoggableElement(r),this.logElement({viz:s,weight:e,element:r,isValid:i,customValidations:a})}logElement({viz:e,weight:t,element:n,isValid:i,customValidations:a,omitPrefix:s=!1}){s||(e.visual=`${this.options.loggingPrefix}${e.visual}`);let r="log",o=[e.visual,e.style,t+1,n];if(!this.options.isValidationEnabled()){this.console[r](...o);return}let{payload:l,warnings:c}=a;l&&("string"==typeof l.expiry&&(l.expiry=new Date(l.expiry)),o.push(l)),c?.length?(r="warn",o.push("\n"+c.map(e=>` ❌ ${e}`).join("\n"))):!i&&(this.options.prefersDynamicAssessment()||this.isStaticHead)&&(r="warn",o.push(` + ❌ invalid element (${n.tagName})`)),this.console[r](...o)}logValidationWarnings(e){this.options.isValidationEnabled()&&e.forEach(({warning:e,elements:t=[],element:n})=>{t=t.map(this.getLoggableElement.bind(this)),this.console.warn(`${this.options.loggingPrefix}${e}`,...t,n||"")})}getColor(e){return this.options.palette[10-e]}getHeadVisualization(e){let t="",n=[];return e.forEach(({weight:e,isValid:i})=>{t+="%c ";let a=this.getColor(e),s="padding: 5px; margin: 0 -1px; ";if(i)s+=`background-color: ${a};`;else{let e;s+=`background-image: ${a==(e="#cccccc")&&(e="red"),`repeating-linear-gradient(45deg, ${a}, ${a} 3px, ${e} 3px, ${e} 6px)`}`}n.push(s)}),{visual:t,styles:n}}getElementVisualization(e){let t=`%c${Array(e+1).fill("█").join("")}`,n=this.getColor(e),i=`color: ${n}`;return{visual:t,style:i}}visualizeHead(e,t,n){let i=this.getHeadVisualization(n);this.console.groupCollapsed(`${this.options.loggingPrefix}${e} %chead%c order +${i.visual}`,"font-family: monospace","font-family: inherit",...i.styles),n.forEach(({weight:e,element:t,isValid:n,customValidations:i})=>{let a=this.getElementVisualization(e);this.logElement({viz:a,weight:e,element:t,isValid:n,customValidations:i,omitPrefix:!0})}),this.console.log(`${e} %chead%c element`,"font-family: monospace","font-family: inherit",t),this.console.groupEnd()}}var l={};e(l,"Options",()=>c);class c{constructor({preferredAssessmentMode:e=c.AssessmentMode.STATIC,validation:t=!0,palette:i=n,loggingPrefix:a="Capo: "}={}){this.setPreferredAssessmentMode(e),this.setValidation(t),this.setPalette(i),this.setLoggingPrefix(a)}static get AssessmentMode(){return{STATIC:"static",DYNAMIC:"dynamic"}}static get Palettes(){return s}prefersStaticAssessment(){return this.preferredAssessmentMode===c.AssessmentMode.STATIC}prefersDynamicAssessment(){return this.preferredAssessmentMode===c.AssessmentMode.DYNAMIC}isValidationEnabled(){return this.validation}setPreferredAssessmentMode(e){if(!this.isValidAssessmentMode(e))throw Error(`Invalid option: preferred assessment mode, expected AssessmentMode.STATIC or AssessmentMode.DYNAMIC, got "${e}".`);this.preferredAssessmentMode=e}setPreferredAssessmentModeToStatic(e){let t=c.AssessmentMode.STATIC;e||(t=c.AssessmentMode.DYNAMIC),this.setPreferredAssessmentMode(t)}setValidation(e){if(!this.isValidValidation(e))throw Error(`Invalid option: validation, expected boolean, got "${e}".`);this.validation=e}setPalette(e){if(!this.isValidPalette(e))throw Error(`Invalid option: palette, expected [${Object.keys(s).join("|")}] or an array of colors, got "${e}".`);if("string"==typeof e){this.palette=s[e];return}this.palette=e}setLoggingPrefix(e){if(!this.isValidLoggingPrefix(e))throw Error(`Invalid option: logging prefix, expected string, got "${e}".`);this.loggingPrefix=e}isValidAssessmentMode(e){return Object.values(c.AssessmentMode).includes(e)}isValidValidation(e){return"boolean"==typeof e}isValidPalette(e){return"string"==typeof e?Object.keys(s).includes(e):!!Array.isArray(e)&&11===e.length&&e.every(e=>"string"==typeof e)}isValidLoggingPrefix(e){return"string"==typeof e}isPreferredPalette(e){return JSON.stringify(this.palette)==JSON.stringify(e)}valueOf(){return{preferredAssessmentMode:this.preferredAssessmentMode,validation:this.validation,palette:this.palette,loggingPrefix:this.loggingPrefix}}}var h={};e(h,"ElementWeights",()=>d),e(h,"ElementDetectors",()=>u),e(h,"isMeta",()=>g),e(h,"isTitle",()=>p),e(h,"isPreconnect",()=>f),e(h,"isAsyncScript",()=>y),e(h,"isImportStyles",()=>E),e(h,"isSyncScript",()=>w),e(h,"isSyncStyles",()=>T),e(h,"isPreload",()=>b),e(h,"isDeferScript",()=>S),e(h,"isPrefetchPrerender",()=>A),e(h,"META_HTTP_EQUIV_KEYWORDS",()=>m),e(h,"isOriginTrial",()=>P),e(h,"isMetaCSP",()=>v),e(h,"getWeight",()=>C),e(h,"getHeadWeights",()=>$);let d={META:10,TITLE:9,PRECONNECT:8,ASYNC_SCRIPT:7,IMPORT_STYLES:6,SYNC_SCRIPT:5,SYNC_STYLES:4,PRELOAD:3,DEFER_SCRIPT:2,PREFETCH_PRERENDER:1,OTHER:0},u={META:g,TITLE:p,PRECONNECT:f,ASYNC_SCRIPT:y,IMPORT_STYLES:E,SYNC_SCRIPT:w,SYNC_STYLES:T,PRELOAD:b,DEFER_SCRIPT:S,PREFETCH_PRERENDER:A},m=["accept-ch","content-security-policy","content-type","default-style","delegate-ch","origin-trial","x-dns-prefetch-control"];function g(e){let t=m.map(e=>`[http-equiv="${e}" i]`).join(", ");return e.matches(`meta:is([charset], ${t}, [name=viewport]), base`)}function p(e){return e.matches("title")}function f(e){return e.matches("link[rel=preconnect]")}function y(e){return e.matches("script[src][async]")}function E(e){return!!e.matches("style")&&/@import/.test(e.textContent)}function w(e){return e.matches("script:not([src][defer],[src][type=module],[src][async],[type*=json])")}function T(e){return e.matches("link[rel=stylesheet],style")}function b(e){return e.matches("link:is([rel=preload], [rel=modulepreload])")}function S(e){return e.matches("script[src][defer], script:not([src][async])[src][type=module]")}function A(e){return e.matches("link:is([rel=prefetch], [rel=dns-prefetch], [rel=prerender])")}function P(e){return e.matches('meta[http-equiv="origin-trial"i]')}function v(e){return e.matches('meta[http-equiv="Content-Security-Policy" i], meta[http-equiv="Content-Security-Policy-Report-Only" i]')}function C(e){for(let[t,n]of Object.entries(u))if(n(e))return d[t];return d.OTHER}function $(e){let t=Array.from(e.children);return t.map(e=>({element:e,weight:C(e)}))}var x={};e(x,"VALID_HEAD_ELEMENTS",()=>L),e(x,"CONTENT_TYPE_SELECTOR",()=>M),e(x,"HTTP_EQUIV_SELECTOR",()=>V),e(x,"PRELOAD_SELECTOR",()=>k),e(x,"isValidElement",()=>H),e(x,"hasValidationWarning",()=>N),e(x,"getValidationWarnings",()=>R),e(x,"getCustomValidations",()=>D);let L=new Set(["base","link","meta","noscript","script","style","template","title"]),M='meta[http-equiv="content-type" i], meta[charset]',V="meta[http-equiv]",k='link:is([rel="preload" i], [rel="modulepreload" i])';function H(e){return L.has(e.tagName.toLowerCase())}function N(e){return!!(!H(e)||e.matches(`:has(:not(${Array.from(L).join(", ")}))`)||e.matches("title:is(:nth-of-type(n+2))")||e.matches("base:has(~ base), base ~ base")||v(e)||function(e){if(!U(e))return!1;let{warnings:t}=W(e);return t.length>0}(e)||function(e){if(!I(e))return!1;let{warnings:t}=F(e);return t.length>0}(e)||function(e){if(!q(e))return!1;let{warnings:t}=j(e);return t.length>0}(e)||function(e){if(!P(e))return!1;let{warnings:t}=O(e);return t.length>0}(e)||_(e))}function R(e){let t=[],n=Array.from(e.querySelectorAll("title")),i=n.length;1!=i&&t.push({warning:`Expected exactly 1 <title> element, found ${i}`,elements:n});let a=Array.from(e.querySelectorAll("base")),s=a.length;s>1&&t.push({warning:`Expected at most 1 <base> element, found ${s}`,elements:a});let r=e.querySelector('meta[http-equiv="Content-Security-Policy" i]');r&&t.push({warning:"CSP meta tags disable the preload scanner due to a bug in Chrome. Use the CSP header instead. Learn more: https://crbug.com/1458493",element:r}),e.querySelectorAll("*").forEach(n=>{if(H(n))return;let i=n;for(;i.parentElement!=e;)i=i.parentElement;t.push({warning:`${n.tagName} elements are not allowed in the <head>`,element:i})});let o=Array.from(e.querySelectorAll('meta[http-equiv="Origin-Trial" i]'));return o.forEach(e=>{let n=O(e);0!=n.warnings.length&&t.push({warning:`Invalid origin trial token: ${n.warnings.join(", ")}`,elements:[e],element:n.payload})}),t}function D(e){return P(e)?O(e):v(e)?function(e){let t=[];return e.matches('meta[http-equiv="Content-Security-Policy-Report-Only" i]')?t.push("CSP Report-Only is forbidden in meta tags"):e.matches('meta[http-equiv="Content-Security-Policy" i]')&&t.push("meta CSP discouraged. See https://crbug.com/1458493."),{warnings:t}}(e):I(e)?F(e):q(e)?j(e):U(e)?W(e):_(e)?function(e){let t=e.getAttribute("href"),n=Y(t),i=z(e.parentElement,n);if(!i)throw Error("Expected an invalid preload, but none found.");return{warnings:[`This preload has little to no effect. ${t} is already discoverable by another ${i.tagName} element.`]}}(e):{}}function O(e){var t,n,i,a;let s={payload:null,warnings:[]},r=e.getAttribute("content");try{s.payload=function(e){let t=new Uint8Array([...atob(e)].map(e=>e.charCodeAt(0))),n=new DataView(t.buffer),i=n.getUint32(65,!1),a=JSON.parse(new TextDecoder().decode(t.slice(69,69+i)));return a.expiry=new Date(1e3*a.expiry),a}(r)}catch{return s.warnings.push("invalid token"),s}if(s.payload.expiry<new Date&&s.warnings.push("expired"),t=s.payload.origin,n=document.location.href,new URL(t).origin!==new URL(n).origin){let e=(i=s.payload.origin,a=document.location.href,i=new URL(i),(a=new URL(a)).host.endsWith(`.${i.host}`));console.log({subdomain:e,payload:s.payload}),e&&!s.payload.isSubdomain?s.warnings.push("invalid subdomain"):e||s.payload.isThirdParty||s.warnings.push("invalid third-party origin")}return s}function I(e){return e.matches('meta[http-equiv="default-style" i]')}function q(e){return e.matches(M)}function U(e){return e.matches(V)}function _(e){if(!e.matches(k))return!1;let t=e.getAttribute("href");if(!t)return!1;let n=Y(t);return null!=z(e.parentElement,n)}function z(e,t){let n=Array.from(e.querySelectorAll(`link:not(${k}), script`));return n.find(e=>{let n=e.getAttribute("href")||e.getAttribute("src");return!!n&&t==Y(n)})}function Y(e){return new URL(e,document.baseURI).href}function F(e){let t=[],n=null,i=e.getAttribute("content"),a=e.parentElement.querySelector(`link[rel~="alternate" i][rel~="stylesheet" i][title="${i}"]`);return i?a||(n={alternateStylesheets:Array.from(e.parentElement.querySelectorAll('link[rel~="alternate" i][rel~="stylesheet" i]'))},t.push(`This has no effect. No alternate stylesheet found having title="${i}".`)):t.push("This has no effect. The content attribute must be set to a valid stylesheet title."),t.push("Even when used correctly, the default-style method of setting a preferred stylesheet results in a flash of unstyled content. Use modern CSS features like @media rules instead."),{warnings:t,payload:n}}function j(e){let t=[],n=null;if(e.matches(':is(meta[charset] ~ meta[http-equiv="content-type" i])')||e.matches(":has(~ meta[charset])")){let i=e.parentElement.querySelector("meta[charset]");(n=n??{}).encodingDeclaration=i,t.push(`There can only be one meta-based character encoding declaration per document. Already found \`${i.outerHTML}\`.`)}let i=e.ownerDocument.documentElement.outerHTML.indexOf(e.outerHTML)+e.outerHTML.length;i>1024&&((n=n??{}).characterPosition=i,t.push(`The element containing the character encoding declaration must be serialized completely within the first 1024 bytes of the document. Found at byte ${i}.`));let a=null;return"utf-8"!=(a=e.matches("meta[charset]")?e.getAttribute("charset"):e.getAttribute("content")?.match(/text\/html;\s*charset=(.*)/i)?.[1]).toLowerCase()&&((n=n??{}).charset=a,t.push(`Documents are required to use UTF-8 encoding. Found "${a}".`)),t.length&&(t[length-1]=t.at(-1)+"\nLearn more: https://html.spec.whatwg.org/multipage/semantics.html#character-encoding-declaration"),{warnings:t,payload:n}}function W(e){let t=[],n=e.getAttribute("http-equiv").toLowerCase(),i=e.getAttribute("content").toLowerCase();switch(n){case"content-security-policy":case"content-security-policy-report-only":case"origin-trial":case"content-type":case"default-style":break;case"refresh":i.includes("url=")?t.push("Meta auto-redirects are discouraged. Use HTTP 3XX responses instead."):t.push("Meta auto-refreshes are discouraged unless users have the ability to disable it.");break;case"x-dns-prefetch-control":"on"==i?t.push(`DNS prefetching is enabled by default. Setting it to "${i}" has no effect.`):"off"!=i?t.push(`This is a non-standard way of disabling DNS prefetching, which is a performance optimization. Found content="${i}". Use content="off" if you have a legitimate security concern, otherwise remove it.`):t.push("This is non-standard, however most browsers support disabling speculative DNS prefetching. It should still be noted that DNS prefetching is a generally accepted performance optimization and you should only disable it if you have specific security concerns.");break;case"cache-control":case"etag":case"pragma":case"expires":case"last-modified":t.push("This doesn't do anything. Use HTTP headers for any cache directives.");break;case"x-frame-options":t.push("This doesn't do anything. Use the CSP HTTP header with the frame-ancestors directive instead.");break;case"x-ua-compatible":case"content-style-type":case"content-script-type":case"imagetoolbar":case"cleartype":case"page-enter":case"page-exit":case"site-enter":case"site-exit":case"msthemecompatible":case"window-target":t.push("This doesn't do anything. It was an Internet Explorer feature and is now deprecated.");break;case"content-language":case"language":t.push("This is non-conforming. Use the html[lang] attribute instead.");break;case"set-cookie":t.push("This is non-conforming. Use the Set-Cookie HTTP header instead.");break;case"application-name":case"author":case"description":case"generator":case"keywords":case"referrer":case"theme-color":case"color-scheme":case"viewport":case"creator":case"googlebot":case"publisher":case"robots":t.push(`This doesn't do anything. Did you mean \`meta[name=${n}]\`?`);break;case"encoding":t.push("This doesn't do anything. Did you mean `meta[charset]`?");break;case"title":t.push("This doesn't do anything. Did you mean to use the `title` tag instead?");break;case"accept-ch":case"delegate-ch":t.push("This is non-standard and may not work across browsers. Use HTTP headers instead.");break;default:t.push("This is non-standard and may not work across browsers. http-equiv is not an alternative to HTTP headers.")}return{warnings:t}}async function J(e){await e.init(),function(e,t){let n=t.getValidationWarnings(e.getHead());e.logValidationWarnings(n)}(e,x);let t=function(e,t,n){let i=e.getHead(),a=n.getHeadWeights(i).map(({element:n,weight:i})=>({weight:i,element:e.getLoggableElement(n),isValid:!t.hasValidationWarning(n),customValidations:t.getCustomValidations(n)}));e.visualizeHead("Actual",i,a);let s=Array.from(a).sort((e,t)=>t.weight-e.weight),r=document.createElement("head");return s.forEach(({element:e})=>{r.appendChild(e.cloneNode(!0))}),e.visualizeHead("Sorted",r,s),a}(e,x,h);return{actual:t.map(({element:t,weight:n,isValid:i,customValidations:a})=>(a?.payload?.expiry&&(a.payload.expiry=a.payload.expiry.toString()),{weight:n,color:e.getColor(n),selector:e.stringifyElement(t),innerHTML:t.innerHTML,isValid:i,customValidations:a}))}}async function K(){let{options:e}=await chrome.storage.sync.get("options");return new l.Options(e)}!async function(){let e=await K(),t=new r.IO(document,e),{click:n}=await chrome.storage.local.get("click");if(n)t.logElementFromSelector(JSON.parse(n)),await chrome.storage.local.remove("click");else{let e=await J(t);await chrome.storage.local.set({data:e})}}()})(); \ No newline at end of file diff --git a/crx/manifest.json b/crx/manifest.json index eb47d3b..f81d5e7 100644 --- a/crx/manifest.json +++ b/crx/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Capo: get your ﹤𝚑𝚎𝚊𝚍﹥ in order", "description": "Visualize the optimal ordering of ﹤𝚑𝚎𝚊𝚍﹥ elements on any web page", - "version": "1.4.11", + "version": "1.5.0", "permissions": [ "scripting", "activeTab", diff --git a/docs/src/content/docs/user/validation.mdx b/docs/src/content/docs/user/validation.mdx index 6eab347..0bdb46b 100644 --- a/docs/src/content/docs/user/validation.mdx +++ b/docs/src/content/docs/user/validation.mdx @@ -3,9 +3,8 @@ title: Validation description: Learn about how capo.js validates the head element --- -import { Image } from 'astro:assets'; -import ValidationAreas from '../../../assets/validation-areas.png'; - +import { Image } from "astro:assets"; +import ValidationAreas from "../../../assets/validation-areas.png"; The `<head>` element sets up all of the necessary metadata for a page to load properly and performantly. capo.js performs a number of validation checks on the `<head>` to ensure it meets modern best practices. @@ -19,7 +18,11 @@ There are a few ways to see when an element is invalid: - the element appears striped in the color bar - the expanded console entry is annotated with an ❌ icon -<Image src={ValidationAreas} alt="Console logs showing all three ways elements are flagged as invalid." loading="eager" /> +<Image + src={ValidationAreas} + alt="Console logs showing all three ways elements are flagged as invalid." + loading="eager" +/> In the example above, you can see all three ways that an element can be flagged as invalid: top-level warning, striped color bar, and warning styles in its expanded entry. @@ -62,7 +65,7 @@ If capo.js detects zero or more than one `<title>` element, it will log a valida ![Validation warning that "Expected exactly 1 title element, found 0"](../../../assets/validation-title.png) -In the example above, the `<title>` element is missing, so capo.js warns that "Expected exactly 1 `<title>` element, found 0". +In the example above, the `<title>` element is missing, so capo.js warns that "Expected exactly 1 `<title>` element, found 0". ## No more than one `<base>` element @@ -74,9 +77,30 @@ If capo.js detects more than one `<base>` element, it will log a validation warn In the example above, there is more than one `<base>` element, so capo.js warns that "Expected at most 1 `<base>` element, found 2". -## No `<meta>` CSP +## No invalid `<meta http-equiv>` elements + +There are a handful of standardized pragma directives that can be used with the `http-equiv` attribute to change browser behavior. There are even some non-standard directives that browsers still choose support, the most notable being [`origin-trial`](#no-invalid-origin-trials). And there are actually a couple of standardized directives that are non-conforming, meaning that their use is totally discouraged, specifically: `content-language` and `set-cookie`. + +:::caution +Contrary to popular belief, `http-equiv` meta tags _are not_ equivalent to HTTP headers. Learn more about why [you probably don't need `http-equiv` meta tags](https://rviscomi.dev/2023/07/you-probably-dont-need-http-equiv-meta-tags/). +::: + +Even when using a standardized, supported, and conforming `http-equiv` directive, its `content` attribute value may still be invalid. For example, the `content-type` directive _must_ declare a character encoding of UTF-8. There are some additional restrictions on this directive, specifically the requirements that it be discovered within the first 1024 bytes and it doesn't coincide with the `<meta charset>` element. + +Many of these cases are harmless no-ops, but capo.js will validate them and provide a helpful warning message with relevant metadata to help you understand whether it's safe to remove it or if anything needs to be changed for it to work properly. -According to the [W3C specification](https://w3c.github.io/webappsec-csp/#policy-delivery), a Content Security Policy (CSP) can be set as either an HTTP header or a `<meta http-equiv>` tag. +There are some cases where it's not so harmless: + +- `set-cookie` doesn't actually set any cookies, which could lead to broken behavior. +- `content-type` character encoding can be declared too late, which may lead to performance issues. +- `content-security-policy` [breaks](https://issues.chromium.org/issues/40273969) Chrome's preload scanner, which may lead to performance issues. +- `content-security-policy` directives like `frame-ancestors` and `sandbox` are not supported in meta tags and may have been set mistakenly, breaking assumptions about the page's security. +- `description` and similar metadata may have been mistakenly assigned to the `http-equiv` meta attribute instead of the intended `name` attribute, which could break assumptions about the page's SEO. +- `refresh` can reload or redirect the page and is known to cause [accessibility issues](https://www.w3.org/TR/WCAG10-HTML-TECHS/#meta-element). + +### No `<meta>` CSP + +According to the [W3C specification](https://w3c.github.io/webappsec-csp/#policy-delivery), a Content Security Policy (CSP) can be set as either an HTTP header or a `<meta http-equiv>` tag. Despite `<meta>` CSP declarations being technically valid, per the spec, browsers handle them differently. In particular, Chrome will disable the [preload scanner](https://web.dev/preload-scanner/) if it discovers a CSP declared after a `<script>` element. The preload scanner can improve performance by 20%, so this behavior has major implications on the user experience. @@ -92,7 +116,7 @@ In the example above, there is a `<meta>` CSP element, so capo.js warns that "CS This validation warning is an example of capo.js being more opinionated than simply following the specification. The warning includes a recommendation to use the CSP header instead, which avoids the preload scanner issue all together. Also note that the `Content-Security-Policy-Report-Only` directive is only valid as an HTTP header and not as a `<meta http-equiv>` element. -## No invalid origin trials +### No invalid origin trials Sites can register for [origin trials](https://developer.chrome.com/en/docs/web-platform/origin-trials/) to enable individual experimental web platform features. To enable them on a given site, a token must be included as either an `Origin-Trial` HTTP header or `<meta http-equiv>` element. @@ -123,3 +147,35 @@ In the example above, two separate embedded third party scripts injected origin In the first warning, the token contains an invalid origin. The token metadata is missing the `isThirdParty` flag and the `origin` property is set `https://googlesyndication.com:443`, which is presumably the third party that injected the token. However, because the origin of the page is different from the one in the origin trial metadata, and it wasn't registered as a third party token, it's not valid. A similar warning would appear if the origin of the page is `https://www.example.com` but the origin in the metadata is `https://example.com:443` and it's missing the `isSubdomain` flag. In the second warning, the origin is a valid third party, but the token is expired. In the token metadata, you can see that it expired in November 2022. + +### No invalid `default-style` directives + +The `default-style` directive indicates which [alternative stylesheet](https://developer.mozilla.org/en-US/docs/Web/CSS/Alternative_style_sheets) should be enabled. While it's completely standard and supported across browsers, [adoption is extremely low](https://rviscomi.dev/2023/07/you-probably-dont-need-http-equiv-meta-tags/#http-equiv-adoption) at only about 1k websites. Of those, about [31%](https://rviscomi.dev/2023/07/you-probably-dont-need-http-equiv-meta-tags/#default-style) are used incorrectly. + +To be used correctly, the `content` attribute of the directive must be equal to the `title` value of an alternative stylesheet. capo.js will warn when the title cannot be found and the directive has no effect. + +In addition to flagging incorrect usage, capo.js will also discourage using this directive entirely. Setting a preferred stylesheet results in a flash of unstyled content, which could be avoided by using default stylesheets with `@media` rules instead. + +### No invalid character encoding + +The `content-type` directive is an alias for the `charset` meta tag, which declares the document's content encoding. The [HTML spec](https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-http-equiv-content-type) places strict requirements on how and where this declaration can occur: + +- The document cannot have both a `content-type` directive and a `charset` meta tag +- If one does exist, the character encoding declaration must be found within the firsts 1024 bytes of the document +- If one does exist, the character encoding must be set to UTF-8 + +It's valid for a document not to have either `content-type` nor `charset` meta tags, as long as the `Content-Type` HTTP header is set, also to UTF-8. capo.js does not presently validate HTTP headers, only the contents of the document `<head>`. + +capo.js will validate that all three of the requirements above are met, and log a warning if not. If there are redundant character encoding declarations, capo.js will warn on the `content-type` element, giving preference to the `charset` meta tag. If the declaration occurs too late, capo.js will include the byte index for reference. And if the encoding is not set to UTF-8, capo.js will log the actual encoding used. + +### No meta refresh + +The `refresh` directive can force the page to reload or redirect after a specified amount of time. This is considered an [accessibility issue](https://www.w3.org/TR/WCAG10-CORE-TECHS/#auto-page-refresh) by the WCAG. It can be disorienting to users for the page to suddenly reload or redirect, and there are more semantic ways to indicate that a page's contents have moved. + +capo.js issues a warning whenever a meta `refresh` directive is found. If it contains a redirect, capo.js encourages using HTTP 3xx redirects instead. Otherwise, it encourages including a mechanism for users to disable the auto-reloading behavior, if one is not already provided by browsers. + +### Don't disable DNS prefetching + +The [`x-dns-prefetch-control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-DNS-Prefetch-Control) HTTP header and meta directive are non-standard, but widely supported. By default, most browsers will speculatively resolve the DNS records for URLs needed by the page, which is a generally accepted performance optimization. + +In Chrome, any value other than `on` will disable the default DNS prefetching behavior. capo.js will always warn when this directive is found, and the message will be unique to the usage. In cases when DNS prefetching is explicitly enabled, capo.js clarifies that this has no effect as it's already the default behavior. In cases where it's set to `off`, capo.js emphasizes the performance benefits of DNS prefetching and clarifies that it should only be disabled when there are legitimate security concerns. For all other values besides `on` and `off`, capo.js warns about using non-standard values and reiterates the performance and security considerations. diff --git a/docs/src/lib/capo.js b/docs/src/lib/capo.js index f17713d..bc29397 100644 --- a/docs/src/lib/capo.js +++ b/docs/src/lib/capo.js @@ -169,11 +169,11 @@ class $33f7359dc421be0c$export$8f8422ac5947a789 { if (warnings?.length) { // Element-specific warnings. loggingLevel = "warn"; - warnings.forEach((warning)=>args.push(`❌ ${warning}`)); + args.push("\n" + warnings.map((warning)=>` ❌ ${warning}`).join("\n")); } else if (!isValid && (this.options.prefersDynamicAssessment() || this.isStaticHead)) { // General warnings. loggingLevel = "warn"; - args.push(`❌ invalid element (${element.tagName})`); + args.push(`\n ❌ invalid element (${element.tagName})`); } this.console[loggingLevel](...args); } @@ -433,6 +433,8 @@ function $ee7e0c73e51ebfda$export$5cc4a311ddbe699c(head) { var $c322f9a5057eaf5c$exports = {}; $parcel$export($c322f9a5057eaf5c$exports, "VALID_HEAD_ELEMENTS", () => $c322f9a5057eaf5c$export$79e124b7caef7aa9); +$parcel$export($c322f9a5057eaf5c$exports, "CONTENT_TYPE_SELECTOR", () => $c322f9a5057eaf5c$export$2f975f13375faaa1); +$parcel$export($c322f9a5057eaf5c$exports, "HTTP_EQUIV_SELECTOR", () => $c322f9a5057eaf5c$export$9739336dee0b3205); $parcel$export($c322f9a5057eaf5c$exports, "PRELOAD_SELECTOR", () => $c322f9a5057eaf5c$export$5540ac2a18901364); $parcel$export($c322f9a5057eaf5c$exports, "isValidElement", () => $c322f9a5057eaf5c$export$a8257692ac88316c); $parcel$export($c322f9a5057eaf5c$exports, "hasValidationWarning", () => $c322f9a5057eaf5c$export$eeefd08c3a6f8db7); @@ -449,6 +451,8 @@ const $c322f9a5057eaf5c$export$79e124b7caef7aa9 = new Set([ "template", "title" ]); +const $c322f9a5057eaf5c$export$2f975f13375faaa1 = 'meta[http-equiv="content-type" i], meta[charset]'; +const $c322f9a5057eaf5c$export$9739336dee0b3205 = "meta[http-equiv]"; const $c322f9a5057eaf5c$export$5540ac2a18901364 = 'link:is([rel="preload" i], [rel="modulepreload" i])'; function $c322f9a5057eaf5c$export$a8257692ac88316c(element) { return $c322f9a5057eaf5c$export$79e124b7caef7aa9.has(element.tagName.toLowerCase()); @@ -464,7 +468,13 @@ function $c322f9a5057eaf5c$export$eeefd08c3a6f8db7(element) { if (element.matches("base:has(~ base), base ~ base")) return true; // CSP meta tag anywhere. if ((0, $ee7e0c73e51ebfda$export$14b1a2f64a600585)(element)) return true; - // Origin trial expired or cross-origin. + // Invalid http-equiv. + if ($c322f9a5057eaf5c$var$isInvalidHttpEquiv(element)) return true; + // Invalid default-style. + if ($c322f9a5057eaf5c$var$isInvalidDefaultStyle(element)) return true; + // Invalid character encoding. + if ($c322f9a5057eaf5c$var$isInvalidContentType(element)) return true; + // Origin trial expired, or invalid origin. if ($c322f9a5057eaf5c$var$isInvalidOriginTrial(element)) return true; // Preload is unnecessary. if ($c322f9a5057eaf5c$var$isUnnecessaryPreload(element)) return true; @@ -515,6 +525,9 @@ function $c322f9a5057eaf5c$export$b01ab94d0cd042a0(head) { function $c322f9a5057eaf5c$export$6c93e2175c028eeb(element) { if ((0, $ee7e0c73e51ebfda$export$38a04d482ec50f88)(element)) return $c322f9a5057eaf5c$var$validateOriginTrial(element); if ((0, $ee7e0c73e51ebfda$export$14b1a2f64a600585)(element)) return $c322f9a5057eaf5c$var$validateCSP(element); + if ($c322f9a5057eaf5c$var$isDefaultStyle(element)) return $c322f9a5057eaf5c$var$validateDefaultStyle(element); + if ($c322f9a5057eaf5c$var$isContentType(element)) return $c322f9a5057eaf5c$var$validateContentType(element); + if ($c322f9a5057eaf5c$var$isHttpEquiv(element)) return $c322f9a5057eaf5c$var$validateHttpEquiv(element); if ($c322f9a5057eaf5c$var$isUnnecessaryPreload(element)) return $c322f9a5057eaf5c$var$validateUnnecessaryPreload(element); return {}; } @@ -547,8 +560,15 @@ function $c322f9a5057eaf5c$var$validateOriginTrial(element) { if (metadata.payload.expiry < new Date()) metadata.warnings.push("expired"); if (!$c322f9a5057eaf5c$var$isSameOrigin(metadata.payload.origin, document.location.href)) { const subdomain = $c322f9a5057eaf5c$var$isSubdomain(metadata.payload.origin, document.location.href); + console.log({ + subdomain: subdomain, + payload: metadata.payload + }); + // Cross-origin OTs are only valid if: + // 1. The document is a subdomain of the OT origin and the isSubdomain config is set + // 2. The isThirdParty config is set if (subdomain && !metadata.payload.isSubdomain) metadata.warnings.push("invalid subdomain"); - else if (!subdomain && !metadata.payload.isThirdParty) metadata.warnings.push("invalid origin"); + else if (!subdomain && !metadata.payload.isThirdParty) metadata.warnings.push("invalid third-party origin"); } return metadata; } @@ -558,8 +578,8 @@ function $c322f9a5057eaf5c$var$decodeOriginTrialToken(token) { ...atob(token) ].map((a)=>a.charCodeAt(0))); const view = new DataView(buffer.buffer); - const length = view.getUint32(65, false); - const payload = JSON.parse(new TextDecoder().decode(buffer.slice(69, 69 + length))); + const length1 = view.getUint32(65, false); + const payload = JSON.parse(new TextDecoder().decode(buffer.slice(69, 69 + length1))); payload.expiry = new Date(payload.expiry * 1000); return payload; } @@ -573,6 +593,30 @@ function $c322f9a5057eaf5c$var$isSubdomain(a, b) { b = new URL(b); return b.host.endsWith(`.${a.host}`); } +function $c322f9a5057eaf5c$var$isDefaultStyle(element) { + return element.matches('meta[http-equiv="default-style" i]'); +} +function $c322f9a5057eaf5c$var$isContentType(element) { + return element.matches($c322f9a5057eaf5c$export$2f975f13375faaa1); +} +function $c322f9a5057eaf5c$var$isInvalidDefaultStyle(element) { + if (!$c322f9a5057eaf5c$var$isDefaultStyle(element)) return false; + const { warnings: warnings } = $c322f9a5057eaf5c$var$validateDefaultStyle(element); + return warnings.length > 0; +} +function $c322f9a5057eaf5c$var$isInvalidContentType(element) { + if (!$c322f9a5057eaf5c$var$isContentType(element)) return false; + const { warnings: warnings } = $c322f9a5057eaf5c$var$validateContentType(element); + return warnings.length > 0; +} +function $c322f9a5057eaf5c$var$isHttpEquiv(element) { + return element.matches($c322f9a5057eaf5c$export$9739336dee0b3205); +} +function $c322f9a5057eaf5c$var$isInvalidHttpEquiv(element) { + if (!$c322f9a5057eaf5c$var$isHttpEquiv(element)) return false; + const { warnings: warnings } = $c322f9a5057eaf5c$var$validateHttpEquiv(element); + return warnings.length > 0; +} function $c322f9a5057eaf5c$var$isUnnecessaryPreload(element) { if (!element.matches($c322f9a5057eaf5c$export$5540ac2a18901364)) return false; const href = element.getAttribute("href"); @@ -591,6 +635,145 @@ function $c322f9a5057eaf5c$var$findElementWithSource(root, sourceUrl) { function $c322f9a5057eaf5c$var$absolutifyUrl(href) { return new URL(href, document.baseURI).href; } +function $c322f9a5057eaf5c$var$validateDefaultStyle(element) { + const warnings = []; + let payload = null; + // Check if the value points to an alternate stylesheet with that title + const title = element.getAttribute("content"); + const stylesheet = element.parentElement.querySelector(`link[rel~="alternate" i][rel~="stylesheet" i][title="${title}"]`); + if (!title) warnings.push("This has no effect. The content attribute must be set to a valid stylesheet title."); + else if (!stylesheet) { + payload = { + alternateStylesheets: Array.from(element.parentElement.querySelectorAll('link[rel~="alternate" i][rel~="stylesheet" i]')) + }; + warnings.push(`This has no effect. No alternate stylesheet found having title="${title}".`); + } + warnings.push("Even when used correctly, the default-style method of setting a preferred stylesheet results in a flash of unstyled content. Use modern CSS features like @media rules instead."); + return { + warnings: warnings, + payload: payload + }; +} +function $c322f9a5057eaf5c$var$validateContentType(element) { + const warnings = []; + let payload = null; + // https://html.spec.whatwg.org/multipage/semantics.html#character-encoding-declaration + // Check if there exists both meta[http-equiv] and meta[chartset] variations + if (element.matches(':is(meta[charset] ~ meta[http-equiv="content-type" i])') || element.matches(":has(~ meta[charset])")) { + const encodingDeclaration = element.parentElement.querySelector("meta[charset]"); + payload = payload ?? {}; + payload.encodingDeclaration = encodingDeclaration; + warnings.push(`There can only be one meta-based character encoding declaration per document. Already found \`${encodingDeclaration.outerHTML}\`.`); + } + // Check if it compeltely exists in the first 1024 bytes + const charPos = element.ownerDocument.documentElement.outerHTML.indexOf(element.outerHTML) + element.outerHTML.length; + if (charPos > 1024) { + payload = payload ?? {}; + payload.characterPosition = charPos; + warnings.push(`The element containing the character encoding declaration must be serialized completely within the first 1024 bytes of the document. Found at byte ${charPos}.`); + } + // Check that the character encoding is UTF-8 + let charset = null; + if (element.matches("meta[charset]")) charset = element.getAttribute("charset"); + else { + const charsetPattern = /text\/html;\s*charset=(.*)/i; + charset = element.getAttribute("content")?.match(charsetPattern)?.[1]; + } + if (charset.toLowerCase() != "utf-8") { + payload = payload ?? {}; + payload.charset = charset; + warnings.push(`Documents are required to use UTF-8 encoding. Found "${charset}".`); + } + if (warnings.length) // Append the spec source to the last warning + warnings[length - 1] = warnings.at(-1) + "\nLearn more: https://html.spec.whatwg.org/multipage/semantics.html#character-encoding-declaration"; + return { + warnings: warnings, + payload: payload + }; +} +function $c322f9a5057eaf5c$var$validateHttpEquiv(element) { + const warnings = []; + const type = element.getAttribute("http-equiv").toLowerCase(); + const content = element.getAttribute("content").toLowerCase(); + switch(type){ + case "content-security-policy": + case "content-security-policy-report-only": + case "origin-trial": + case "content-type": + case "default-style": + break; + case "refresh": + if (content.includes("url=")) warnings.push("Meta auto-redirects are discouraged. Use HTTP 3XX responses instead."); + else warnings.push("Meta auto-refreshes are discouraged unless users have the ability to disable it."); + break; + case "x-dns-prefetch-control": + if (content == "on") warnings.push(`DNS prefetching is enabled by default. Setting it to "${content}" has no effect.`); + else if (content != "off") warnings.push(`This is a non-standard way of disabling DNS prefetching, which is a performance optimization. Found content="${content}". Use content="off" if you have a legitimate security concern, otherwise remove it.`); + else warnings.push("This is non-standard, however most browsers support disabling speculative DNS prefetching. It should still be noted that DNS prefetching is a generally accepted performance optimization and you should only disable it if you have specific security concerns."); + break; + case "cache-control": + case "etag": + case "pragma": + case "expires": + case "last-modified": + warnings.push("This doesn't do anything. Use HTTP headers for any cache directives."); + break; + case "x-frame-options": + warnings.push("This doesn't do anything. Use the CSP HTTP header with the frame-ancestors directive instead."); + break; + case "x-ua-compatible": + case "content-style-type": + case "content-script-type": + case "imagetoolbar": + case "cleartype": + case "page-enter": + case "page-exit": + case "site-enter": + case "site-exit": + case "msthemecompatible": + case "window-target": + warnings.push("This doesn't do anything. It was an Internet Explorer feature and is now deprecated."); + break; + case "content-language": + case "language": + warnings.push("This is non-conforming. Use the html[lang] attribute instead."); + break; + case "set-cookie": + warnings.push("This is non-conforming. Use the Set-Cookie HTTP header instead."); + break; + case "application-name": + case "author": + case "description": + case "generator": + case "keywords": + case "referrer": + case "theme-color": + case "color-scheme": + case "viewport": + case "creator": + case "googlebot": + case "publisher": + case "robots": + warnings.push(`This doesn't do anything. Did you mean \`meta[name=${type}]\`?`); + break; + case "encoding": + warnings.push("This doesn't do anything. Did you mean `meta[charset]`?"); + break; + case "title": + warnings.push("This doesn't do anything. Did you mean to use the `title` tag instead?"); + break; + case "accept-ch": + case "delegate-ch": + warnings.push("This is non-standard and may not work across browsers. Use HTTP headers instead."); + break; + default: + warnings.push("This is non-standard and may not work across browsers. http-equiv is not an alternative to HTTP headers."); + break; + } + return { + warnings: warnings + }; +} function $c322f9a5057eaf5c$var$validateUnnecessaryPreload(element) { const href = element.getAttribute("href"); const preloadedUrl = $c322f9a5057eaf5c$var$absolutifyUrl(href); diff --git a/package.json b/package.json index 6e579b3..99f842e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rviscomi/capo.js", - "version": "1.4.11", + "version": "1.5.0", "description": "Get your ﹤𝚑𝚎𝚊𝚍﹥ in order", "author": "Rick Viscomi", "license": "Apache-2.0", diff --git a/snippet/capo.js b/snippet/capo.js index 6a7bfde..226feb8 100644 --- a/snippet/capo.js +++ b/snippet/capo.js @@ -170,11 +170,11 @@ class $d410929ede0a2ee4$export$8f8422ac5947a789 { if (warnings?.length) { // Element-specific warnings. loggingLevel = "warn"; - warnings.forEach((warning)=>args.push(`❌ ${warning}`)); + args.push("\n" + warnings.map((warning)=>` ❌ ${warning}`).join("\n")); } else if (!isValid && (this.options.prefersDynamicAssessment() || this.isStaticHead)) { // General warnings. loggingLevel = "warn"; - args.push(`❌ invalid element (${element.tagName})`); + args.push(`\n ❌ invalid element (${element.tagName})`); } this.console[loggingLevel](...args); } @@ -434,6 +434,8 @@ function $9c3989fcb9437829$export$5cc4a311ddbe699c(head) { var $580f7ed6bc170ae8$exports = {}; $parcel$export($580f7ed6bc170ae8$exports, "VALID_HEAD_ELEMENTS", () => $580f7ed6bc170ae8$export$79e124b7caef7aa9); +$parcel$export($580f7ed6bc170ae8$exports, "CONTENT_TYPE_SELECTOR", () => $580f7ed6bc170ae8$export$2f975f13375faaa1); +$parcel$export($580f7ed6bc170ae8$exports, "HTTP_EQUIV_SELECTOR", () => $580f7ed6bc170ae8$export$9739336dee0b3205); $parcel$export($580f7ed6bc170ae8$exports, "PRELOAD_SELECTOR", () => $580f7ed6bc170ae8$export$5540ac2a18901364); $parcel$export($580f7ed6bc170ae8$exports, "isValidElement", () => $580f7ed6bc170ae8$export$a8257692ac88316c); $parcel$export($580f7ed6bc170ae8$exports, "hasValidationWarning", () => $580f7ed6bc170ae8$export$eeefd08c3a6f8db7); @@ -450,6 +452,8 @@ const $580f7ed6bc170ae8$export$79e124b7caef7aa9 = new Set([ "template", "title" ]); +const $580f7ed6bc170ae8$export$2f975f13375faaa1 = 'meta[http-equiv="content-type" i], meta[charset]'; +const $580f7ed6bc170ae8$export$9739336dee0b3205 = "meta[http-equiv]"; const $580f7ed6bc170ae8$export$5540ac2a18901364 = 'link:is([rel="preload" i], [rel="modulepreload" i])'; function $580f7ed6bc170ae8$export$a8257692ac88316c(element) { return $580f7ed6bc170ae8$export$79e124b7caef7aa9.has(element.tagName.toLowerCase()); @@ -465,7 +469,13 @@ function $580f7ed6bc170ae8$export$eeefd08c3a6f8db7(element) { if (element.matches("base:has(~ base), base ~ base")) return true; // CSP meta tag anywhere. if ((0, $9c3989fcb9437829$export$14b1a2f64a600585)(element)) return true; - // Origin trial expired or cross-origin. + // Invalid http-equiv. + if ($580f7ed6bc170ae8$var$isInvalidHttpEquiv(element)) return true; + // Invalid default-style. + if ($580f7ed6bc170ae8$var$isInvalidDefaultStyle(element)) return true; + // Invalid character encoding. + if ($580f7ed6bc170ae8$var$isInvalidContentType(element)) return true; + // Origin trial expired, or invalid origin. if ($580f7ed6bc170ae8$var$isInvalidOriginTrial(element)) return true; // Preload is unnecessary. if ($580f7ed6bc170ae8$var$isUnnecessaryPreload(element)) return true; @@ -516,6 +526,9 @@ function $580f7ed6bc170ae8$export$b01ab94d0cd042a0(head) { function $580f7ed6bc170ae8$export$6c93e2175c028eeb(element) { if ((0, $9c3989fcb9437829$export$38a04d482ec50f88)(element)) return $580f7ed6bc170ae8$var$validateOriginTrial(element); if ((0, $9c3989fcb9437829$export$14b1a2f64a600585)(element)) return $580f7ed6bc170ae8$var$validateCSP(element); + if ($580f7ed6bc170ae8$var$isDefaultStyle(element)) return $580f7ed6bc170ae8$var$validateDefaultStyle(element); + if ($580f7ed6bc170ae8$var$isContentType(element)) return $580f7ed6bc170ae8$var$validateContentType(element); + if ($580f7ed6bc170ae8$var$isHttpEquiv(element)) return $580f7ed6bc170ae8$var$validateHttpEquiv(element); if ($580f7ed6bc170ae8$var$isUnnecessaryPreload(element)) return $580f7ed6bc170ae8$var$validateUnnecessaryPreload(element); return {}; } @@ -548,8 +561,15 @@ function $580f7ed6bc170ae8$var$validateOriginTrial(element) { if (metadata.payload.expiry < new Date()) metadata.warnings.push("expired"); if (!$580f7ed6bc170ae8$var$isSameOrigin(metadata.payload.origin, document.location.href)) { const subdomain = $580f7ed6bc170ae8$var$isSubdomain(metadata.payload.origin, document.location.href); + console.log({ + subdomain: subdomain, + payload: metadata.payload + }); + // Cross-origin OTs are only valid if: + // 1. The document is a subdomain of the OT origin and the isSubdomain config is set + // 2. The isThirdParty config is set if (subdomain && !metadata.payload.isSubdomain) metadata.warnings.push("invalid subdomain"); - else if (!subdomain && !metadata.payload.isThirdParty) metadata.warnings.push("invalid origin"); + else if (!subdomain && !metadata.payload.isThirdParty) metadata.warnings.push("invalid third-party origin"); } return metadata; } @@ -559,8 +579,8 @@ function $580f7ed6bc170ae8$var$decodeOriginTrialToken(token) { ...atob(token) ].map((a)=>a.charCodeAt(0))); const view = new DataView(buffer.buffer); - const length = view.getUint32(65, false); - const payload = JSON.parse(new TextDecoder().decode(buffer.slice(69, 69 + length))); + const length1 = view.getUint32(65, false); + const payload = JSON.parse(new TextDecoder().decode(buffer.slice(69, 69 + length1))); payload.expiry = new Date(payload.expiry * 1000); return payload; } @@ -574,6 +594,30 @@ function $580f7ed6bc170ae8$var$isSubdomain(a, b) { b = new URL(b); return b.host.endsWith(`.${a.host}`); } +function $580f7ed6bc170ae8$var$isDefaultStyle(element) { + return element.matches('meta[http-equiv="default-style" i]'); +} +function $580f7ed6bc170ae8$var$isContentType(element) { + return element.matches($580f7ed6bc170ae8$export$2f975f13375faaa1); +} +function $580f7ed6bc170ae8$var$isInvalidDefaultStyle(element) { + if (!$580f7ed6bc170ae8$var$isDefaultStyle(element)) return false; + const { warnings: warnings } = $580f7ed6bc170ae8$var$validateDefaultStyle(element); + return warnings.length > 0; +} +function $580f7ed6bc170ae8$var$isInvalidContentType(element) { + if (!$580f7ed6bc170ae8$var$isContentType(element)) return false; + const { warnings: warnings } = $580f7ed6bc170ae8$var$validateContentType(element); + return warnings.length > 0; +} +function $580f7ed6bc170ae8$var$isHttpEquiv(element) { + return element.matches($580f7ed6bc170ae8$export$9739336dee0b3205); +} +function $580f7ed6bc170ae8$var$isInvalidHttpEquiv(element) { + if (!$580f7ed6bc170ae8$var$isHttpEquiv(element)) return false; + const { warnings: warnings } = $580f7ed6bc170ae8$var$validateHttpEquiv(element); + return warnings.length > 0; +} function $580f7ed6bc170ae8$var$isUnnecessaryPreload(element) { if (!element.matches($580f7ed6bc170ae8$export$5540ac2a18901364)) return false; const href = element.getAttribute("href"); @@ -592,6 +636,145 @@ function $580f7ed6bc170ae8$var$findElementWithSource(root, sourceUrl) { function $580f7ed6bc170ae8$var$absolutifyUrl(href) { return new URL(href, document.baseURI).href; } +function $580f7ed6bc170ae8$var$validateDefaultStyle(element) { + const warnings = []; + let payload = null; + // Check if the value points to an alternate stylesheet with that title + const title = element.getAttribute("content"); + const stylesheet = element.parentElement.querySelector(`link[rel~="alternate" i][rel~="stylesheet" i][title="${title}"]`); + if (!title) warnings.push("This has no effect. The content attribute must be set to a valid stylesheet title."); + else if (!stylesheet) { + payload = { + alternateStylesheets: Array.from(element.parentElement.querySelectorAll('link[rel~="alternate" i][rel~="stylesheet" i]')) + }; + warnings.push(`This has no effect. No alternate stylesheet found having title="${title}".`); + } + warnings.push("Even when used correctly, the default-style method of setting a preferred stylesheet results in a flash of unstyled content. Use modern CSS features like @media rules instead."); + return { + warnings: warnings, + payload: payload + }; +} +function $580f7ed6bc170ae8$var$validateContentType(element) { + const warnings = []; + let payload = null; + // https://html.spec.whatwg.org/multipage/semantics.html#character-encoding-declaration + // Check if there exists both meta[http-equiv] and meta[chartset] variations + if (element.matches(':is(meta[charset] ~ meta[http-equiv="content-type" i])') || element.matches(":has(~ meta[charset])")) { + const encodingDeclaration = element.parentElement.querySelector("meta[charset]"); + payload = payload ?? {}; + payload.encodingDeclaration = encodingDeclaration; + warnings.push(`There can only be one meta-based character encoding declaration per document. Already found \`${encodingDeclaration.outerHTML}\`.`); + } + // Check if it compeltely exists in the first 1024 bytes + const charPos = element.ownerDocument.documentElement.outerHTML.indexOf(element.outerHTML) + element.outerHTML.length; + if (charPos > 1024) { + payload = payload ?? {}; + payload.characterPosition = charPos; + warnings.push(`The element containing the character encoding declaration must be serialized completely within the first 1024 bytes of the document. Found at byte ${charPos}.`); + } + // Check that the character encoding is UTF-8 + let charset = null; + if (element.matches("meta[charset]")) charset = element.getAttribute("charset"); + else { + const charsetPattern = /text\/html;\s*charset=(.*)/i; + charset = element.getAttribute("content")?.match(charsetPattern)?.[1]; + } + if (charset.toLowerCase() != "utf-8") { + payload = payload ?? {}; + payload.charset = charset; + warnings.push(`Documents are required to use UTF-8 encoding. Found "${charset}".`); + } + if (warnings.length) // Append the spec source to the last warning + warnings[length - 1] = warnings.at(-1) + "\nLearn more: https://html.spec.whatwg.org/multipage/semantics.html#character-encoding-declaration"; + return { + warnings: warnings, + payload: payload + }; +} +function $580f7ed6bc170ae8$var$validateHttpEquiv(element) { + const warnings = []; + const type = element.getAttribute("http-equiv").toLowerCase(); + const content = element.getAttribute("content").toLowerCase(); + switch(type){ + case "content-security-policy": + case "content-security-policy-report-only": + case "origin-trial": + case "content-type": + case "default-style": + break; + case "refresh": + if (content.includes("url=")) warnings.push("Meta auto-redirects are discouraged. Use HTTP 3XX responses instead."); + else warnings.push("Meta auto-refreshes are discouraged unless users have the ability to disable it."); + break; + case "x-dns-prefetch-control": + if (content == "on") warnings.push(`DNS prefetching is enabled by default. Setting it to "${content}" has no effect.`); + else if (content != "off") warnings.push(`This is a non-standard way of disabling DNS prefetching, which is a performance optimization. Found content="${content}". Use content="off" if you have a legitimate security concern, otherwise remove it.`); + else warnings.push("This is non-standard, however most browsers support disabling speculative DNS prefetching. It should still be noted that DNS prefetching is a generally accepted performance optimization and you should only disable it if you have specific security concerns."); + break; + case "cache-control": + case "etag": + case "pragma": + case "expires": + case "last-modified": + warnings.push("This doesn't do anything. Use HTTP headers for any cache directives."); + break; + case "x-frame-options": + warnings.push("This doesn't do anything. Use the CSP HTTP header with the frame-ancestors directive instead."); + break; + case "x-ua-compatible": + case "content-style-type": + case "content-script-type": + case "imagetoolbar": + case "cleartype": + case "page-enter": + case "page-exit": + case "site-enter": + case "site-exit": + case "msthemecompatible": + case "window-target": + warnings.push("This doesn't do anything. It was an Internet Explorer feature and is now deprecated."); + break; + case "content-language": + case "language": + warnings.push("This is non-conforming. Use the html[lang] attribute instead."); + break; + case "set-cookie": + warnings.push("This is non-conforming. Use the Set-Cookie HTTP header instead."); + break; + case "application-name": + case "author": + case "description": + case "generator": + case "keywords": + case "referrer": + case "theme-color": + case "color-scheme": + case "viewport": + case "creator": + case "googlebot": + case "publisher": + case "robots": + warnings.push(`This doesn't do anything. Did you mean \`meta[name=${type}]\`?`); + break; + case "encoding": + warnings.push("This doesn't do anything. Did you mean `meta[charset]`?"); + break; + case "title": + warnings.push("This doesn't do anything. Did you mean to use the `title` tag instead?"); + break; + case "accept-ch": + case "delegate-ch": + warnings.push("This is non-standard and may not work across browsers. Use HTTP headers instead."); + break; + default: + warnings.push("This is non-standard and may not work across browsers. http-equiv is not an alternative to HTTP headers."); + break; + } + return { + warnings: warnings + }; +} function $580f7ed6bc170ae8$var$validateUnnecessaryPreload(element) { const href = element.getAttribute("href"); const preloadedUrl = $580f7ed6bc170ae8$var$absolutifyUrl(href); diff --git a/src/lib/io.js b/src/lib/io.js index b5b48e0..482f602 100644 --- a/src/lib/io.js +++ b/src/lib/io.js @@ -22,8 +22,7 @@ export class IO { try { let html = await this.getStaticHTML(); html = html.replace(/(\<\/?)(head)/gi, "$1static-head"); - const staticDoc = - this.document.implementation.createHTMLDocument("New Document"); + const staticDoc = this.document.implementation.createHTMLDocument("New Document"); staticDoc.documentElement.innerHTML = html; this.head = staticDoc.querySelector("static-head"); @@ -33,10 +32,7 @@ export class IO { this.head = this.document.head; } } catch (e) { - this.console.error( - `${this.options.loggingPrefix}An exception occurred while getting the static <head>:`, - e - ); + this.console.error(`${this.options.loggingPrefix}An exception occurred while getting the static <head>:`, e); this.head = this.document.head; } @@ -60,9 +56,7 @@ export class IO { stringifyElement(element) { return element.getAttributeNames().reduce((id, attr) => { - return (id += `[${CSS.escape(attr)}=${JSON.stringify( - element.getAttribute(attr) - )}]`); + return (id += `[${CSS.escape(attr)}=${JSON.stringify(element.getAttribute(attr))}]`); }, element.nodeName); } @@ -72,9 +66,7 @@ export class IO { } const selector = this.stringifyElement(element); - const candidates = Array.from( - this.document.head.querySelectorAll(selector) - ); + const candidates = Array.from(this.document.head.querySelectorAll(selector)); if (candidates.length == 0) { return element; } @@ -129,13 +121,7 @@ export class IO { return element; } - logElementFromSelector({ - weight, - selector, - innerHTML, - isValid, - customValidations = {}, - }) { + logElementFromSelector({ weight, selector, innerHTML, isValid, customValidations = {} }) { weight = +weight; const viz = this.getElementVisualization(weight); let element = this.createElementFromSelector(selector); @@ -145,14 +131,7 @@ export class IO { this.logElement({ viz, weight, element, isValid, customValidations }); } - logElement({ - viz, - weight, - element, - isValid, - customValidations, - omitPrefix = false, - }) { + logElement({ viz, weight, element, isValid, customValidations, omitPrefix = false }) { if (!omitPrefix) { viz.visual = `${this.options.loggingPrefix}${viz.visual}`; } @@ -177,14 +156,11 @@ export class IO { if (warnings?.length) { // Element-specific warnings. loggingLevel = "warn"; - warnings.forEach((warning) => args.push(`❌ ${warning}`)); - } else if ( - !isValid && - (this.options.prefersDynamicAssessment() || this.isStaticHead) - ) { + args.push("\n" + warnings.map((warning) => ` ❌ ${warning}`).join("\n")); + } else if (!isValid && (this.options.prefersDynamicAssessment() || this.isStaticHead)) { // General warnings. loggingLevel = "warn"; - args.push(`❌ invalid element (${element.tagName})`); + args.push(`\n ❌ invalid element (${element.tagName})`); } this.console[loggingLevel](...args); @@ -197,11 +173,7 @@ export class IO { warnings.forEach(({ warning, elements = [], element }) => { elements = elements.map(this.getLoggableElement.bind(this)); - this.console.warn( - `${this.options.loggingPrefix}${warning}`, - ...elements, - element || "" - ); + this.console.warn(`${this.options.loggingPrefix}${warning}`, ...elements, element || ""); }); } @@ -261,12 +233,7 @@ export class IO { }); }); - this.console.log( - `${groupName} %chead%c element`, - "font-family: monospace", - "font-family: inherit", - headElement - ); + this.console.log(`${groupName} %chead%c element`, "font-family: monospace", "font-family: inherit", headElement); this.console.groupEnd(); } diff --git a/src/lib/validation.js b/src/lib/validation.js index d904631..17e903c 100644 --- a/src/lib/validation.js +++ b/src/lib/validation.js @@ -11,8 +11,11 @@ export const VALID_HEAD_ELEMENTS = new Set([ "title", ]); -export const PRELOAD_SELECTOR = - 'link:is([rel="preload" i], [rel="modulepreload" i])'; +export const CONTENT_TYPE_SELECTOR = 'meta[http-equiv="content-type" i], meta[charset]'; + +export const HTTP_EQUIV_SELECTOR = "meta[http-equiv]"; + +export const PRELOAD_SELECTOR = 'link:is([rel="preload" i], [rel="modulepreload" i])'; export function isValidElement(element) { return VALID_HEAD_ELEMENTS.has(element.tagName.toLowerCase()); @@ -25,9 +28,7 @@ export function hasValidationWarning(element) { } // Children are not valid. - if ( - element.matches(`:has(:not(${Array.from(VALID_HEAD_ELEMENTS).join(", ")}))`) - ) { + if (element.matches(`:has(:not(${Array.from(VALID_HEAD_ELEMENTS).join(", ")}))`)) { return true; } @@ -46,7 +47,22 @@ export function hasValidationWarning(element) { return true; } - // Origin trial expired or cross-origin. + // Invalid http-equiv. + if (isInvalidHttpEquiv(element)) { + return true; + } + + // Invalid default-style. + if (isInvalidDefaultStyle(element)) { + return true; + } + + // Invalid character encoding. + if (isInvalidContentType(element)) { + return true; + } + + // Origin trial expired, or invalid origin. if (isInvalidOriginTrial(element)) { return true; } @@ -80,9 +96,7 @@ export function getValidationWarnings(head) { }); } - const metaCSP = head.querySelector( - 'meta[http-equiv="Content-Security-Policy" i]' - ); + const metaCSP = head.querySelector('meta[http-equiv="Content-Security-Policy" i]'); if (metaCSP) { validationWarnings.push({ warning: @@ -107,9 +121,7 @@ export function getValidationWarnings(head) { }); }); - const originTrials = Array.from( - head.querySelectorAll('meta[http-equiv="Origin-Trial" i]') - ); + const originTrials = Array.from(head.querySelectorAll('meta[http-equiv="Origin-Trial" i]')); originTrials.forEach((element) => { const metadata = validateOriginTrial(element); @@ -136,6 +148,18 @@ export function getCustomValidations(element) { return validateCSP(element); } + if (isDefaultStyle(element)) { + return validateDefaultStyle(element); + } + + if (isContentType(element)) { + return validateContentType(element); + } + + if (isHttpEquiv(element)) { + return validateHttpEquiv(element); + } + if (isUnnecessaryPreload(element)) { return validateUnnecessaryPreload(element); } @@ -146,9 +170,7 @@ export function getCustomValidations(element) { function validateCSP(element) { const warnings = []; - if ( - element.matches('meta[http-equiv="Content-Security-Policy-Report-Only" i]') - ) { + if (element.matches('meta[http-equiv="Content-Security-Policy-Report-Only" i]')) { //https://w3c.github.io/webappsec-csp/#meta-element warnings.push("CSP Report-Only is forbidden in meta tags"); } else if (element.matches('meta[http-equiv="Content-Security-Policy" i]')) { @@ -189,17 +211,15 @@ function validateOriginTrial(element) { metadata.warnings.push("expired"); } if (!isSameOrigin(metadata.payload.origin, document.location.href)) { - const subdomain = isSubdomain( - metadata.payload.origin, - document.location.href - ); + const subdomain = isSubdomain(metadata.payload.origin, document.location.href); + console.log({ subdomain, payload: metadata.payload }); // Cross-origin OTs are only valid if: // 1. The document is a subdomain of the OT origin and the isSubdomain config is set // 2. The isThirdParty config is set if (subdomain && !metadata.payload.isSubdomain) { metadata.warnings.push("invalid subdomain"); - } else if (!metadata.payload.isThirdParty) { - metadata.warnings.push("invalid origin"); + } else if (!subdomain && !metadata.payload.isThirdParty) { + metadata.warnings.push("invalid third-party origin"); } } @@ -211,9 +231,7 @@ function decodeOriginTrialToken(token) { const buffer = new Uint8Array([...atob(token)].map((a) => a.charCodeAt(0))); const view = new DataView(buffer.buffer); const length = view.getUint32(65, false); - const payload = JSON.parse( - new TextDecoder().decode(buffer.slice(69, 69 + length)) - ); + const payload = JSON.parse(new TextDecoder().decode(buffer.slice(69, 69 + length))); payload.expiry = new Date(payload.expiry * 1000); return payload; } @@ -230,6 +248,45 @@ function isSubdomain(a, b) { return b.host.endsWith(`.${a.host}`); } +function isDefaultStyle(element) { + return element.matches('meta[http-equiv="default-style" i]'); +} + +function isContentType(element) { + return element.matches(CONTENT_TYPE_SELECTOR); +} + +function isInvalidDefaultStyle(element) { + if (!isDefaultStyle(element)) { + return false; + } + + const { warnings } = validateDefaultStyle(element); + return warnings.length > 0; +} + +function isInvalidContentType(element) { + if (!isContentType(element)) { + return false; + } + + const { warnings } = validateContentType(element); + return warnings.length > 0; +} + +function isHttpEquiv(element) { + return element.matches(HTTP_EQUIV_SELECTOR); +} + +function isInvalidHttpEquiv(element) { + if (!isHttpEquiv(element)) { + return false; + } + + const { warnings } = validateHttpEquiv(element); + return warnings.length > 0; +} + function isUnnecessaryPreload(element) { if (!element.matches(PRELOAD_SELECTOR)) { return false; @@ -246,9 +303,7 @@ function isUnnecessaryPreload(element) { } function findElementWithSource(root, sourceUrl) { - const linksAndScripts = Array.from( - root.querySelectorAll(`link:not(${PRELOAD_SELECTOR}), script`) - ); + const linksAndScripts = Array.from(root.querySelectorAll(`link:not(${PRELOAD_SELECTOR}), script`)); return linksAndScripts.find((e) => { const src = e.getAttribute("href") || e.getAttribute("src"); @@ -264,13 +319,202 @@ function absolutifyUrl(href) { return new URL(href, document.baseURI).href; } +function validateDefaultStyle(element) { + const warnings = []; + let payload = null; + + // Check if the value points to an alternate stylesheet with that title + const title = element.getAttribute("content"); + const stylesheet = element.parentElement.querySelector( + `link[rel~="alternate" i][rel~="stylesheet" i][title="${title}"]` + ); + + if (!title) { + warnings.push("This has no effect. The content attribute must be set to a valid stylesheet title."); + } else if (!stylesheet) { + payload = { + alternateStylesheets: Array.from( + element.parentElement.querySelectorAll('link[rel~="alternate" i][rel~="stylesheet" i]') + ), + }; + warnings.push(`This has no effect. No alternate stylesheet found having title="${title}".`); + } + + warnings.push( + "Even when used correctly, the default-style method of setting a preferred stylesheet results in a flash of unstyled content. Use modern CSS features like @media rules instead." + ); + + return { warnings, payload }; +} + +function validateContentType(element) { + const warnings = []; + let payload = null; + // https://html.spec.whatwg.org/multipage/semantics.html#character-encoding-declaration + // Check if there exists both meta[http-equiv] and meta[chartset] variations + if ( + element.matches(':is(meta[charset] ~ meta[http-equiv="content-type" i])') || + element.matches(":has(~ meta[charset])") + ) { + const encodingDeclaration = element.parentElement.querySelector("meta[charset]"); + payload = payload ?? {}; + payload.encodingDeclaration = encodingDeclaration; + warnings.push( + `There can only be one meta-based character encoding declaration per document. Already found \`${encodingDeclaration.outerHTML}\`.` + ); + } + + // Check if it compeltely exists in the first 1024 bytes + const charPos = element.ownerDocument.documentElement.outerHTML.indexOf(element.outerHTML) + element.outerHTML.length; + if (charPos > 1024) { + payload = payload ?? {}; + payload.characterPosition = charPos; + warnings.push( + `The element containing the character encoding declaration must be serialized completely within the first 1024 bytes of the document. Found at byte ${charPos}.` + ); + } + + // Check that the character encoding is UTF-8 + let charset = null; + if (element.matches("meta[charset]")) { + charset = element.getAttribute("charset"); + } else { + const charsetPattern = /text\/html;\s*charset=(.*)/i; + charset = element.getAttribute("content")?.match(charsetPattern)?.[1]; + } + + if (charset.toLowerCase() != "utf-8") { + payload = payload ?? {}; + payload.charset = charset; + warnings.push(`Documents are required to use UTF-8 encoding. Found "${charset}".`); + } + + if (warnings.length) { + // Append the spec source to the last warning + warnings[length - 1] = + warnings.at(-1) + + "\nLearn more: https://html.spec.whatwg.org/multipage/semantics.html#character-encoding-declaration"; + } + + return { warnings, payload }; +} + +function validateHttpEquiv(element) { + const warnings = []; + const type = element.getAttribute("http-equiv").toLowerCase(); + const content = element.getAttribute("content").toLowerCase(); + + switch (type) { + case "content-security-policy": + case "content-security-policy-report-only": + case "origin-trial": + case "content-type": + case "default-style": + // Legitimate use case and/or more specific validation already exists + break; + + case "refresh": + if (content.includes("url=")) { + warnings.push("Meta auto-redirects are discouraged. Use HTTP 3XX responses instead."); + } else { + warnings.push("Meta auto-refreshes are discouraged unless users have the ability to disable it."); + } + break; + + case "x-dns-prefetch-control": + if (content == "on") { + warnings.push(`DNS prefetching is enabled by default. Setting it to "${content}" has no effect.`); + } else if (content != "off") { + warnings.push( + `This is a non-standard way of disabling DNS prefetching, which is a performance optimization. Found content="${content}". Use content="off" if you have a legitimate security concern, otherwise remove it.` + ); + } else { + warnings.push( + "This is non-standard, however most browsers support disabling speculative DNS prefetching. It should still be noted that DNS prefetching is a generally accepted performance optimization and you should only disable it if you have specific security concerns." + ); + } + break; + + case "cache-control": + case "etag": + case "pragma": + case "expires": + case "last-modified": + warnings.push("This doesn't do anything. Use HTTP headers for any cache directives."); + break; + + case "x-frame-options": + warnings.push("This doesn't do anything. Use the CSP HTTP header with the frame-ancestors directive instead."); + break; + + case "x-ua-compatible": + case "content-style-type": + case "content-script-type": + case "imagetoolbar": + case "cleartype": + case "page-enter": + case "page-exit": + case "site-enter": + case "site-exit": + case "msthemecompatible": + case "window-target": + warnings.push("This doesn't do anything. It was an Internet Explorer feature and is now deprecated."); + break; + + case "content-language": + case "language": + warnings.push("This is non-conforming. Use the html[lang] attribute instead."); + break; + + case "set-cookie": + warnings.push("This is non-conforming. Use the Set-Cookie HTTP header instead."); + break; + + case "application-name": + case "author": + case "description": + case "generator": + case "keywords": + case "referrer": + case "theme-color": + case "color-scheme": + case "viewport": + case "creator": + case "googlebot": + case "publisher": + case "robots": + warnings.push(`This doesn't do anything. Did you mean \`meta[name=${type}]\`?`); + break; + + case "encoding": + warnings.push("This doesn't do anything. Did you mean `meta[charset]`?"); + break; + + case "title": + warnings.push("This doesn't do anything. Did you mean to use the `title` tag instead?"); + break; + + case "accept-ch": + case "delegate-ch": + warnings.push("This is non-standard and may not work across browsers. Use HTTP headers instead."); + break; + + default: + warnings.push( + "This is non-standard and may not work across browsers. http-equiv is not an alternative to HTTP headers." + ); + break; + } + + return { + warnings, + }; +} + function validateUnnecessaryPreload(element) { const href = element.getAttribute("href"); const preloadedUrl = absolutifyUrl(href); - const preloadedElement = findElementWithSource( - element.parentElement, - preloadedUrl - ); + const preloadedElement = findElementWithSource(element.parentElement, preloadedUrl); if (!preloadedElement) { throw new Error("Expected an invalid preload, but none found.");