11import { isMetaCSP , isOriginTrial } from "./rules" ;
22
33export const VALID_HEAD_ELEMENTS = new Set ( [
4- ' base' ,
5- ' link' ,
6- ' meta' ,
7- ' noscript' ,
8- ' script' ,
9- ' style' ,
10- ' template' ,
11- ' title'
4+ " base" ,
5+ " link" ,
6+ " meta" ,
7+ " noscript" ,
8+ " script" ,
9+ " style" ,
10+ " template" ,
11+ " title" ,
1212] ) ;
1313
14- export const PRELOAD_SELECTOR = 'link:is([rel="preload" i], [rel="modulepreload" i])' ;
14+ export const PRELOAD_SELECTOR =
15+ 'link:is([rel="preload" i], [rel="modulepreload" i])' ;
1516
1617export function isValidElement ( element ) {
1718 return VALID_HEAD_ELEMENTS . has ( element . tagName . toLowerCase ( ) ) ;
@@ -24,20 +25,22 @@ export function hasValidationWarning(element) {
2425 }
2526
2627 // Children are not valid.
27- if ( element . matches ( `:has(:not(${ Array . from ( VALID_HEAD_ELEMENTS ) . join ( ', ' ) } ))` ) ) {
28+ if (
29+ element . matches ( `:has(:not(${ Array . from ( VALID_HEAD_ELEMENTS ) . join ( ", " ) } ))` )
30+ ) {
2831 return true ;
2932 }
3033
3134 // <title> is not the first of its type.
32- if ( element . matches ( ' title:is(:nth-of-type(n+2))' ) ) {
35+ if ( element . matches ( " title:is(:nth-of-type(n+2))" ) ) {
3336 return true ;
3437 }
3538
3639 // <base> is not the first of its type.
37- if ( element . matches ( ' base:has(~ base), base ~ base' ) ) {
40+ if ( element . matches ( " base:has(~ base), base ~ base" ) ) {
3841 return true ;
3942 }
40-
43+
4144 // CSP meta tag anywhere.
4245 if ( isMetaCSP ( element ) ) {
4346 return true ;
@@ -59,33 +62,36 @@ export function hasValidationWarning(element) {
5962export function getValidationWarnings ( head ) {
6063 const validationWarnings = [ ] ;
6164
62- const titleElements = Array . from ( head . querySelectorAll ( ' title' ) ) ;
65+ const titleElements = Array . from ( head . querySelectorAll ( " title" ) ) ;
6366 const titleElementCount = titleElements . length ;
6467 if ( titleElementCount != 1 ) {
6568 validationWarnings . push ( {
6669 warning : `Expected exactly 1 <title> element, found ${ titleElementCount } ` ,
67- elements : titleElements
70+ elements : titleElements ,
6871 } ) ;
6972 }
7073
71- const baseElements = Array . from ( head . querySelectorAll ( ' base' ) ) ;
74+ const baseElements = Array . from ( head . querySelectorAll ( " base" ) ) ;
7275 const baseElementCount = baseElements . length ;
7376 if ( baseElementCount > 1 ) {
7477 validationWarnings . push ( {
7578 warning : `Expected at most 1 <base> element, found ${ baseElementCount } ` ,
76- elements : baseElements
79+ elements : baseElements ,
7780 } ) ;
7881 }
79-
80- const metaCSP = head . querySelector ( 'meta[http-equiv="Content-Security-Policy" i]' ) ;
82+
83+ const metaCSP = head . querySelector (
84+ 'meta[http-equiv="Content-Security-Policy" i]'
85+ ) ;
8186 if ( metaCSP ) {
8287 validationWarnings . push ( {
83- 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' ,
84- element : metaCSP
88+ warning :
89+ "CSP meta tags disable the preload scanner due to a bug in Chrome. Use the CSP header instead. Learn more: https://crbug.com/1458493" ,
90+ element : metaCSP ,
8591 } ) ;
8692 }
8793
88- head . querySelectorAll ( '*' ) . forEach ( element => {
94+ head . querySelectorAll ( "*" ) . forEach ( ( element ) => {
8995 if ( isValidElement ( element ) ) {
9096 return ;
9197 }
@@ -97,22 +103,24 @@ export function getValidationWarnings(head) {
97103
98104 validationWarnings . push ( {
99105 warning : `${ element . tagName } elements are not allowed in the <head>` ,
100- element : root
106+ element : root ,
101107 } ) ;
102108 } ) ;
103109
104- const originTrials = Array . from ( head . querySelectorAll ( 'meta[http-equiv="Origin-Trial" i]' ) ) ;
105- originTrials . forEach ( element => {
110+ const originTrials = Array . from (
111+ head . querySelectorAll ( 'meta[http-equiv="Origin-Trial" i]' )
112+ ) ;
113+ originTrials . forEach ( ( element ) => {
106114 const metadata = validateOriginTrial ( element ) ;
107115
108116 if ( metadata . warnings . length == 0 ) {
109117 return ;
110118 }
111119
112120 validationWarnings . push ( {
113- warning : `Invalid origin trial token: ${ metadata . warnings . join ( ', ' ) } ` ,
121+ warning : `Invalid origin trial token: ${ metadata . warnings . join ( ", " ) } ` ,
114122 elements : [ element ] ,
115- element : metadata . payload
123+ element : metadata . payload ,
116124 } ) ;
117125 } ) ;
118126
@@ -138,17 +146,19 @@ export function getCustomValidations(element) {
138146function validateCSP ( element ) {
139147 const warnings = [ ] ;
140148
141- if ( element . matches ( 'meta[http-equiv="Content-Security-Policy-Report-Only" i]' ) ) {
149+ if (
150+ element . matches ( 'meta[http-equiv="Content-Security-Policy-Report-Only" i]' )
151+ ) {
142152 //https://w3c.github.io/webappsec-csp/#meta-element
143- warnings . push ( ' CSP Report-Only is forbidden in meta tags' ) ;
153+ warnings . push ( " CSP Report-Only is forbidden in meta tags" ) ;
144154 } else if ( element . matches ( 'meta[http-equiv="Content-Security-Policy" i]' ) ) {
145- warnings . push ( ' meta CSP discouraged. See https://crbug.com/1458493.' ) ;
155+ warnings . push ( " meta CSP discouraged. See https://crbug.com/1458493." ) ;
146156
147157 // TODO: Validate that CSP doesn't include `report-uri`, `frame-ancestors`, or `sandbox` directives.
148158 }
149159
150160 return {
151- warnings
161+ warnings,
152162 } ;
153163}
154164
@@ -157,39 +167,53 @@ function isInvalidOriginTrial(element) {
157167 return false ;
158168 }
159169
160- const { warnings} = validateOriginTrial ( element ) ;
170+ const { warnings } = validateOriginTrial ( element ) ;
161171 return warnings . length > 0 ;
162172}
163173
164174function validateOriginTrial ( element ) {
165175 const metadata = {
166176 payload : null ,
167- warnings : [ ]
177+ warnings : [ ] ,
168178 } ;
169179
170- const token = element . getAttribute ( ' content' ) ;
180+ const token = element . getAttribute ( " content" ) ;
171181 try {
172182 metadata . payload = decodeOriginTrialToken ( token ) ;
183+ } catch {
184+ metadata . warnings . push ( "invalid token" ) ;
185+ return metadata ;
186+ }
173187
174- if ( metadata . payload . expiry < new Date ( ) ) {
175- metadata . warnings . push ( 'expired' ) ;
176- }
177- if ( ! metadata . payload . isThirdParty && ! isSameOrigin ( metadata . payload . origin , document . location . href ) ) {
178- metadata . warnings . push ( 'invalid origin' ) ;
188+ if ( metadata . payload . expiry < new Date ( ) ) {
189+ metadata . warnings . push ( "expired" ) ;
190+ }
191+ if ( ! isSameOrigin ( metadata . payload . origin , document . location . href ) ) {
192+ const subdomain = isSubdomain (
193+ metadata . payload . origin ,
194+ document . location . href
195+ ) ;
196+ // Cross-origin OTs are only valid if:
197+ // 1. The document is a subdomain of the OT origin and the isSubdomain config is set
198+ // 2. The isThirdParty config is set
199+ if ( subdomain && ! metadata . payload . isSubdomain ) {
200+ metadata . warnings . push ( "invalid subdomain" ) ;
201+ } else if ( ! metadata . payload . isThirdParty ) {
202+ metadata . warnings . push ( "invalid origin" ) ;
179203 }
180- } catch {
181- metadata . warnings . push ( 'invalid token' ) ;
182204 }
183205
184206 return metadata ;
185207}
186208
187209// Adapted from https://glitch.com/~ot-decode.
188210function decodeOriginTrialToken ( token ) {
189- const buffer = new Uint8Array ( [ ...atob ( token ) ] . map ( a => a . charCodeAt ( 0 ) ) ) ;
190- const view = new DataView ( buffer . buffer )
191- const length = view . getUint32 ( 65 , false )
192- const payload = JSON . parse ( ( new TextDecoder ( ) ) . decode ( buffer . slice ( 69 , 69 + length ) ) ) ;
211+ const buffer = new Uint8Array ( [ ...atob ( token ) ] . map ( ( a ) => a . charCodeAt ( 0 ) ) ) ;
212+ const view = new DataView ( buffer . buffer ) ;
213+ const length = view . getUint32 ( 65 , false ) ;
214+ const payload = JSON . parse (
215+ new TextDecoder ( ) . decode ( buffer . slice ( 69 , 69 + length ) )
216+ ) ;
193217 payload . expiry = new Date ( payload . expiry * 1000 ) ;
194218 return payload ;
195219}
@@ -198,12 +222,20 @@ function isSameOrigin(a, b) {
198222 return new URL ( a ) . origin === new URL ( b ) . origin ;
199223}
200224
225+ // Whether b is a subdomain of a
226+ function isSubdomain ( a , b ) {
227+ // www.example.com ends with .example.com
228+ a = new URL ( a ) ;
229+ b = new URL ( b ) ;
230+ return b . host . endsWith ( `.${ a . host } ` ) ;
231+ }
232+
201233function isUnnecessaryPreload ( element ) {
202234 if ( ! element . matches ( PRELOAD_SELECTOR ) ) {
203235 return false ;
204236 }
205237
206- const href = element . getAttribute ( ' href' ) ;
238+ const href = element . getAttribute ( " href" ) ;
207239 if ( ! href ) {
208240 return false ;
209241 }
@@ -214,10 +246,12 @@ function isUnnecessaryPreload(element) {
214246}
215247
216248function findElementWithSource ( root , sourceUrl ) {
217- const linksAndScripts = Array . from ( root . querySelectorAll ( `link:not(${ PRELOAD_SELECTOR } ), script` ) ) ;
218-
219- return linksAndScripts . find ( e => {
220- const src = e . getAttribute ( 'href' ) || e . getAttribute ( 'src' ) ;
249+ const linksAndScripts = Array . from (
250+ root . querySelectorAll ( `link:not(${ PRELOAD_SELECTOR } ), script` )
251+ ) ;
252+
253+ return linksAndScripts . find ( ( e ) => {
254+ const src = e . getAttribute ( "href" ) || e . getAttribute ( "src" ) ;
221255 if ( ! src ) {
222256 return false ;
223257 }
@@ -231,15 +265,20 @@ function absolutifyUrl(href) {
231265}
232266
233267function validateUnnecessaryPreload ( element ) {
234- const href = element . getAttribute ( ' href' ) ;
268+ const href = element . getAttribute ( " href" ) ;
235269 const preloadedUrl = absolutifyUrl ( href ) ;
236- const preloadedElement = findElementWithSource ( element . parentElement , preloadedUrl ) ;
270+ const preloadedElement = findElementWithSource (
271+ element . parentElement ,
272+ preloadedUrl
273+ ) ;
237274
238275 if ( ! preloadedElement ) {
239- throw new Error ( ' Expected an invalid preload, but none found.' ) ;
276+ throw new Error ( " Expected an invalid preload, but none found." ) ;
240277 }
241278
242279 return {
243- warnings : [ `This preload has little to no effect. ${ href } is already discoverable by another ${ preloadedElement . tagName } element.` ] ,
280+ warnings : [
281+ `This preload has little to no effect. ${ href } is already discoverable by another ${ preloadedElement . tagName } element.` ,
282+ ] ,
244283 } ;
245284}
0 commit comments