Skip to content

Commit 4c5415b

Browse files
authored
Validate origin trial subdomains (#100)
1 parent 8bee979 commit 4c5415b

File tree

6 files changed

+124
-61
lines changed

6 files changed

+124
-61
lines changed

crx/capo.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crx/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"manifest_version": 3,
33
"name": "Capo: get your ﹤𝚑𝚎𝚊𝚍﹥ in order",
44
"description": "Visualize the optimal ordering of ﹤𝚑𝚎𝚊𝚍﹥ elements on any web page",
5-
"version": "1.4.9",
5+
"version": "1.4.10",
66
"permissions": [
77
"scripting",
88
"activeTab",

docs/src/lib/capo.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -540,10 +540,15 @@ function $c322f9a5057eaf5c$var$validateOriginTrial(element) {
540540
const token = element.getAttribute("content");
541541
try {
542542
metadata.payload = $c322f9a5057eaf5c$var$decodeOriginTrialToken(token);
543-
if (metadata.payload.expiry < new Date()) metadata.warnings.push("expired");
544-
if (!metadata.payload.isThirdParty && !$c322f9a5057eaf5c$var$isSameOrigin(metadata.payload.origin, document.location.href)) metadata.warnings.push("invalid origin");
545543
} catch {
546544
metadata.warnings.push("invalid token");
545+
return metadata;
546+
}
547+
if (metadata.payload.expiry < new Date()) metadata.warnings.push("expired");
548+
if (!$c322f9a5057eaf5c$var$isSameOrigin(metadata.payload.origin, document.location.href)) {
549+
const subdomain = $c322f9a5057eaf5c$var$isSubdomain(metadata.payload.origin, document.location.href);
550+
if (subdomain && !metadata.payload.isSubdomain) metadata.warnings.push("invalid subdomain");
551+
else if (!subdomain && !metadata.payload.isThirdParty) metadata.warnings.push("invalid origin");
547552
}
548553
return metadata;
549554
}
@@ -561,6 +566,13 @@ function $c322f9a5057eaf5c$var$decodeOriginTrialToken(token) {
561566
function $c322f9a5057eaf5c$var$isSameOrigin(a, b) {
562567
return new URL(a).origin === new URL(b).origin;
563568
}
569+
// Whether b is a subdomain of a
570+
function $c322f9a5057eaf5c$var$isSubdomain(a, b) {
571+
// www.example.com ends with .example.com
572+
a = new URL(a);
573+
b = new URL(b);
574+
return b.host.endsWith(`.${a.host}`);
575+
}
564576
function $c322f9a5057eaf5c$var$isUnnecessaryPreload(element) {
565577
if (!element.matches($c322f9a5057eaf5c$export$5540ac2a18901364)) return false;
566578
const href = element.getAttribute("href");

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@rviscomi/capo.js",
3-
"version": "1.4.9",
3+
"version": "1.4.10",
44
"description": "Get your ﹤𝚑𝚎𝚊𝚍﹥ in order",
55
"author": "Rick Viscomi",
66
"license": "Apache-2.0",

snippet/capo.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -541,10 +541,15 @@ function $580f7ed6bc170ae8$var$validateOriginTrial(element) {
541541
const token = element.getAttribute("content");
542542
try {
543543
metadata.payload = $580f7ed6bc170ae8$var$decodeOriginTrialToken(token);
544-
if (metadata.payload.expiry < new Date()) metadata.warnings.push("expired");
545-
if (!metadata.payload.isThirdParty && !$580f7ed6bc170ae8$var$isSameOrigin(metadata.payload.origin, document.location.href)) metadata.warnings.push("invalid origin");
546544
} catch {
547545
metadata.warnings.push("invalid token");
546+
return metadata;
547+
}
548+
if (metadata.payload.expiry < new Date()) metadata.warnings.push("expired");
549+
if (!$580f7ed6bc170ae8$var$isSameOrigin(metadata.payload.origin, document.location.href)) {
550+
const subdomain = $580f7ed6bc170ae8$var$isSubdomain(metadata.payload.origin, document.location.href);
551+
if (subdomain && !metadata.payload.isSubdomain) metadata.warnings.push("invalid subdomain");
552+
else if (!subdomain && !metadata.payload.isThirdParty) metadata.warnings.push("invalid origin");
548553
}
549554
return metadata;
550555
}
@@ -562,6 +567,13 @@ function $580f7ed6bc170ae8$var$decodeOriginTrialToken(token) {
562567
function $580f7ed6bc170ae8$var$isSameOrigin(a, b) {
563568
return new URL(a).origin === new URL(b).origin;
564569
}
570+
// Whether b is a subdomain of a
571+
function $580f7ed6bc170ae8$var$isSubdomain(a, b) {
572+
// www.example.com ends with .example.com
573+
a = new URL(a);
574+
b = new URL(b);
575+
return b.host.endsWith(`.${a.host}`);
576+
}
565577
function $580f7ed6bc170ae8$var$isUnnecessaryPreload(element) {
566578
if (!element.matches($580f7ed6bc170ae8$export$5540ac2a18901364)) return false;
567579
const href = element.getAttribute("href");

src/lib/validation.js

Lines changed: 93 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { isMetaCSP, isOriginTrial } from "./rules";
22

33
export 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

1617
export 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) {
5962
export 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) {
138146
function 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

164174
function 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.
188210
function 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+
201233
function 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

216248
function 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

233267
function 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

Comments
 (0)