Skip to content

Commit

Permalink
Validate origin trial subdomains (#100)
Browse files Browse the repository at this point in the history
  • Loading branch information
rviscomi authored Apr 17, 2024
1 parent 8bee979 commit 4c5415b
Show file tree
Hide file tree
Showing 6 changed files with 124 additions and 61 deletions.
2 changes: 1 addition & 1 deletion crx/capo.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crx/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.9",
"version": "1.4.10",
"permissions": [
"scripting",
"activeTab",
Expand Down
16 changes: 14 additions & 2 deletions docs/src/lib/capo.js
Original file line number Diff line number Diff line change
Expand Up @@ -540,10 +540,15 @@ function $c322f9a5057eaf5c$var$validateOriginTrial(element) {
const token = element.getAttribute("content");
try {
metadata.payload = $c322f9a5057eaf5c$var$decodeOriginTrialToken(token);
if (metadata.payload.expiry < new Date()) metadata.warnings.push("expired");
if (!metadata.payload.isThirdParty && !$c322f9a5057eaf5c$var$isSameOrigin(metadata.payload.origin, document.location.href)) metadata.warnings.push("invalid origin");
} catch {
metadata.warnings.push("invalid token");
return metadata;
}
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);
if (subdomain && !metadata.payload.isSubdomain) metadata.warnings.push("invalid subdomain");
else if (!subdomain && !metadata.payload.isThirdParty) metadata.warnings.push("invalid origin");
}
return metadata;
}
Expand All @@ -561,6 +566,13 @@ function $c322f9a5057eaf5c$var$decodeOriginTrialToken(token) {
function $c322f9a5057eaf5c$var$isSameOrigin(a, b) {
return new URL(a).origin === new URL(b).origin;
}
// Whether b is a subdomain of a
function $c322f9a5057eaf5c$var$isSubdomain(a, b) {
// www.example.com ends with .example.com
a = new URL(a);
b = new URL(b);
return b.host.endsWith(`.${a.host}`);
}
function $c322f9a5057eaf5c$var$isUnnecessaryPreload(element) {
if (!element.matches($c322f9a5057eaf5c$export$5540ac2a18901364)) return false;
const href = element.getAttribute("href");
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rviscomi/capo.js",
"version": "1.4.9",
"version": "1.4.10",
"description": "Get your ﹤𝚑𝚎𝚊𝚍﹥ in order",
"author": "Rick Viscomi",
"license": "Apache-2.0",
Expand Down
16 changes: 14 additions & 2 deletions snippet/capo.js
Original file line number Diff line number Diff line change
Expand Up @@ -541,10 +541,15 @@ function $580f7ed6bc170ae8$var$validateOriginTrial(element) {
const token = element.getAttribute("content");
try {
metadata.payload = $580f7ed6bc170ae8$var$decodeOriginTrialToken(token);
if (metadata.payload.expiry < new Date()) metadata.warnings.push("expired");
if (!metadata.payload.isThirdParty && !$580f7ed6bc170ae8$var$isSameOrigin(metadata.payload.origin, document.location.href)) metadata.warnings.push("invalid origin");
} catch {
metadata.warnings.push("invalid token");
return metadata;
}
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);
if (subdomain && !metadata.payload.isSubdomain) metadata.warnings.push("invalid subdomain");
else if (!subdomain && !metadata.payload.isThirdParty) metadata.warnings.push("invalid origin");
}
return metadata;
}
Expand All @@ -562,6 +567,13 @@ function $580f7ed6bc170ae8$var$decodeOriginTrialToken(token) {
function $580f7ed6bc170ae8$var$isSameOrigin(a, b) {
return new URL(a).origin === new URL(b).origin;
}
// Whether b is a subdomain of a
function $580f7ed6bc170ae8$var$isSubdomain(a, b) {
// www.example.com ends with .example.com
a = new URL(a);
b = new URL(b);
return b.host.endsWith(`.${a.host}`);
}
function $580f7ed6bc170ae8$var$isUnnecessaryPreload(element) {
if (!element.matches($580f7ed6bc170ae8$export$5540ac2a18901364)) return false;
const href = element.getAttribute("href");
Expand Down
147 changes: 93 additions & 54 deletions src/lib/validation.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { isMetaCSP, isOriginTrial } from "./rules";

export const VALID_HEAD_ELEMENTS = new Set([
'base',
'link',
'meta',
'noscript',
'script',
'style',
'template',
'title'
"base",
"link",
"meta",
"noscript",
"script",
"style",
"template",
"title",
]);

export const PRELOAD_SELECTOR = 'link:is([rel="preload" i], [rel="modulepreload" i])';
export const PRELOAD_SELECTOR =
'link:is([rel="preload" i], [rel="modulepreload" i])';

export function isValidElement(element) {
return VALID_HEAD_ELEMENTS.has(element.tagName.toLowerCase());
Expand All @@ -24,20 +25,22 @@ 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;
}

// <title> is not the first of its type.
if (element.matches('title:is(:nth-of-type(n+2))')) {
if (element.matches("title:is(:nth-of-type(n+2))")) {
return true;
}

// <base> is not the first of its type.
if (element.matches('base:has(~ base), base ~ base')) {
if (element.matches("base:has(~ base), base ~ base")) {
return true;
}

// CSP meta tag anywhere.
if (isMetaCSP(element)) {
return true;
Expand All @@ -59,33 +62,36 @@ export function hasValidationWarning(element) {
export function getValidationWarnings(head) {
const validationWarnings = [];

const titleElements = Array.from(head.querySelectorAll('title'));
const titleElements = Array.from(head.querySelectorAll("title"));
const titleElementCount = titleElements.length;
if (titleElementCount != 1) {
validationWarnings.push({
warning: `Expected exactly 1 <title> element, found ${titleElementCount}`,
elements: titleElements
elements: titleElements,
});
}

const baseElements = Array.from(head.querySelectorAll('base'));
const baseElements = Array.from(head.querySelectorAll("base"));
const baseElementCount = baseElements.length;
if (baseElementCount > 1) {
validationWarnings.push({
warning: `Expected at most 1 <base> element, found ${baseElementCount}`,
elements: baseElements
elements: baseElements,
});
}

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: '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: metaCSP
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: metaCSP,
});
}

head.querySelectorAll('*').forEach(element => {
head.querySelectorAll("*").forEach((element) => {
if (isValidElement(element)) {
return;
}
Expand All @@ -97,22 +103,24 @@ export function getValidationWarnings(head) {

validationWarnings.push({
warning: `${element.tagName} elements are not allowed in the <head>`,
element: root
element: root,
});
});

const originTrials = Array.from(head.querySelectorAll('meta[http-equiv="Origin-Trial" i]'));
originTrials.forEach(element => {
const originTrials = Array.from(
head.querySelectorAll('meta[http-equiv="Origin-Trial" i]')
);
originTrials.forEach((element) => {
const metadata = validateOriginTrial(element);

if (metadata.warnings.length == 0) {
return;
}

validationWarnings.push({
warning: `Invalid origin trial token: ${metadata.warnings.join(', ')}`,
warning: `Invalid origin trial token: ${metadata.warnings.join(", ")}`,
elements: [element],
element: metadata.payload
element: metadata.payload,
});
});

Expand All @@ -138,17 +146,19 @@ 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');
warnings.push("CSP Report-Only is forbidden in meta tags");
} else if (element.matches('meta[http-equiv="Content-Security-Policy" i]')) {
warnings.push('meta CSP discouraged. See https://crbug.com/1458493.');
warnings.push("meta CSP discouraged. See https://crbug.com/1458493.");

// TODO: Validate that CSP doesn't include `report-uri`, `frame-ancestors`, or `sandbox` directives.
}

return {
warnings
warnings,
};
}

Expand All @@ -157,39 +167,53 @@ function isInvalidOriginTrial(element) {
return false;
}

const {warnings} = validateOriginTrial(element);
const { warnings } = validateOriginTrial(element);
return warnings.length > 0;
}

function validateOriginTrial(element) {
const metadata = {
payload: null,
warnings: []
warnings: [],
};

const token = element.getAttribute('content');
const token = element.getAttribute("content");
try {
metadata.payload = decodeOriginTrialToken(token);
} catch {
metadata.warnings.push("invalid token");
return metadata;
}

if (metadata.payload.expiry < new Date()) {
metadata.warnings.push('expired');
}
if (!metadata.payload.isThirdParty && !isSameOrigin(metadata.payload.origin, document.location.href)) {
metadata.warnings.push('invalid origin');
if (metadata.payload.expiry < new Date()) {
metadata.warnings.push("expired");
}
if (!isSameOrigin(metadata.payload.origin, document.location.href)) {
const subdomain = isSubdomain(
metadata.payload.origin,
document.location.href
);
// 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");
}
} catch {
metadata.warnings.push('invalid token');
}

return metadata;
}

// Adapted from https://glitch.com/~ot-decode.
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 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))
);
payload.expiry = new Date(payload.expiry * 1000);
return payload;
}
Expand All @@ -198,12 +222,20 @@ function isSameOrigin(a, b) {
return new URL(a).origin === new URL(b).origin;
}

// Whether b is a subdomain of a
function isSubdomain(a, b) {
// www.example.com ends with .example.com
a = new URL(a);
b = new URL(b);
return b.host.endsWith(`.${a.host}`);
}

function isUnnecessaryPreload(element) {
if (!element.matches(PRELOAD_SELECTOR)) {
return false;
}

const href = element.getAttribute('href');
const href = element.getAttribute("href");
if (!href) {
return false;
}
Expand All @@ -214,10 +246,12 @@ function isUnnecessaryPreload(element) {
}

function findElementWithSource(root, sourceUrl) {
const linksAndScripts = Array.from(root.querySelectorAll(`link:not(${PRELOAD_SELECTOR}), script`));

return linksAndScripts.find(e => {
const src = e.getAttribute('href') || e.getAttribute('src');
const linksAndScripts = Array.from(
root.querySelectorAll(`link:not(${PRELOAD_SELECTOR}), script`)
);

return linksAndScripts.find((e) => {
const src = e.getAttribute("href") || e.getAttribute("src");
if (!src) {
return false;
}
Expand All @@ -231,15 +265,20 @@ function absolutifyUrl(href) {
}

function validateUnnecessaryPreload(element) {
const href = element.getAttribute('href');
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.');
throw new Error("Expected an invalid preload, but none found.");
}

return {
warnings: [`This preload has little to no effect. ${href} is already discoverable by another ${preloadedElement.tagName} element.`],
warnings: [
`This preload has little to no effect. ${href} is already discoverable by another ${preloadedElement.tagName} element.`,
],
};
}

0 comments on commit 4c5415b

Please sign in to comment.