Skip to content

Commit

Permalink
new_audit: ensure proper origin isolation with COOP (#16275)
Browse files Browse the repository at this point in the history
  • Loading branch information
sebastian9er authored Dec 12, 2024
1 parent 3c09253 commit 1d75655
Show file tree
Hide file tree
Showing 13 changed files with 938 additions and 250 deletions.
4 changes: 4 additions & 0 deletions cli/test/smokehouse/core-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import metricsTrickyTti from './test-definitions/metrics-tricky-tti.js';
import metricsTrickyTtiLateFcp from './test-definitions/metrics-tricky-tti-late-fcp.js';
import oopifRequests from './test-definitions/oopif-requests.js';
import oopifScripts from './test-definitions/oopif-scripts.js';
import originIsolationCoopHeaderMissing from './test-definitions/origin-isolation-coop-header-missing.js';
import originIsolationCoopPresent from './test-definitions/origin-isolation-coop-present.js';
import perfDebug from './test-definitions/perf-debug.js';
import perfDiagnosticsAnimations from './test-definitions/perf-diagnostics-animations.js';
import perfDiagnosticsThirdParty from './test-definitions/perf-diagnostics-third-party.js';
Expand Down Expand Up @@ -97,6 +99,8 @@ const smokeTests = [
metricsTrickyTtiLateFcp,
oopifRequests,
oopifScripts,
originIsolationCoopHeaderMissing,
originIsolationCoopPresent,
perfDebug,
perfDiagnosticsAnimations,
perfDiagnosticsThirdParty,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* @type {Smokehouse.ExpectedRunnerResult}
* Expected Lighthouse results for a site with a missing COOP header.
*/
const expectations = {
lhr: {
requestedUrl: 'https://example.com/',
finalDisplayedUrl: 'https://example.com/',
audits: {
'origin-isolation': {
score: 1,
details: {
items: [
{
description: 'No COOP header found',
severity: 'High',
},
],
},
},
},
},
};

export default {
id: 'origin-isolation-coop-header-missing',
expectations,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* @type {Smokehouse.ExpectedRunnerResult}
* Expected Lighthouse results for a site with a configured COOP header.
*/
const expectations = {
lhr: {
requestedUrl: 'https://csp.withgoogle.com/docs/index.html',
finalDisplayedUrl: 'https://csp.withgoogle.com/docs/index.html',
audits: {
'origin-isolation': {
score: null,
},
},
},
};

export default {
id: 'origin-isolation-coop-present',
expectations,
};
155 changes: 155 additions & 0 deletions core/audits/origin-isolation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {Audit} from './audit.js';
import {MainResource} from '../computed/main-resource.js';
import * as i18n from '../lib/i18n/i18n.js';

const UIStrings = {
/** 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. */
title: 'Ensure proper origin isolation with COOP',
/** 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. */
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)',
/** 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. */
noCoop: 'No COOP header found',
/** Table item value calling out the presence of a syntax error. */
invalidSyntax: 'Invalid syntax',
/** Label for a column in a data table; entries will be a directive of the COOP header. "COOP" stands for "Cross-Origin-Opener-Policy". */
columnDirective: 'Directive',
/** 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". */
columnSeverity: 'Severity',
};

const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);

class OriginIsolation extends Audit {
/**
* @return {LH.Audit.Meta}
*/
static get meta() {
return {
id: 'origin-isolation',
scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE,
title: str_(UIStrings.title),
description: str_(UIStrings.description),
requiredArtifacts: ['devtoolsLogs', 'URL'],
supportedModes: ['navigation'],
};
}


/**
* @param {LH.Artifacts} artifacts
* @param {LH.Audit.Context} context
* @return {Promise<string[]>}
*/
static async getRawCoop(artifacts, context) {
const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
const mainResource =
await MainResource.request({devtoolsLog, URL: artifacts.URL}, context);

let coopHeaders =
mainResource.responseHeaders
.filter(h => {
return h.name.toLowerCase() === 'cross-origin-opener-policy';
})
.flatMap(h => h.value);

// Sanitize the header value.
coopHeaders = coopHeaders.map(v => v.toLowerCase().replace(/\s/g, ''));

return coopHeaders;
}

/**
* @param {string | undefined} coopDirective
* @param {LH.IcuMessage | string} findingDescription
* @param {LH.IcuMessage=} severity
* @return {LH.Audit.Details.TableItem}
*/
static findingToTableItem(coopDirective, findingDescription, severity) {
return {
directive: coopDirective,
description: findingDescription,
severity,
};
}

/**
* @param {string[]} coopHeaders
* @return {{score: number, results: LH.Audit.Details.TableItem[]}}
*/
static constructResults(coopHeaders) {
const rawCoop = [...coopHeaders];
const allowedDirectives = [
'unsafe-none', 'same-origin-allow-popups', 'same-origin',
'noopener-allow-popups',
];
const violations = [];
const syntax = [];

if (!rawCoop.length) {
violations.push({
severity: str_(i18n.UIStrings.itemSeverityHigh),
description: str_(UIStrings.noCoop),
directive: undefined,
});
}

for (const actualDirective of coopHeaders) {
// If there is a directive that's not an official COOP directive.
if (!allowedDirectives.includes(actualDirective)) {
syntax.push({
severity: str_(i18n.UIStrings.itemSeverityLow),
description: str_(UIStrings.invalidSyntax),
directive: actualDirective,
});
}
}

const results = [
...violations.map(
f => this.findingToTableItem(
f.directive, f.description,
str_(i18n.UIStrings.itemSeverityHigh))),
...syntax.map(
f => this.findingToTableItem(
f.directive, f.description,
str_(i18n.UIStrings.itemSeverityLow))),
];

return {score: violations.length || syntax.length ? 0 : 1, results};
}

/**
* @param {LH.Artifacts} artifacts
* @param {LH.Audit.Context} context
* @return {Promise<LH.Audit.Product>}
*/
static async audit(artifacts, context) {
const coopHeaders = await this.getRawCoop(artifacts, context);
const {score, results} = this.constructResults(coopHeaders);

/** @type {LH.Audit.Details.Table['headings']} */
const headings = [
/* eslint-disable max-len */
{key: 'description', valueType: 'text', subItemsHeading: {key: 'description'}, label: str_(i18n.UIStrings.columnDescription)},
{key: 'directive', valueType: 'code', subItemsHeading: {key: 'directive'}, label: str_(UIStrings.columnDirective)},
{key: 'severity', valueType: 'text', subItemsHeading: {key: 'severity'}, label: str_(UIStrings.columnSeverity)},
/* eslint-enable max-len */
];
const details = Audit.makeTableDetails(headings, results);

return {
score,
notApplicable: !results.length,
details,
};
}
}

export default OriginIsolation;
export {UIStrings};
2 changes: 2 additions & 0 deletions core/config/default-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ const defaultConfig = {
'prioritize-lcp-image',
'csp-xss',
'has-hsts',
'origin-isolation',
'script-treemap-data',
'accessibility/accesskeys',
'accessibility/aria-allowed-attr',
Expand Down Expand Up @@ -543,6 +544,7 @@ const defaultConfig = {
{id: 'notification-on-start', weight: 1, group: 'best-practices-trust-safety'},
{id: 'csp-xss', weight: 0, group: 'best-practices-trust-safety'},
{id: 'has-hsts', weight: 0, group: 'best-practices-trust-safety'},
{id: 'origin-isolation', weight: 0, group: 'best-practices-trust-safety'},
// User Experience
{id: 'paste-preventing-inputs', weight: 3, group: 'best-practices-ux'},
{id: 'image-aspect-ratio', weight: 1, group: 'best-practices-ux'},
Expand Down
2 changes: 2 additions & 0 deletions core/scripts/i18n/collect-strings.js
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ function checkKnownFixedCollisions(strings) {
'Consider uploading your GIF to a service which will make it available to embed as an HTML5 video.',
'Directive',
'Directive',
'Directive',
'Document contains a $MARKDOWN_SNIPPET_0$ that triggers $MARKDOWN_SNIPPET_1$',
'Document contains a $MARKDOWN_SNIPPET_0$ that triggers $MARKDOWN_SNIPPET_1$',
'Document has a valid $MARKDOWN_SNIPPET_0$',
Expand All @@ -751,6 +752,7 @@ function checkKnownFixedCollisions(strings) {
'Potential Savings',
'Severity',
'Severity',
'Severity',
'The page was evicted from the cache to allow another page to be cached.',
'The page was evicted from the cache to allow another page to be cached.',
'Use $MARKDOWN_SNIPPET_0$ to detect unused JavaScript code. $LINK_START_0$Learn more$LINK_END_0$',
Expand Down
Loading

0 comments on commit 1d75655

Please sign in to comment.