Skip to content

Commit 1d75655

Browse files
authored
new_audit: ensure proper origin isolation with COOP (#16275)
1 parent 3c09253 commit 1d75655

File tree

13 files changed

+938
-250
lines changed

13 files changed

+938
-250
lines changed

cli/test/smokehouse/core-tests.js

+4
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import metricsTrickyTti from './test-definitions/metrics-tricky-tti.js';
3737
import metricsTrickyTtiLateFcp from './test-definitions/metrics-tricky-tti-late-fcp.js';
3838
import oopifRequests from './test-definitions/oopif-requests.js';
3939
import oopifScripts from './test-definitions/oopif-scripts.js';
40+
import originIsolationCoopHeaderMissing from './test-definitions/origin-isolation-coop-header-missing.js';
41+
import originIsolationCoopPresent from './test-definitions/origin-isolation-coop-present.js';
4042
import perfDebug from './test-definitions/perf-debug.js';
4143
import perfDiagnosticsAnimations from './test-definitions/perf-diagnostics-animations.js';
4244
import perfDiagnosticsThirdParty from './test-definitions/perf-diagnostics-third-party.js';
@@ -97,6 +99,8 @@ const smokeTests = [
9799
metricsTrickyTtiLateFcp,
98100
oopifRequests,
99101
oopifScripts,
102+
originIsolationCoopHeaderMissing,
103+
originIsolationCoopPresent,
100104
perfDebug,
101105
perfDiagnosticsAnimations,
102106
perfDiagnosticsThirdParty,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @license
3+
* Copyright 2024 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* @type {Smokehouse.ExpectedRunnerResult}
9+
* Expected Lighthouse results for a site with a missing COOP header.
10+
*/
11+
const expectations = {
12+
lhr: {
13+
requestedUrl: 'https://example.com/',
14+
finalDisplayedUrl: 'https://example.com/',
15+
audits: {
16+
'origin-isolation': {
17+
score: 1,
18+
details: {
19+
items: [
20+
{
21+
description: 'No COOP header found',
22+
severity: 'High',
23+
},
24+
],
25+
},
26+
},
27+
},
28+
},
29+
};
30+
31+
export default {
32+
id: 'origin-isolation-coop-header-missing',
33+
expectations,
34+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* @license
3+
* Copyright 2024 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* @type {Smokehouse.ExpectedRunnerResult}
9+
* Expected Lighthouse results for a site with a configured COOP header.
10+
*/
11+
const expectations = {
12+
lhr: {
13+
requestedUrl: 'https://csp.withgoogle.com/docs/index.html',
14+
finalDisplayedUrl: 'https://csp.withgoogle.com/docs/index.html',
15+
audits: {
16+
'origin-isolation': {
17+
score: null,
18+
},
19+
},
20+
},
21+
};
22+
23+
export default {
24+
id: 'origin-isolation-coop-present',
25+
expectations,
26+
};

core/audits/origin-isolation.js

+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* @license
3+
* Copyright 2024 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {Audit} from './audit.js';
8+
import {MainResource} from '../computed/main-resource.js';
9+
import * as i18n from '../lib/i18n/i18n.js';
10+
11+
const UIStrings = {
12+
/** Title of a Lighthouse audit that evaluates the security of a page's COOP header for origin isolation. "COOP" stands for "Cross-Origin-Opener-Policy" and should not be translated. */
13+
title: 'Ensure proper origin isolation with COOP',
14+
/** Description of a Lighthouse audit that evaluates the security of a page's COOP header for origin isolation. This is displayed after a user expands the section to see more. No character length limits. The last sentence starting with 'Learn' becomes link text to additional documentation. "COOP" stands for "Cross-Origin-Opener-Policy", neither should be translated. */
15+
description: 'The Cross-Origin-Opener-Policy (COOP) can be used to isolate the top-level window from other documents such as pop-ups. [Learn more about deploying the COOP header.](https://web.dev/articles/why-coop-coep#coop)',
16+
/** Summary text for the results of a Lighthouse audit that evaluates the COOP header for origin isolation. This is displayed if no COOP header is deployed. "COOP" stands for "Cross-Origin-Opener-Policy" and should not be translated. */
17+
noCoop: 'No COOP header found',
18+
/** Table item value calling out the presence of a syntax error. */
19+
invalidSyntax: 'Invalid syntax',
20+
/** Label for a column in a data table; entries will be a directive of the COOP header. "COOP" stands for "Cross-Origin-Opener-Policy". */
21+
columnDirective: 'Directive',
22+
/** Label for a column in a data table; entries will be the severity of an issue with the COOP header. "COOP" stands for "Cross-Origin-Opener-Policy". */
23+
columnSeverity: 'Severity',
24+
};
25+
26+
const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
27+
28+
class OriginIsolation extends Audit {
29+
/**
30+
* @return {LH.Audit.Meta}
31+
*/
32+
static get meta() {
33+
return {
34+
id: 'origin-isolation',
35+
scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE,
36+
title: str_(UIStrings.title),
37+
description: str_(UIStrings.description),
38+
requiredArtifacts: ['devtoolsLogs', 'URL'],
39+
supportedModes: ['navigation'],
40+
};
41+
}
42+
43+
44+
/**
45+
* @param {LH.Artifacts} artifacts
46+
* @param {LH.Audit.Context} context
47+
* @return {Promise<string[]>}
48+
*/
49+
static async getRawCoop(artifacts, context) {
50+
const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
51+
const mainResource =
52+
await MainResource.request({devtoolsLog, URL: artifacts.URL}, context);
53+
54+
let coopHeaders =
55+
mainResource.responseHeaders
56+
.filter(h => {
57+
return h.name.toLowerCase() === 'cross-origin-opener-policy';
58+
})
59+
.flatMap(h => h.value);
60+
61+
// Sanitize the header value.
62+
coopHeaders = coopHeaders.map(v => v.toLowerCase().replace(/\s/g, ''));
63+
64+
return coopHeaders;
65+
}
66+
67+
/**
68+
* @param {string | undefined} coopDirective
69+
* @param {LH.IcuMessage | string} findingDescription
70+
* @param {LH.IcuMessage=} severity
71+
* @return {LH.Audit.Details.TableItem}
72+
*/
73+
static findingToTableItem(coopDirective, findingDescription, severity) {
74+
return {
75+
directive: coopDirective,
76+
description: findingDescription,
77+
severity,
78+
};
79+
}
80+
81+
/**
82+
* @param {string[]} coopHeaders
83+
* @return {{score: number, results: LH.Audit.Details.TableItem[]}}
84+
*/
85+
static constructResults(coopHeaders) {
86+
const rawCoop = [...coopHeaders];
87+
const allowedDirectives = [
88+
'unsafe-none', 'same-origin-allow-popups', 'same-origin',
89+
'noopener-allow-popups',
90+
];
91+
const violations = [];
92+
const syntax = [];
93+
94+
if (!rawCoop.length) {
95+
violations.push({
96+
severity: str_(i18n.UIStrings.itemSeverityHigh),
97+
description: str_(UIStrings.noCoop),
98+
directive: undefined,
99+
});
100+
}
101+
102+
for (const actualDirective of coopHeaders) {
103+
// If there is a directive that's not an official COOP directive.
104+
if (!allowedDirectives.includes(actualDirective)) {
105+
syntax.push({
106+
severity: str_(i18n.UIStrings.itemSeverityLow),
107+
description: str_(UIStrings.invalidSyntax),
108+
directive: actualDirective,
109+
});
110+
}
111+
}
112+
113+
const results = [
114+
...violations.map(
115+
f => this.findingToTableItem(
116+
f.directive, f.description,
117+
str_(i18n.UIStrings.itemSeverityHigh))),
118+
...syntax.map(
119+
f => this.findingToTableItem(
120+
f.directive, f.description,
121+
str_(i18n.UIStrings.itemSeverityLow))),
122+
];
123+
124+
return {score: violations.length || syntax.length ? 0 : 1, results};
125+
}
126+
127+
/**
128+
* @param {LH.Artifacts} artifacts
129+
* @param {LH.Audit.Context} context
130+
* @return {Promise<LH.Audit.Product>}
131+
*/
132+
static async audit(artifacts, context) {
133+
const coopHeaders = await this.getRawCoop(artifacts, context);
134+
const {score, results} = this.constructResults(coopHeaders);
135+
136+
/** @type {LH.Audit.Details.Table['headings']} */
137+
const headings = [
138+
/* eslint-disable max-len */
139+
{key: 'description', valueType: 'text', subItemsHeading: {key: 'description'}, label: str_(i18n.UIStrings.columnDescription)},
140+
{key: 'directive', valueType: 'code', subItemsHeading: {key: 'directive'}, label: str_(UIStrings.columnDirective)},
141+
{key: 'severity', valueType: 'text', subItemsHeading: {key: 'severity'}, label: str_(UIStrings.columnSeverity)},
142+
/* eslint-enable max-len */
143+
];
144+
const details = Audit.makeTableDetails(headings, results);
145+
146+
return {
147+
score,
148+
notApplicable: !results.length,
149+
details,
150+
};
151+
}
152+
}
153+
154+
export default OriginIsolation;
155+
export {UIStrings};

core/config/default-config.js

+2
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ const defaultConfig = {
193193
'prioritize-lcp-image',
194194
'csp-xss',
195195
'has-hsts',
196+
'origin-isolation',
196197
'script-treemap-data',
197198
'accessibility/accesskeys',
198199
'accessibility/aria-allowed-attr',
@@ -543,6 +544,7 @@ const defaultConfig = {
543544
{id: 'notification-on-start', weight: 1, group: 'best-practices-trust-safety'},
544545
{id: 'csp-xss', weight: 0, group: 'best-practices-trust-safety'},
545546
{id: 'has-hsts', weight: 0, group: 'best-practices-trust-safety'},
547+
{id: 'origin-isolation', weight: 0, group: 'best-practices-trust-safety'},
546548
// User Experience
547549
{id: 'paste-preventing-inputs', weight: 3, group: 'best-practices-ux'},
548550
{id: 'image-aspect-ratio', weight: 1, group: 'best-practices-ux'},

core/scripts/i18n/collect-strings.js

+2
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,7 @@ function checkKnownFixedCollisions(strings) {
731731
'Consider uploading your GIF to a service which will make it available to embed as an HTML5 video.',
732732
'Directive',
733733
'Directive',
734+
'Directive',
734735
'Document contains a $MARKDOWN_SNIPPET_0$ that triggers $MARKDOWN_SNIPPET_1$',
735736
'Document contains a $MARKDOWN_SNIPPET_0$ that triggers $MARKDOWN_SNIPPET_1$',
736737
'Document has a valid $MARKDOWN_SNIPPET_0$',
@@ -751,6 +752,7 @@ function checkKnownFixedCollisions(strings) {
751752
'Potential Savings',
752753
'Severity',
753754
'Severity',
755+
'Severity',
754756
'The page was evicted from the cache to allow another page to be cached.',
755757
'The page was evicted from the cache to allow another page to be cached.',
756758
'Use $MARKDOWN_SNIPPET_0$ to detect unused JavaScript code. $LINK_START_0$Learn more$LINK_END_0$',

0 commit comments

Comments
 (0)