diff --git a/cli/test/smokehouse/core-tests.js b/cli/test/smokehouse/core-tests.js index dc7d00d0beec..0762587c2054 100644 --- a/cli/test/smokehouse/core-tests.js +++ b/cli/test/smokehouse/core-tests.js @@ -66,6 +66,8 @@ import serviceWorkerReloaded from './test-definitions/service-worker-reloaded.js import shiftAttribution from './test-definitions/shift-attribution.js'; import sourceMaps from './test-definitions/source-maps.js'; import timing from './test-definitions/timing.js'; +import trustedTypesDirectivePresent from './test-definitions/trusted-types-directive-present.js'; +import trustedTypesDirectiveMissingDirective from './test-definitions/trusted-types-missing-directives.js'; /** @type {ReadonlyArray} */ const smokeTests = [ @@ -131,6 +133,8 @@ const smokeTests = [ shiftAttribution, sourceMaps, timing, + trustedTypesDirectivePresent, + trustedTypesDirectiveMissingDirective, ]; export default smokeTests; diff --git a/cli/test/smokehouse/test-definitions/trusted-types-directive-present.js b/cli/test/smokehouse/test-definitions/trusted-types-directive-present.js new file mode 100644 index 000000000000..3c63ffdc8fc0 --- /dev/null +++ b/cli/test/smokehouse/test-definitions/trusted-types-directive-present.js @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @type {Smokehouse.ExpectedRunnerResult} + * Expected Lighthouse results for a site with present DOM-XSS mitigations + * (through a Trusted-Types direcive in the Content-Security-Policy header). + */ +const expectations = { + lhr: { + requestedUrl: 'https://m.youtube.com/', + finalDisplayedUrl: 'https://m.youtube.com/', + audits: { + 'trusted-types-xss': { + score: null, + }, + }, + }, +}; + +export default { + id: 'trusted-types-directive-present', + expectations, +}; diff --git a/cli/test/smokehouse/test-definitions/trusted-types-missing-directives.js b/cli/test/smokehouse/test-definitions/trusted-types-missing-directives.js new file mode 100644 index 000000000000..5569de4f5430 --- /dev/null +++ b/cli/test/smokehouse/test-definitions/trusted-types-missing-directives.js @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @type {Smokehouse.ExpectedRunnerResult} + * Expected Lighthouse results for a site with missing DOM-XSS mitigations + * (through the lack of Trusted-Types direcives in the Content-Security-Policy + * headers). + */ +const expectations = { + lhr: { + requestedUrl: 'https://example.com/', + finalDisplayedUrl: 'https://example.com/', + audits: { + 'trusted-types-xss': { + score: 1, + details: { + items: [ + { + description: 'No `Content-Security-Policy` header with Trusted Types directive found', + severity: 'High', + }, + ], + }, + }, + }, + }, +}; + +export default { + id: 'trusted-types-missing-directives', + expectations, +}; diff --git a/core/audits/trusted-types-xss.js b/core/audits/trusted-types-xss.js new file mode 100644 index 000000000000..f0f15c216f18 --- /dev/null +++ b/core/audits/trusted-types-xss.js @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Directive} from 'csp_evaluator/dist/csp.js'; + +import {Audit} from './audit.js'; +import {MainResource} from '../computed/main-resource.js'; +import * as i18n from '../lib/i18n/i18n.js'; +import {parseCsp} from '../lib/csp-evaluator.js'; + +const UIStrings = { + /** Title of a Lighthouse audit that evaluates whether the set CSP header and Trusted Types directive is mitigating DOM-based XSS. "CSP" stands for "Content-Security-Policy" and should not be translated. "XSS" stands for "Cross Site Scripting" and should not be translated. */ + title: 'Mitigate DOM-based XSS with Trusted Types', + /** Description of a Lighthouse audit that evaluates whether the set CSP header and Trusted Types directive is mitigating DOM-based XSS. This is displayed after a user expands the section to see more. "CSP" stands for "Content-Security-Policy" and should not be translated. "XSS" stands for "Cross Site Scripting" and should not be translated. No character length limits. The last sentence starting with 'Learn' becomes link text to additional documentation. */ + description: + 'The `require-trusted-types-for` directive in the `Content-Security-Policy` (CSP) header ' + + 'instructs user agents to control the data passed to DOM XSS sink functions. ' + + '[Learn more about mitigating DOM-based XSS with Trusted Types](https://web.dev/articles/trusted-types).', + /** Summary text for the results of a Lighthouse audit that evaluates whether the set CSP header and Trusted Types directive is mitigating DOM-based XSS. This text is displayed if the page does not respond with a CSP header and a Trusted Types directive. "CSP" stands for "Content-Security-Policy" and should not be translated. "XSS" stands for "Cross Site Scripting" and should not be translated. */ + noTrustedTypesToMitigateXss: + 'No `Content-Security-Policy` header with Trusted Types directive found', + /** Label for a column in a data table; entries will be the severity of an issue with the page's CSP and Trusted Types directive. */ + columnSeverity: 'Severity', +}; + +const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings); + +class TrustedTypesXss extends Audit { + /** + * @return {LH.Audit.Meta} + */ + static get meta() { + return { + id: 'trusted-types-xss', + scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE, + title: str_(UIStrings.title), + description: str_(UIStrings.description), + requiredArtifacts: ['DevtoolsLog', 'MetaElements', 'URL'], + supportedModes: ['navigation'], + }; + } + + /** + * @param {LH.Artifacts} artifacts + * @param {LH.Audit.Context} context + * @return {Promise<{cspHeaders: string[], cspMetaTags: string[]}>} + */ + static async getRawCsps(artifacts, context) { + const devtoolsLog = artifacts.DevtoolsLog; + const mainResource = await MainResource.request({devtoolsLog, URL: artifacts.URL}, context); + + const cspMetaTags = artifacts.MetaElements + .filter(m => { + return m.httpEquiv && m.httpEquiv.toLowerCase() === 'content-security-policy'; + }) + .flatMap(m => (m.content || '').split(',')) + .filter(rawCsp => rawCsp.replace(/\s/g, '')); + const cspHeaders = mainResource.responseHeaders + .filter(h => { + return h.name.toLowerCase() === 'content-security-policy'; + }) + .flatMap(h => h.value.split(',')) + .filter(rawCsp => rawCsp.replace(/\s/g, '')); + + return {cspHeaders, cspMetaTags}; + } + + /** + * @param {LH.IcuMessage | string} findingDescription + * @param {LH.IcuMessage=} severity + * @return {LH.Audit.Details.TableItem} + */ + static findingToTableItem(findingDescription, severity) { + return { + description: findingDescription, + severity, + }; + } + + /** + * @param {string[]} cspHeaders + * @param {string[]} cspMetaTags + * @return {{score: number, results: LH.Audit.Details.TableItem[]}} + */ + static constructResults(cspHeaders, cspMetaTags) { + const rawCsps = [...cspHeaders, ...cspMetaTags]; + const parsedCsps = rawCsps.map(parseCsp); + + // Check for require-trusted-types-for 'script' in CSP. + for (const pc of parsedCsps) { + const directiveValues = pc.directives[pc.getEffectiveDirective( + Directive.REQUIRE_TRUSTED_TYPES_FOR)] || []; + if (directiveValues.includes('\'script\'')) { + return {score: 1, results: []}; + } + } + + return { + score: 0, + results: [{ + severity: str_(i18n.UIStrings.itemSeverityHigh), + description: str_(UIStrings.noTrustedTypesToMitigateXss), + }], + }; + } + + /** + * @param {LH.Artifacts} artifacts + * @param {LH.Audit.Context} context + * @return {Promise} + */ + static async audit(artifacts, context) { + const {cspHeaders, cspMetaTags} = await this.getRawCsps(artifacts, context); + const {score, results} = this.constructResults(cspHeaders, cspMetaTags); + + /** @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: '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 TrustedTypesXss; +export {UIStrings}; diff --git a/core/config/default-config.js b/core/config/default-config.js index 35461f248a61..755c578efcbf 100644 --- a/core/config/default-config.js +++ b/core/config/default-config.js @@ -198,6 +198,7 @@ const defaultConfig = { 'has-hsts', 'origin-isolation', 'clickjacking-mitigation', + 'trusted-types-xss', 'script-treemap-data', 'accessibility/accesskeys', 'accessibility/aria-allowed-attr', @@ -590,6 +591,7 @@ const defaultConfig = { {id: 'has-hsts', weight: 0, group: 'best-practices-trust-safety'}, {id: 'origin-isolation', weight: 0, group: 'best-practices-trust-safety'}, {id: 'clickjacking-mitigation', weight: 0, group: 'best-practices-trust-safety'}, + {id: 'trusted-types-xss', 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'}, diff --git a/core/scripts/i18n/collect-strings.js b/core/scripts/i18n/collect-strings.js index a2559f96d707..fd22a15b7c81 100644 --- a/core/scripts/i18n/collect-strings.js +++ b/core/scripts/i18n/collect-strings.js @@ -750,6 +750,7 @@ function checkKnownFixedCollisions(strings) { 'Severity', 'Severity', 'Severity', + 'Severity', 'Total', 'Total', 'Use $MARKDOWN_SNIPPET_0$ to detect unused JavaScript code. $LINK_START_0$Learn more$LINK_END_0$', diff --git a/core/test/audits/trusted-types-xss-test.js b/core/test/audits/trusted-types-xss-test.js new file mode 100644 index 000000000000..5a1c1433d293 --- /dev/null +++ b/core/test/audits/trusted-types-xss-test.js @@ -0,0 +1,286 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import TrustedTypesXss from '../../audits/trusted-types-xss.js'; +import {networkRecordsToDevtoolsLog} from '../network-records-to-devtools-log.js'; + +it('marked N/A if no violations found', async () => { + const artifacts = { + MetaElements: [], + URL: { + requestedUrl: 'https://example.com', + mainDocumentUrl: 'https://example.com', + finalDisplayedUrl: 'https://example.com', + }, + DevtoolsLog: networkRecordsToDevtoolsLog([ + { + url: 'https://example.com', + responseHeaders: [ + { + name: 'Content-Security-Policy', + value: + `require-trusted-types-for 'script';report-uri /cspreport`, + }, + ], + }, + ]), + }; + const results = + await TrustedTypesXss.audit(artifacts, {computedCache: new Map()}); + expect(results.details.items).toHaveLength(0); + expect(results.notApplicable).toBeTruthy(); +}); + +it('marked N/A if no violations found in metatag', async () => { + const artifacts = { + MetaElements: [ + { + httpEquiv: 'Content-Security-Policy', + content: `require-trusted-types-for 'script';report-uri /cspreport`, + }, + ], + URL: { + requestedUrl: 'https://example.com', + mainDocumentUrl: 'https://example.com', + finalDisplayedUrl: 'https://example.com', + }, + DevtoolsLog: networkRecordsToDevtoolsLog([ + { + url: 'https://example.com', + responseHeaders: [], + }, + ]), + }; + const results = + await TrustedTypesXss.audit(artifacts, {computedCache: new Map()}); + expect(results.details.items).toHaveLength(0); + expect(results.notApplicable).toBeTruthy(); +}); + +it('No CSP but foo header found', async () => { + const artifacts = { + MetaElements: [], + DevtoolsLog: networkRecordsToDevtoolsLog([ + { + url: 'https://example.com', + responseHeaders: [ + {name: 'Foo-Header', value: `some-value`}, + ], + }, + ]), + URL: { + requestedUrl: 'https://example.com', + mainDocumentUrl: 'https://example.com', + finalDisplayedUrl: 'https://example.com', + }, + }; + + const results = + await TrustedTypesXss.audit(artifacts, {computedCache: new Map()}); + expect(results.notApplicable).toBeFalsy(); + expect(results.details.items[0].severity).toBeDisplayString('High'); + expect(results.details.items[0].description) + .toBeDisplayString( + 'No `Content-Security-Policy` header with Trusted Types directive found'); +}); + +it('Unknown CSP directive.', async () => { + const artifacts = { + MetaElements: [], + DevtoolsLog: networkRecordsToDevtoolsLog([ + { + url: 'https://example.com', + responseHeaders: [ + {name: 'Content-Security-Policy', value: `fooDirective`}, + ], + }, + ]), + URL: { + requestedUrl: 'https://example.com', + mainDocumentUrl: 'https://example.com', + finalDisplayedUrl: 'https://example.com', + }, + }; + + const results = + await TrustedTypesXss.audit(artifacts, {computedCache: new Map()}); + expect(results.notApplicable).toBeFalsy(); + expect(results.details.items[0].severity).toBeDisplayString('High'); + expect(results.details.items[0].description) + .toBeDisplayString( + 'No `Content-Security-Policy` header with Trusted Types directive found'); +}); + +it('Messed Trusted Types directive.', async () => { + const artifacts = { + MetaElements: [], + DevtoolsLog: networkRecordsToDevtoolsLog([ + { + url: 'https://example.com', + responseHeaders: [ + { + name: 'Content-Security-Policy', + value: `require-trusted-types-for; 'script' report-uri /cspreport`, + }, + ], + }, + ]), + URL: { + requestedUrl: 'https://example.com', + mainDocumentUrl: 'https://example.com', + finalDisplayedUrl: 'https://example.com', + }, + }; + + const results = + await TrustedTypesXss.audit(artifacts, {computedCache: new Map()}); + expect(results.notApplicable).toBeFalsy(); + expect(results.details.items[0].severity).toBeDisplayString('High'); + expect(results.details.items[0].description) + .toBeDisplayString( + 'No `Content-Security-Policy` header with Trusted Types directive found'); +}); + +describe('getRawCsps', () => { + it('basic case', async () => { + const artifacts = { + MetaElements: [], + URL: { + requestedUrl: 'https://example.com', + mainDocumentUrl: 'https://example.com', + finalDisplayedUrl: 'https://example.com', + }, + DevtoolsLog: networkRecordsToDevtoolsLog([ + { + url: 'https://example.com', + responseHeaders: [ + { + name: 'Content-Security-Policy', + value: `require-trusted-types-for 'script';report-uri /cspreport`, + }, + ], + }, + ]), + }; + const {cspHeaders, cspMetaTags} = + await TrustedTypesXss.getRawCsps(artifacts, {computedCache: new Map()}); + expect(cspHeaders).toEqual([`require-trusted-types-for 'script';report-uri /cspreport`]); + expect(cspMetaTags).toEqual([]); + }); + + it('basic case with metatag', async () => { + const artifacts = { + MetaElements: [ + { + httpEquiv: 'Content-Security-Policy', + content: `require-trusted-types-for 'script';report-uri /cspreport`, + }, + ], + URL: { + requestedUrl: 'https://example.com', + mainDocumentUrl: 'https://example.com', + finalDisplayedUrl: 'https://example.com', + }, + DevtoolsLog: networkRecordsToDevtoolsLog([ + { + url: 'https://example.com', + responseHeaders: [], + }, + ]), + }; + const {cspHeaders, cspMetaTags} = + await TrustedTypesXss.getRawCsps(artifacts, {computedCache: new Map()}); + expect(cspHeaders).toEqual([]); + expect(cspMetaTags).toEqual([`require-trusted-types-for 'script';report-uri /cspreport`]); + }); + + it('ignore if empty', async () => { + const artifacts = { + MetaElements: [], + URL: { + requestedUrl: 'https://example.com', + mainDocumentUrl: 'https://example.com', + finalDisplayedUrl: 'https://example.com', + }, + DevtoolsLog: networkRecordsToDevtoolsLog([ + { + url: 'https://example.com', + responseHeaders: [ + { + name: 'Content-Security-Policy', + value: ``, + }, + ], + }, + ]), + }; + const {cspHeaders, cspMetaTags} = + await TrustedTypesXss.getRawCsps(artifacts, {computedCache: new Map()}); + expect(cspHeaders).toEqual([]); + expect(cspMetaTags).toEqual([]); + }); + + it('ignore if only whitespace', async () => { + const artifacts = { + MetaElements: [], + URL: { + requestedUrl: 'https://example.com', + mainDocumentUrl: 'https://example.com', + finalDisplayedUrl: 'https://example.com', + }, + DevtoolsLog: networkRecordsToDevtoolsLog([ + { + url: 'https://example.com', + responseHeaders: [ + { + name: 'Content-Security-Policy', + value: ' \t', + }, + ], + }, + ]), + }; + const {cspHeaders, cspMetaTags} = + await TrustedTypesXss.getRawCsps(artifacts, {computedCache: new Map()}); + expect(cspHeaders).toEqual([]); + expect(cspMetaTags).toEqual([]); + }); +}); + +describe('constructResults', () => { + it('passes with no findings', () => { + const {score, results} = TrustedTypesXss.constructResults( + [`require-trusted-types-for 'script';report-uri /cspreport`], []); + expect(score).toEqual(1); + expect(results).toEqual([]); + }); + + it('passes with no findings in metatags', () => { + const {score, results} = TrustedTypesXss.constructResults( + [], [`require-trusted-types-for 'script';report-uri /cspreport`]); + expect(score).toEqual(1); + expect(results).toEqual([]); + }); + + it('constructs result based on misconfigured CSP header', () => { + const {score, results} = + TrustedTypesXss.constructResults(['foo-directive'], []); + expect(score).toEqual(0); + expect(results[0].severity).toBeDisplayString('High'); + expect(results[0].description) + .toBeDisplayString( + 'No `Content-Security-Policy` header with Trusted Types directive found'); + }); + + it('returns single item for no CSP', () => { + const {score, results} = TrustedTypesXss.constructResults([], []); + expect(score).toEqual(0); + expect(results[0].severity).toBeDisplayString('High'); + expect(results[0].description) + .toBeDisplayString( + 'No `Content-Security-Policy` header with Trusted Types directive found'); + }); +}); diff --git a/core/test/fixtures/user-flows/reports/sample-flow-result.json b/core/test/fixtures/user-flows/reports/sample-flow-result.json index e7d38ea1041b..f2fe9425c43f 100644 --- a/core/test/fixtures/user-flows/reports/sample-flow-result.json +++ b/core/test/fixtures/user-flows/reports/sample-flow-result.json @@ -2262,6 +2262,40 @@ ] } }, + "trusted-types-xss": { + "id": "trusted-types-xss", + "title": "Mitigate DOM-based XSS with Trusted Types", + "description": "The `require-trusted-types-for` directive in the `Content-Security-Policy` (CSP) header instructs user agents to control the data passed to DOM XSS sink functions. [Learn more about mitigating DOM-based XSS with Trusted Types](https://web.dev/articles/trusted-types).", + "score": 1, + "scoreDisplayMode": "informative", + "details": { + "type": "table", + "headings": [ + { + "key": "description", + "valueType": "text", + "subItemsHeading": { + "key": "description" + }, + "label": "Description" + }, + { + "key": "severity", + "valueType": "text", + "subItemsHeading": { + "key": "severity" + }, + "label": "Severity" + } + ], + "items": [ + { + "severity": "High", + "description": "No `Content-Security-Policy` header with Trusted Types directive found" + } + ] + } + }, "script-treemap-data": { "id": "script-treemap-data", "title": "Script Treemap Data", @@ -6118,6 +6152,11 @@ "weight": 0, "group": "best-practices-trust-safety" }, + { + "id": "trusted-types-xss", + "weight": 0, + "group": "best-practices-trust-safety" + }, { "id": "paste-preventing-inputs", "weight": 3, @@ -8144,19 +8183,19 @@ }, { "startTime": 211, - "name": "lh:audit:script-treemap-data", + "name": "lh:audit:trusted-types-xss", "duration": 1, "entryType": "measure" }, { "startTime": 212, - "name": "lh:computed:ModuleDuplication", + "name": "lh:audit:script-treemap-data", "duration": 1, "entryType": "measure" }, { "startTime": 213, - "name": "lh:computed:UnusedJavascriptSummary", + "name": "lh:computed:ModuleDuplication", "duration": 1, "entryType": "measure" }, @@ -8240,786 +8279,792 @@ }, { "startTime": 227, - "name": "lh:audit:accesskeys", + "name": "lh:computed:UnusedJavascriptSummary", "duration": 1, "entryType": "measure" }, { "startTime": 228, - "name": "lh:audit:aria-allowed-attr", + "name": "lh:audit:accesskeys", "duration": 1, "entryType": "measure" }, { "startTime": 229, - "name": "lh:audit:aria-allowed-role", + "name": "lh:audit:aria-allowed-attr", "duration": 1, "entryType": "measure" }, { "startTime": 230, - "name": "lh:audit:aria-command-name", + "name": "lh:audit:aria-allowed-role", "duration": 1, "entryType": "measure" }, { "startTime": 231, - "name": "lh:audit:aria-conditional-attr", + "name": "lh:audit:aria-command-name", "duration": 1, "entryType": "measure" }, { "startTime": 232, - "name": "lh:audit:aria-deprecated-role", + "name": "lh:audit:aria-conditional-attr", "duration": 1, "entryType": "measure" }, { "startTime": 233, - "name": "lh:audit:aria-dialog-name", + "name": "lh:audit:aria-deprecated-role", "duration": 1, "entryType": "measure" }, { "startTime": 234, - "name": "lh:audit:aria-hidden-body", + "name": "lh:audit:aria-dialog-name", "duration": 1, "entryType": "measure" }, { "startTime": 235, - "name": "lh:audit:aria-hidden-focus", + "name": "lh:audit:aria-hidden-body", "duration": 1, "entryType": "measure" }, { "startTime": 236, - "name": "lh:audit:aria-input-field-name", + "name": "lh:audit:aria-hidden-focus", "duration": 1, "entryType": "measure" }, { "startTime": 237, - "name": "lh:audit:aria-meter-name", + "name": "lh:audit:aria-input-field-name", "duration": 1, "entryType": "measure" }, { "startTime": 238, - "name": "lh:audit:aria-progressbar-name", + "name": "lh:audit:aria-meter-name", "duration": 1, "entryType": "measure" }, { "startTime": 239, - "name": "lh:audit:aria-prohibited-attr", + "name": "lh:audit:aria-progressbar-name", "duration": 1, "entryType": "measure" }, { "startTime": 240, - "name": "lh:audit:aria-required-attr", + "name": "lh:audit:aria-prohibited-attr", "duration": 1, "entryType": "measure" }, { "startTime": 241, - "name": "lh:audit:aria-required-children", + "name": "lh:audit:aria-required-attr", "duration": 1, "entryType": "measure" }, { "startTime": 242, - "name": "lh:audit:aria-required-parent", + "name": "lh:audit:aria-required-children", "duration": 1, "entryType": "measure" }, { "startTime": 243, - "name": "lh:audit:aria-roles", + "name": "lh:audit:aria-required-parent", "duration": 1, "entryType": "measure" }, { "startTime": 244, - "name": "lh:audit:aria-text", + "name": "lh:audit:aria-roles", "duration": 1, "entryType": "measure" }, { "startTime": 245, - "name": "lh:audit:aria-toggle-field-name", + "name": "lh:audit:aria-text", "duration": 1, "entryType": "measure" }, { "startTime": 246, - "name": "lh:audit:aria-tooltip-name", + "name": "lh:audit:aria-toggle-field-name", "duration": 1, "entryType": "measure" }, { "startTime": 247, - "name": "lh:audit:aria-treeitem-name", + "name": "lh:audit:aria-tooltip-name", "duration": 1, "entryType": "measure" }, { "startTime": 248, - "name": "lh:audit:aria-valid-attr-value", + "name": "lh:audit:aria-treeitem-name", "duration": 1, "entryType": "measure" }, { "startTime": 249, - "name": "lh:audit:aria-valid-attr", + "name": "lh:audit:aria-valid-attr-value", "duration": 1, "entryType": "measure" }, { "startTime": 250, - "name": "lh:audit:button-name", + "name": "lh:audit:aria-valid-attr", "duration": 1, "entryType": "measure" }, { "startTime": 251, - "name": "lh:audit:bypass", + "name": "lh:audit:button-name", "duration": 1, "entryType": "measure" }, { "startTime": 252, - "name": "lh:audit:color-contrast", + "name": "lh:audit:bypass", "duration": 1, "entryType": "measure" }, { "startTime": 253, - "name": "lh:audit:definition-list", + "name": "lh:audit:color-contrast", "duration": 1, "entryType": "measure" }, { "startTime": 254, - "name": "lh:audit:dlitem", + "name": "lh:audit:definition-list", "duration": 1, "entryType": "measure" }, { "startTime": 255, - "name": "lh:audit:document-title", + "name": "lh:audit:dlitem", "duration": 1, "entryType": "measure" }, { "startTime": 256, - "name": "lh:audit:duplicate-id-aria", + "name": "lh:audit:document-title", "duration": 1, "entryType": "measure" }, { "startTime": 257, - "name": "lh:audit:empty-heading", + "name": "lh:audit:duplicate-id-aria", "duration": 1, "entryType": "measure" }, { "startTime": 258, - "name": "lh:audit:form-field-multiple-labels", + "name": "lh:audit:empty-heading", "duration": 1, "entryType": "measure" }, { "startTime": 259, - "name": "lh:audit:frame-title", + "name": "lh:audit:form-field-multiple-labels", "duration": 1, "entryType": "measure" }, { "startTime": 260, - "name": "lh:audit:heading-order", + "name": "lh:audit:frame-title", "duration": 1, "entryType": "measure" }, { "startTime": 261, - "name": "lh:audit:html-has-lang", + "name": "lh:audit:heading-order", "duration": 1, "entryType": "measure" }, { "startTime": 262, - "name": "lh:audit:html-lang-valid", + "name": "lh:audit:html-has-lang", "duration": 1, "entryType": "measure" }, { "startTime": 263, - "name": "lh:audit:html-xml-lang-mismatch", + "name": "lh:audit:html-lang-valid", "duration": 1, "entryType": "measure" }, { "startTime": 264, - "name": "lh:audit:identical-links-same-purpose", + "name": "lh:audit:html-xml-lang-mismatch", "duration": 1, "entryType": "measure" }, { "startTime": 265, - "name": "lh:audit:image-alt", + "name": "lh:audit:identical-links-same-purpose", "duration": 1, "entryType": "measure" }, { "startTime": 266, - "name": "lh:audit:image-redundant-alt", + "name": "lh:audit:image-alt", "duration": 1, "entryType": "measure" }, { "startTime": 267, - "name": "lh:audit:input-button-name", + "name": "lh:audit:image-redundant-alt", "duration": 1, "entryType": "measure" }, { "startTime": 268, - "name": "lh:audit:input-image-alt", + "name": "lh:audit:input-button-name", "duration": 1, "entryType": "measure" }, { "startTime": 269, - "name": "lh:audit:label-content-name-mismatch", + "name": "lh:audit:input-image-alt", "duration": 1, "entryType": "measure" }, { "startTime": 270, - "name": "lh:audit:label", + "name": "lh:audit:label-content-name-mismatch", "duration": 1, "entryType": "measure" }, { "startTime": 271, - "name": "lh:audit:landmark-one-main", + "name": "lh:audit:label", "duration": 1, "entryType": "measure" }, { "startTime": 272, - "name": "lh:audit:link-name", + "name": "lh:audit:landmark-one-main", "duration": 1, "entryType": "measure" }, { "startTime": 273, - "name": "lh:audit:link-in-text-block", + "name": "lh:audit:link-name", "duration": 1, "entryType": "measure" }, { "startTime": 274, - "name": "lh:audit:list", + "name": "lh:audit:link-in-text-block", "duration": 1, "entryType": "measure" }, { "startTime": 275, - "name": "lh:audit:listitem", + "name": "lh:audit:list", "duration": 1, "entryType": "measure" }, { "startTime": 276, - "name": "lh:audit:meta-refresh", + "name": "lh:audit:listitem", "duration": 1, "entryType": "measure" }, { "startTime": 277, - "name": "lh:audit:meta-viewport", + "name": "lh:audit:meta-refresh", "duration": 1, "entryType": "measure" }, { "startTime": 278, - "name": "lh:audit:object-alt", + "name": "lh:audit:meta-viewport", "duration": 1, "entryType": "measure" }, { "startTime": 279, - "name": "lh:audit:select-name", + "name": "lh:audit:object-alt", "duration": 1, "entryType": "measure" }, { "startTime": 280, - "name": "lh:audit:skip-link", + "name": "lh:audit:select-name", "duration": 1, "entryType": "measure" }, { "startTime": 281, - "name": "lh:audit:tabindex", + "name": "lh:audit:skip-link", "duration": 1, "entryType": "measure" }, { "startTime": 282, - "name": "lh:audit:table-duplicate-name", + "name": "lh:audit:tabindex", "duration": 1, "entryType": "measure" }, { "startTime": 283, - "name": "lh:audit:table-fake-caption", + "name": "lh:audit:table-duplicate-name", "duration": 1, "entryType": "measure" }, { "startTime": 284, - "name": "lh:audit:target-size", + "name": "lh:audit:table-fake-caption", "duration": 1, "entryType": "measure" }, { "startTime": 285, - "name": "lh:audit:td-has-header", + "name": "lh:audit:target-size", "duration": 1, "entryType": "measure" }, { "startTime": 286, - "name": "lh:audit:td-headers-attr", + "name": "lh:audit:td-has-header", "duration": 1, "entryType": "measure" }, { "startTime": 287, - "name": "lh:audit:th-has-data-cells", + "name": "lh:audit:td-headers-attr", "duration": 1, "entryType": "measure" }, { "startTime": 288, - "name": "lh:audit:valid-lang", + "name": "lh:audit:th-has-data-cells", "duration": 1, "entryType": "measure" }, { "startTime": 289, - "name": "lh:audit:video-caption", + "name": "lh:audit:valid-lang", "duration": 1, "entryType": "measure" }, { "startTime": 290, - "name": "lh:audit:custom-controls-labels", + "name": "lh:audit:video-caption", "duration": 1, "entryType": "measure" }, { "startTime": 291, - "name": "lh:audit:custom-controls-roles", + "name": "lh:audit:custom-controls-labels", "duration": 1, "entryType": "measure" }, { "startTime": 292, - "name": "lh:audit:focus-traps", + "name": "lh:audit:custom-controls-roles", "duration": 1, "entryType": "measure" }, { "startTime": 293, - "name": "lh:audit:focusable-controls", + "name": "lh:audit:focus-traps", "duration": 1, "entryType": "measure" }, { "startTime": 294, - "name": "lh:audit:interactive-element-affordance", + "name": "lh:audit:focusable-controls", "duration": 1, "entryType": "measure" }, { "startTime": 295, - "name": "lh:audit:logical-tab-order", + "name": "lh:audit:interactive-element-affordance", "duration": 1, "entryType": "measure" }, { "startTime": 296, - "name": "lh:audit:managed-focus", + "name": "lh:audit:logical-tab-order", "duration": 1, "entryType": "measure" }, { "startTime": 297, - "name": "lh:audit:offscreen-content-hidden", + "name": "lh:audit:managed-focus", "duration": 1, "entryType": "measure" }, { "startTime": 298, - "name": "lh:audit:use-landmarks", + "name": "lh:audit:offscreen-content-hidden", "duration": 1, "entryType": "measure" }, { "startTime": 299, - "name": "lh:audit:visual-order-follows-dom", + "name": "lh:audit:use-landmarks", "duration": 1, "entryType": "measure" }, { "startTime": 300, - "name": "lh:audit:uses-long-cache-ttl", + "name": "lh:audit:visual-order-follows-dom", "duration": 1, "entryType": "measure" }, { "startTime": 301, - "name": "lh:audit:total-byte-weight", + "name": "lh:audit:uses-long-cache-ttl", "duration": 1, "entryType": "measure" }, { "startTime": 302, - "name": "lh:audit:offscreen-images", + "name": "lh:audit:total-byte-weight", "duration": 1, "entryType": "measure" }, { "startTime": 303, - "name": "lh:audit:render-blocking-resources", + "name": "lh:audit:offscreen-images", "duration": 1, "entryType": "measure" }, { "startTime": 304, - "name": "lh:computed:UnusedCSS", + "name": "lh:audit:render-blocking-resources", "duration": 1, "entryType": "measure" }, { "startTime": 305, - "name": "lh:computed:NavigationInsights", + "name": "lh:computed:UnusedCSS", "duration": 1, "entryType": "measure" }, { "startTime": 306, - "name": "lh:computed:FirstContentfulPaint", + "name": "lh:computed:NavigationInsights", "duration": 1, "entryType": "measure" }, { "startTime": 307, - "name": "lh:audit:unminified-css", + "name": "lh:computed:FirstContentfulPaint", "duration": 1, "entryType": "measure" }, { "startTime": 308, - "name": "lh:audit:unminified-javascript", + "name": "lh:audit:unminified-css", "duration": 1, "entryType": "measure" }, { "startTime": 309, - "name": "lh:audit:unused-css-rules", + "name": "lh:audit:unminified-javascript", "duration": 1, "entryType": "measure" }, { "startTime": 310, - "name": "lh:audit:unused-javascript", + "name": "lh:audit:unused-css-rules", "duration": 1, "entryType": "measure" }, { "startTime": 311, - "name": "lh:audit:modern-image-formats", + "name": "lh:audit:unused-javascript", "duration": 1, "entryType": "measure" }, { "startTime": 312, - "name": "lh:audit:uses-optimized-images", + "name": "lh:audit:modern-image-formats", "duration": 1, "entryType": "measure" }, { "startTime": 313, - "name": "lh:audit:uses-text-compression", + "name": "lh:audit:uses-optimized-images", "duration": 1, "entryType": "measure" }, { "startTime": 314, - "name": "lh:audit:uses-responsive-images", + "name": "lh:audit:uses-text-compression", "duration": 1, "entryType": "measure" }, { "startTime": 315, - "name": "lh:audit:efficient-animated-content", + "name": "lh:audit:uses-responsive-images", "duration": 1, "entryType": "measure" }, { "startTime": 316, - "name": "lh:audit:duplicated-javascript", + "name": "lh:audit:efficient-animated-content", "duration": 1, "entryType": "measure" }, { "startTime": 317, - "name": "lh:audit:legacy-javascript", + "name": "lh:audit:duplicated-javascript", "duration": 1, "entryType": "measure" }, { "startTime": 318, - "name": "lh:audit:doctype", + "name": "lh:audit:legacy-javascript", "duration": 1, "entryType": "measure" }, { "startTime": 319, - "name": "lh:audit:charset", + "name": "lh:audit:doctype", "duration": 1, "entryType": "measure" }, { "startTime": 320, - "name": "lh:audit:dom-size", + "name": "lh:audit:charset", "duration": 1, "entryType": "measure" }, { "startTime": 321, - "name": "lh:audit:geolocation-on-start", + "name": "lh:audit:dom-size", "duration": 1, "entryType": "measure" }, { "startTime": 322, - "name": "lh:audit:inspector-issues", + "name": "lh:audit:geolocation-on-start", "duration": 1, "entryType": "measure" }, { "startTime": 323, - "name": "lh:audit:no-document-write", + "name": "lh:audit:inspector-issues", "duration": 1, "entryType": "measure" }, { "startTime": 324, - "name": "lh:audit:js-libraries", + "name": "lh:audit:no-document-write", "duration": 1, "entryType": "measure" }, { "startTime": 325, - "name": "lh:audit:notification-on-start", + "name": "lh:audit:js-libraries", "duration": 1, "entryType": "measure" }, { "startTime": 326, - "name": "lh:audit:paste-preventing-inputs", + "name": "lh:audit:notification-on-start", "duration": 1, "entryType": "measure" }, { "startTime": 327, - "name": "lh:audit:uses-passive-event-listeners", + "name": "lh:audit:paste-preventing-inputs", "duration": 1, "entryType": "measure" }, { "startTime": 328, - "name": "lh:audit:meta-description", + "name": "lh:audit:uses-passive-event-listeners", "duration": 1, "entryType": "measure" }, { "startTime": 329, - "name": "lh:audit:http-status-code", + "name": "lh:audit:meta-description", "duration": 1, "entryType": "measure" }, { "startTime": 330, - "name": "lh:audit:font-size", + "name": "lh:audit:http-status-code", "duration": 1, "entryType": "measure" }, { "startTime": 331, - "name": "lh:audit:link-text", + "name": "lh:audit:font-size", "duration": 1, "entryType": "measure" }, { "startTime": 332, - "name": "lh:audit:crawlable-anchors", + "name": "lh:audit:link-text", "duration": 1, "entryType": "measure" }, { "startTime": 333, - "name": "lh:audit:is-crawlable", + "name": "lh:audit:crawlable-anchors", "duration": 1, "entryType": "measure" }, { "startTime": 334, - "name": "lh:audit:robots-txt", + "name": "lh:audit:is-crawlable", "duration": 1, "entryType": "measure" }, { "startTime": 335, - "name": "lh:audit:hreflang", + "name": "lh:audit:robots-txt", "duration": 1, "entryType": "measure" }, { "startTime": 336, - "name": "lh:audit:canonical", + "name": "lh:audit:hreflang", "duration": 1, "entryType": "measure" }, { "startTime": 337, - "name": "lh:audit:structured-data", + "name": "lh:audit:canonical", "duration": 1, "entryType": "measure" }, { "startTime": 338, - "name": "lh:audit:bf-cache", + "name": "lh:audit:structured-data", "duration": 1, "entryType": "measure" }, { "startTime": 339, - "name": "lh:audit:cache-insight", + "name": "lh:audit:bf-cache", "duration": 1, "entryType": "measure" }, { "startTime": 340, - "name": "lh:audit:cls-culprits-insight", + "name": "lh:audit:cache-insight", "duration": 1, "entryType": "measure" }, { "startTime": 341, - "name": "lh:audit:document-latency-insight", + "name": "lh:audit:cls-culprits-insight", "duration": 1, "entryType": "measure" }, { "startTime": 342, - "name": "lh:audit:dom-size-insight", + "name": "lh:audit:document-latency-insight", "duration": 1, "entryType": "measure" }, { "startTime": 343, - "name": "lh:audit:duplicated-javascript-insight", + "name": "lh:audit:dom-size-insight", "duration": 1, "entryType": "measure" }, { "startTime": 344, - "name": "lh:audit:font-display-insight", + "name": "lh:audit:duplicated-javascript-insight", "duration": 1, "entryType": "measure" }, { "startTime": 345, - "name": "lh:audit:forced-reflow-insight", + "name": "lh:audit:font-display-insight", "duration": 1, "entryType": "measure" }, { "startTime": 346, - "name": "lh:audit:image-delivery-insight", + "name": "lh:audit:forced-reflow-insight", "duration": 1, "entryType": "measure" }, { "startTime": 347, - "name": "lh:audit:inp-breakdown-insight", + "name": "lh:audit:image-delivery-insight", "duration": 1, "entryType": "measure" }, { "startTime": 348, - "name": "lh:audit:lcp-breakdown-insight", + "name": "lh:audit:inp-breakdown-insight", "duration": 1, "entryType": "measure" }, { "startTime": 349, - "name": "lh:audit:lcp-discovery-insight", + "name": "lh:audit:lcp-breakdown-insight", "duration": 1, "entryType": "measure" }, { "startTime": 350, - "name": "lh:audit:legacy-javascript-insight", + "name": "lh:audit:lcp-discovery-insight", "duration": 1, "entryType": "measure" }, { "startTime": 351, - "name": "lh:audit:modern-http-insight", + "name": "lh:audit:legacy-javascript-insight", "duration": 1, "entryType": "measure" }, { "startTime": 352, - "name": "lh:audit:network-dependency-tree-insight", + "name": "lh:audit:modern-http-insight", "duration": 1, "entryType": "measure" }, { "startTime": 353, - "name": "lh:audit:render-blocking-insight", + "name": "lh:audit:network-dependency-tree-insight", "duration": 1, "entryType": "measure" }, { "startTime": 354, - "name": "lh:audit:third-parties-insight", + "name": "lh:audit:render-blocking-insight", "duration": 1, "entryType": "measure" }, { "startTime": 355, - "name": "lh:audit:viewport-insight", + "name": "lh:audit:third-parties-insight", "duration": 1, "entryType": "measure" }, { "startTime": 356, + "name": "lh:audit:viewport-insight", + "duration": 1, + "entryType": "measure" + }, + { + "startTime": 357, "name": "lh:runner:generate", "duration": 1, "entryType": "measure" } ], - "total": 357 + "total": 358 }, "i18n": { "rendererFormattedStrings": { @@ -9243,7 +9288,8 @@ "audits[csp-xss].details.headings[0].label", "audits[has-hsts].details.headings[0].label", "audits[origin-isolation].details.headings[0].label", - "audits[clickjacking-mitigation].details.headings[0].label" + "audits[clickjacking-mitigation].details.headings[0].label", + "audits[trusted-types-xss].details.headings[0].label" ], "core/audits/server-response-time.js | title": [ "audits[server-response-time].title" @@ -9616,7 +9662,8 @@ "core/lib/i18n/i18n.js | itemSeverityHigh": [ "audits[csp-xss].details.items[0].severity", "audits[origin-isolation].details.items[0].severity", - "audits[clickjacking-mitigation].details.items[0].severity" + "audits[clickjacking-mitigation].details.items[0].severity", + "audits[trusted-types-xss].details.items[0].severity" ], "core/audits/csp-xss.js | noCsp": [ "audits[csp-xss].details.items[0].description" @@ -9670,6 +9717,18 @@ "core/audits/clickjacking-mitigation.js | noClickjackingMitigation": [ "audits[clickjacking-mitigation].details.items[0].description" ], + "core/audits/trusted-types-xss.js | title": [ + "audits[trusted-types-xss].title" + ], + "core/audits/trusted-types-xss.js | description": [ + "audits[trusted-types-xss].description" + ], + "core/audits/trusted-types-xss.js | columnSeverity": [ + "audits[trusted-types-xss].details.headings[1].label" + ], + "core/audits/trusted-types-xss.js | noTrustedTypesToMitigateXss": [ + "audits[trusted-types-xss].details.items[0].description" + ], "core/audits/accessibility/accesskeys.js | title": [ "audits.accesskeys.title" ], @@ -24526,6 +24585,40 @@ ] } }, + "trusted-types-xss": { + "id": "trusted-types-xss", + "title": "Mitigate DOM-based XSS with Trusted Types", + "description": "The `require-trusted-types-for` directive in the `Content-Security-Policy` (CSP) header instructs user agents to control the data passed to DOM XSS sink functions. [Learn more about mitigating DOM-based XSS with Trusted Types](https://web.dev/articles/trusted-types).", + "score": 1, + "scoreDisplayMode": "informative", + "details": { + "type": "table", + "headings": [ + { + "key": "description", + "valueType": "text", + "subItemsHeading": { + "key": "description" + }, + "label": "Description" + }, + { + "key": "severity", + "valueType": "text", + "subItemsHeading": { + "key": "severity" + }, + "label": "Severity" + } + ], + "items": [ + { + "severity": "High", + "description": "No `Content-Security-Policy` header with Trusted Types directive found" + } + ] + } + }, "script-treemap-data": { "id": "script-treemap-data", "title": "Script Treemap Data", @@ -28540,6 +28633,11 @@ "weight": 0, "group": "best-practices-trust-safety" }, + { + "id": "trusted-types-xss", + "weight": 0, + "group": "best-practices-trust-safety" + }, { "id": "paste-preventing-inputs", "weight": 3, @@ -30617,19 +30715,19 @@ }, { "startTime": 209, - "name": "lh:audit:script-treemap-data", + "name": "lh:audit:trusted-types-xss", "duration": 1, "entryType": "measure" }, { "startTime": 210, - "name": "lh:computed:ModuleDuplication", + "name": "lh:audit:script-treemap-data", "duration": 1, "entryType": "measure" }, { "startTime": 211, - "name": "lh:computed:UnusedJavascriptSummary", + "name": "lh:computed:ModuleDuplication", "duration": 1, "entryType": "measure" }, @@ -30719,786 +30817,792 @@ }, { "startTime": 226, - "name": "lh:audit:accesskeys", + "name": "lh:computed:UnusedJavascriptSummary", "duration": 1, "entryType": "measure" }, { "startTime": 227, - "name": "lh:audit:aria-allowed-attr", + "name": "lh:audit:accesskeys", "duration": 1, "entryType": "measure" }, { "startTime": 228, - "name": "lh:audit:aria-allowed-role", + "name": "lh:audit:aria-allowed-attr", "duration": 1, "entryType": "measure" }, { "startTime": 229, - "name": "lh:audit:aria-command-name", + "name": "lh:audit:aria-allowed-role", "duration": 1, "entryType": "measure" }, { "startTime": 230, - "name": "lh:audit:aria-conditional-attr", + "name": "lh:audit:aria-command-name", "duration": 1, "entryType": "measure" }, { "startTime": 231, - "name": "lh:audit:aria-deprecated-role", + "name": "lh:audit:aria-conditional-attr", "duration": 1, "entryType": "measure" }, { "startTime": 232, - "name": "lh:audit:aria-dialog-name", + "name": "lh:audit:aria-deprecated-role", "duration": 1, "entryType": "measure" }, { "startTime": 233, - "name": "lh:audit:aria-hidden-body", + "name": "lh:audit:aria-dialog-name", "duration": 1, "entryType": "measure" }, { "startTime": 234, - "name": "lh:audit:aria-hidden-focus", + "name": "lh:audit:aria-hidden-body", "duration": 1, "entryType": "measure" }, { "startTime": 235, - "name": "lh:audit:aria-input-field-name", + "name": "lh:audit:aria-hidden-focus", "duration": 1, "entryType": "measure" }, { "startTime": 236, - "name": "lh:audit:aria-meter-name", + "name": "lh:audit:aria-input-field-name", "duration": 1, "entryType": "measure" }, { "startTime": 237, - "name": "lh:audit:aria-progressbar-name", + "name": "lh:audit:aria-meter-name", "duration": 1, "entryType": "measure" }, { "startTime": 238, - "name": "lh:audit:aria-prohibited-attr", + "name": "lh:audit:aria-progressbar-name", "duration": 1, "entryType": "measure" }, { "startTime": 239, - "name": "lh:audit:aria-required-attr", + "name": "lh:audit:aria-prohibited-attr", "duration": 1, "entryType": "measure" }, { "startTime": 240, - "name": "lh:audit:aria-required-children", + "name": "lh:audit:aria-required-attr", "duration": 1, "entryType": "measure" }, { "startTime": 241, - "name": "lh:audit:aria-required-parent", + "name": "lh:audit:aria-required-children", "duration": 1, "entryType": "measure" }, { "startTime": 242, - "name": "lh:audit:aria-roles", + "name": "lh:audit:aria-required-parent", "duration": 1, "entryType": "measure" }, { "startTime": 243, - "name": "lh:audit:aria-text", + "name": "lh:audit:aria-roles", "duration": 1, "entryType": "measure" }, { "startTime": 244, - "name": "lh:audit:aria-toggle-field-name", + "name": "lh:audit:aria-text", "duration": 1, "entryType": "measure" }, { "startTime": 245, - "name": "lh:audit:aria-tooltip-name", + "name": "lh:audit:aria-toggle-field-name", "duration": 1, "entryType": "measure" }, { "startTime": 246, - "name": "lh:audit:aria-treeitem-name", + "name": "lh:audit:aria-tooltip-name", "duration": 1, "entryType": "measure" }, { "startTime": 247, - "name": "lh:audit:aria-valid-attr-value", + "name": "lh:audit:aria-treeitem-name", "duration": 1, "entryType": "measure" }, { "startTime": 248, - "name": "lh:audit:aria-valid-attr", + "name": "lh:audit:aria-valid-attr-value", "duration": 1, "entryType": "measure" }, { "startTime": 249, - "name": "lh:audit:button-name", + "name": "lh:audit:aria-valid-attr", "duration": 1, "entryType": "measure" }, { "startTime": 250, - "name": "lh:audit:bypass", + "name": "lh:audit:button-name", "duration": 1, "entryType": "measure" }, { "startTime": 251, - "name": "lh:audit:color-contrast", + "name": "lh:audit:bypass", "duration": 1, "entryType": "measure" }, { "startTime": 252, - "name": "lh:audit:definition-list", + "name": "lh:audit:color-contrast", "duration": 1, "entryType": "measure" }, { "startTime": 253, - "name": "lh:audit:dlitem", + "name": "lh:audit:definition-list", "duration": 1, "entryType": "measure" }, { "startTime": 254, - "name": "lh:audit:document-title", + "name": "lh:audit:dlitem", "duration": 1, "entryType": "measure" }, { "startTime": 255, - "name": "lh:audit:duplicate-id-aria", + "name": "lh:audit:document-title", "duration": 1, "entryType": "measure" }, { "startTime": 256, - "name": "lh:audit:empty-heading", + "name": "lh:audit:duplicate-id-aria", "duration": 1, "entryType": "measure" }, { "startTime": 257, - "name": "lh:audit:form-field-multiple-labels", + "name": "lh:audit:empty-heading", "duration": 1, "entryType": "measure" }, { "startTime": 258, - "name": "lh:audit:frame-title", + "name": "lh:audit:form-field-multiple-labels", "duration": 1, "entryType": "measure" }, { "startTime": 259, - "name": "lh:audit:heading-order", + "name": "lh:audit:frame-title", "duration": 1, "entryType": "measure" }, { "startTime": 260, - "name": "lh:audit:html-has-lang", + "name": "lh:audit:heading-order", "duration": 1, "entryType": "measure" }, { "startTime": 261, - "name": "lh:audit:html-lang-valid", + "name": "lh:audit:html-has-lang", "duration": 1, "entryType": "measure" }, { "startTime": 262, - "name": "lh:audit:html-xml-lang-mismatch", + "name": "lh:audit:html-lang-valid", "duration": 1, "entryType": "measure" }, { "startTime": 263, - "name": "lh:audit:identical-links-same-purpose", + "name": "lh:audit:html-xml-lang-mismatch", "duration": 1, "entryType": "measure" }, { "startTime": 264, - "name": "lh:audit:image-alt", + "name": "lh:audit:identical-links-same-purpose", "duration": 1, "entryType": "measure" }, { "startTime": 265, - "name": "lh:audit:image-redundant-alt", + "name": "lh:audit:image-alt", "duration": 1, "entryType": "measure" }, { "startTime": 266, - "name": "lh:audit:input-button-name", + "name": "lh:audit:image-redundant-alt", "duration": 1, "entryType": "measure" }, { "startTime": 267, - "name": "lh:audit:input-image-alt", + "name": "lh:audit:input-button-name", "duration": 1, "entryType": "measure" }, { "startTime": 268, - "name": "lh:audit:label-content-name-mismatch", + "name": "lh:audit:input-image-alt", "duration": 1, "entryType": "measure" }, { "startTime": 269, - "name": "lh:audit:label", + "name": "lh:audit:label-content-name-mismatch", "duration": 1, "entryType": "measure" }, { "startTime": 270, - "name": "lh:audit:landmark-one-main", + "name": "lh:audit:label", "duration": 1, "entryType": "measure" }, { "startTime": 271, - "name": "lh:audit:link-name", + "name": "lh:audit:landmark-one-main", "duration": 1, "entryType": "measure" }, { "startTime": 272, - "name": "lh:audit:link-in-text-block", + "name": "lh:audit:link-name", "duration": 1, "entryType": "measure" }, { "startTime": 273, - "name": "lh:audit:list", + "name": "lh:audit:link-in-text-block", "duration": 1, "entryType": "measure" }, { "startTime": 274, - "name": "lh:audit:listitem", + "name": "lh:audit:list", "duration": 1, "entryType": "measure" }, { "startTime": 275, - "name": "lh:audit:meta-refresh", + "name": "lh:audit:listitem", "duration": 1, "entryType": "measure" }, { "startTime": 276, - "name": "lh:audit:meta-viewport", + "name": "lh:audit:meta-refresh", "duration": 1, "entryType": "measure" }, { "startTime": 277, - "name": "lh:audit:object-alt", + "name": "lh:audit:meta-viewport", "duration": 1, "entryType": "measure" }, { "startTime": 278, - "name": "lh:audit:select-name", + "name": "lh:audit:object-alt", "duration": 1, "entryType": "measure" }, { "startTime": 279, - "name": "lh:audit:skip-link", + "name": "lh:audit:select-name", "duration": 1, "entryType": "measure" }, { "startTime": 280, - "name": "lh:audit:tabindex", + "name": "lh:audit:skip-link", "duration": 1, "entryType": "measure" }, { "startTime": 281, - "name": "lh:audit:table-duplicate-name", + "name": "lh:audit:tabindex", "duration": 1, "entryType": "measure" }, { "startTime": 282, - "name": "lh:audit:table-fake-caption", + "name": "lh:audit:table-duplicate-name", "duration": 1, "entryType": "measure" }, { "startTime": 283, - "name": "lh:audit:target-size", + "name": "lh:audit:table-fake-caption", "duration": 1, "entryType": "measure" }, { "startTime": 284, - "name": "lh:audit:td-has-header", + "name": "lh:audit:target-size", "duration": 1, "entryType": "measure" }, { "startTime": 285, - "name": "lh:audit:td-headers-attr", + "name": "lh:audit:td-has-header", "duration": 1, "entryType": "measure" }, { "startTime": 286, - "name": "lh:audit:th-has-data-cells", + "name": "lh:audit:td-headers-attr", "duration": 1, "entryType": "measure" }, { "startTime": 287, - "name": "lh:audit:valid-lang", + "name": "lh:audit:th-has-data-cells", "duration": 1, "entryType": "measure" }, { "startTime": 288, - "name": "lh:audit:video-caption", + "name": "lh:audit:valid-lang", "duration": 1, "entryType": "measure" }, { "startTime": 289, - "name": "lh:audit:custom-controls-labels", + "name": "lh:audit:video-caption", "duration": 1, "entryType": "measure" }, { "startTime": 290, - "name": "lh:audit:custom-controls-roles", + "name": "lh:audit:custom-controls-labels", "duration": 1, "entryType": "measure" }, { "startTime": 291, - "name": "lh:audit:focus-traps", + "name": "lh:audit:custom-controls-roles", "duration": 1, "entryType": "measure" }, { "startTime": 292, - "name": "lh:audit:focusable-controls", + "name": "lh:audit:focus-traps", "duration": 1, "entryType": "measure" }, { "startTime": 293, - "name": "lh:audit:interactive-element-affordance", + "name": "lh:audit:focusable-controls", "duration": 1, "entryType": "measure" }, { "startTime": 294, - "name": "lh:audit:logical-tab-order", + "name": "lh:audit:interactive-element-affordance", "duration": 1, "entryType": "measure" }, { "startTime": 295, - "name": "lh:audit:managed-focus", + "name": "lh:audit:logical-tab-order", "duration": 1, "entryType": "measure" }, { "startTime": 296, - "name": "lh:audit:offscreen-content-hidden", + "name": "lh:audit:managed-focus", "duration": 1, "entryType": "measure" }, { "startTime": 297, - "name": "lh:audit:use-landmarks", + "name": "lh:audit:offscreen-content-hidden", "duration": 1, "entryType": "measure" }, { "startTime": 298, - "name": "lh:audit:visual-order-follows-dom", + "name": "lh:audit:use-landmarks", "duration": 1, "entryType": "measure" }, { "startTime": 299, - "name": "lh:audit:uses-long-cache-ttl", + "name": "lh:audit:visual-order-follows-dom", "duration": 1, "entryType": "measure" }, { "startTime": 300, - "name": "lh:audit:total-byte-weight", + "name": "lh:audit:uses-long-cache-ttl", "duration": 1, "entryType": "measure" }, { "startTime": 301, - "name": "lh:audit:offscreen-images", + "name": "lh:audit:total-byte-weight", "duration": 1, "entryType": "measure" }, { "startTime": 302, - "name": "lh:audit:render-blocking-resources", + "name": "lh:audit:offscreen-images", "duration": 1, "entryType": "measure" }, { "startTime": 303, - "name": "lh:computed:UnusedCSS", + "name": "lh:audit:render-blocking-resources", "duration": 1, "entryType": "measure" }, { "startTime": 304, - "name": "lh:computed:NavigationInsights", + "name": "lh:computed:UnusedCSS", "duration": 1, "entryType": "measure" }, { "startTime": 305, - "name": "lh:computed:FirstContentfulPaint", + "name": "lh:computed:NavigationInsights", "duration": 1, "entryType": "measure" }, { "startTime": 306, - "name": "lh:audit:unminified-css", + "name": "lh:computed:FirstContentfulPaint", "duration": 1, "entryType": "measure" }, { "startTime": 307, - "name": "lh:audit:unminified-javascript", + "name": "lh:audit:unminified-css", "duration": 1, "entryType": "measure" }, { "startTime": 308, - "name": "lh:audit:unused-css-rules", + "name": "lh:audit:unminified-javascript", "duration": 1, "entryType": "measure" }, { "startTime": 309, - "name": "lh:audit:unused-javascript", + "name": "lh:audit:unused-css-rules", "duration": 1, "entryType": "measure" }, { "startTime": 310, - "name": "lh:audit:modern-image-formats", + "name": "lh:audit:unused-javascript", "duration": 1, "entryType": "measure" }, { "startTime": 311, - "name": "lh:audit:uses-optimized-images", + "name": "lh:audit:modern-image-formats", "duration": 1, "entryType": "measure" }, { "startTime": 312, - "name": "lh:audit:uses-text-compression", + "name": "lh:audit:uses-optimized-images", "duration": 1, "entryType": "measure" }, { "startTime": 313, - "name": "lh:audit:uses-responsive-images", + "name": "lh:audit:uses-text-compression", "duration": 1, "entryType": "measure" }, { "startTime": 314, - "name": "lh:audit:efficient-animated-content", + "name": "lh:audit:uses-responsive-images", "duration": 1, "entryType": "measure" }, { "startTime": 315, - "name": "lh:audit:duplicated-javascript", + "name": "lh:audit:efficient-animated-content", "duration": 1, "entryType": "measure" }, { "startTime": 316, - "name": "lh:audit:legacy-javascript", + "name": "lh:audit:duplicated-javascript", "duration": 1, "entryType": "measure" }, { "startTime": 317, - "name": "lh:audit:doctype", + "name": "lh:audit:legacy-javascript", "duration": 1, "entryType": "measure" }, { "startTime": 318, - "name": "lh:audit:charset", + "name": "lh:audit:doctype", "duration": 1, "entryType": "measure" }, { "startTime": 319, - "name": "lh:audit:dom-size", + "name": "lh:audit:charset", "duration": 1, "entryType": "measure" }, { "startTime": 320, - "name": "lh:audit:geolocation-on-start", + "name": "lh:audit:dom-size", "duration": 1, "entryType": "measure" }, { "startTime": 321, - "name": "lh:audit:inspector-issues", + "name": "lh:audit:geolocation-on-start", "duration": 1, "entryType": "measure" }, { "startTime": 322, - "name": "lh:audit:no-document-write", + "name": "lh:audit:inspector-issues", "duration": 1, "entryType": "measure" }, { "startTime": 323, - "name": "lh:audit:js-libraries", + "name": "lh:audit:no-document-write", "duration": 1, "entryType": "measure" }, { "startTime": 324, - "name": "lh:audit:notification-on-start", + "name": "lh:audit:js-libraries", "duration": 1, "entryType": "measure" }, { "startTime": 325, - "name": "lh:audit:paste-preventing-inputs", + "name": "lh:audit:notification-on-start", "duration": 1, "entryType": "measure" }, { "startTime": 326, - "name": "lh:audit:uses-passive-event-listeners", + "name": "lh:audit:paste-preventing-inputs", "duration": 1, "entryType": "measure" }, { "startTime": 327, - "name": "lh:audit:meta-description", + "name": "lh:audit:uses-passive-event-listeners", "duration": 1, "entryType": "measure" }, { "startTime": 328, - "name": "lh:audit:http-status-code", + "name": "lh:audit:meta-description", "duration": 1, "entryType": "measure" }, { "startTime": 329, - "name": "lh:audit:font-size", + "name": "lh:audit:http-status-code", "duration": 1, "entryType": "measure" }, { "startTime": 330, - "name": "lh:audit:link-text", + "name": "lh:audit:font-size", "duration": 1, "entryType": "measure" }, { "startTime": 331, - "name": "lh:audit:crawlable-anchors", + "name": "lh:audit:link-text", "duration": 1, "entryType": "measure" }, { "startTime": 332, - "name": "lh:audit:is-crawlable", + "name": "lh:audit:crawlable-anchors", "duration": 1, "entryType": "measure" }, { "startTime": 333, - "name": "lh:audit:robots-txt", + "name": "lh:audit:is-crawlable", "duration": 1, "entryType": "measure" }, { "startTime": 334, - "name": "lh:audit:hreflang", + "name": "lh:audit:robots-txt", "duration": 1, "entryType": "measure" }, { "startTime": 335, - "name": "lh:audit:canonical", + "name": "lh:audit:hreflang", "duration": 1, "entryType": "measure" }, { "startTime": 336, - "name": "lh:audit:structured-data", + "name": "lh:audit:canonical", "duration": 1, "entryType": "measure" }, { "startTime": 337, - "name": "lh:audit:bf-cache", + "name": "lh:audit:structured-data", "duration": 1, "entryType": "measure" }, { "startTime": 338, - "name": "lh:audit:cache-insight", + "name": "lh:audit:bf-cache", "duration": 1, "entryType": "measure" }, { "startTime": 339, - "name": "lh:audit:cls-culprits-insight", + "name": "lh:audit:cache-insight", "duration": 1, "entryType": "measure" }, { "startTime": 340, - "name": "lh:audit:document-latency-insight", + "name": "lh:audit:cls-culprits-insight", "duration": 1, "entryType": "measure" }, { "startTime": 341, - "name": "lh:audit:dom-size-insight", + "name": "lh:audit:document-latency-insight", "duration": 1, "entryType": "measure" }, { "startTime": 342, - "name": "lh:audit:duplicated-javascript-insight", + "name": "lh:audit:dom-size-insight", "duration": 1, "entryType": "measure" }, { "startTime": 343, - "name": "lh:audit:font-display-insight", + "name": "lh:audit:duplicated-javascript-insight", "duration": 1, "entryType": "measure" }, { "startTime": 344, - "name": "lh:audit:forced-reflow-insight", + "name": "lh:audit:font-display-insight", "duration": 1, "entryType": "measure" }, { "startTime": 345, - "name": "lh:audit:image-delivery-insight", + "name": "lh:audit:forced-reflow-insight", "duration": 1, "entryType": "measure" }, { "startTime": 346, - "name": "lh:audit:inp-breakdown-insight", + "name": "lh:audit:image-delivery-insight", "duration": 1, "entryType": "measure" }, { "startTime": 347, - "name": "lh:audit:lcp-breakdown-insight", + "name": "lh:audit:inp-breakdown-insight", "duration": 1, "entryType": "measure" }, { "startTime": 348, - "name": "lh:audit:lcp-discovery-insight", + "name": "lh:audit:lcp-breakdown-insight", "duration": 1, "entryType": "measure" }, { "startTime": 349, - "name": "lh:audit:legacy-javascript-insight", + "name": "lh:audit:lcp-discovery-insight", "duration": 1, "entryType": "measure" }, { "startTime": 350, - "name": "lh:audit:modern-http-insight", + "name": "lh:audit:legacy-javascript-insight", "duration": 1, "entryType": "measure" }, { "startTime": 351, - "name": "lh:audit:network-dependency-tree-insight", + "name": "lh:audit:modern-http-insight", "duration": 1, "entryType": "measure" }, { "startTime": 352, - "name": "lh:audit:render-blocking-insight", + "name": "lh:audit:network-dependency-tree-insight", "duration": 1, "entryType": "measure" }, { "startTime": 353, - "name": "lh:audit:third-parties-insight", + "name": "lh:audit:render-blocking-insight", "duration": 1, "entryType": "measure" }, { "startTime": 354, - "name": "lh:audit:viewport-insight", + "name": "lh:audit:third-parties-insight", "duration": 1, "entryType": "measure" }, { "startTime": 355, + "name": "lh:audit:viewport-insight", + "duration": 1, + "entryType": "measure" + }, + { + "startTime": 356, "name": "lh:runner:generate", "duration": 1, "entryType": "measure" } ], - "total": 356 + "total": 357 }, "i18n": { "rendererFormattedStrings": { @@ -31722,7 +31826,8 @@ "audits[csp-xss].details.headings[0].label", "audits[has-hsts].details.headings[0].label", "audits[origin-isolation].details.headings[0].label", - "audits[clickjacking-mitigation].details.headings[0].label" + "audits[clickjacking-mitigation].details.headings[0].label", + "audits[trusted-types-xss].details.headings[0].label" ], "core/audits/server-response-time.js | title": [ "audits[server-response-time].title" @@ -32088,7 +32193,8 @@ "core/lib/i18n/i18n.js | itemSeverityHigh": [ "audits[csp-xss].details.items[0].severity", "audits[origin-isolation].details.items[0].severity", - "audits[clickjacking-mitigation].details.items[0].severity" + "audits[clickjacking-mitigation].details.items[0].severity", + "audits[trusted-types-xss].details.items[0].severity" ], "core/audits/csp-xss.js | noCsp": [ "audits[csp-xss].details.items[0].description" @@ -32142,6 +32248,18 @@ "core/audits/clickjacking-mitigation.js | noClickjackingMitigation": [ "audits[clickjacking-mitigation].details.items[0].description" ], + "core/audits/trusted-types-xss.js | title": [ + "audits[trusted-types-xss].title" + ], + "core/audits/trusted-types-xss.js | description": [ + "audits[trusted-types-xss].description" + ], + "core/audits/trusted-types-xss.js | columnSeverity": [ + "audits[trusted-types-xss].details.headings[1].label" + ], + "core/audits/trusted-types-xss.js | noTrustedTypesToMitigateXss": [ + "audits[trusted-types-xss].details.items[0].description" + ], "core/audits/accessibility/accesskeys.js | title": [ "audits.accesskeys.title" ], diff --git a/core/test/results/sample_v2.json b/core/test/results/sample_v2.json index 2184fd7cca95..9e2bc9e1993f 100644 --- a/core/test/results/sample_v2.json +++ b/core/test/results/sample_v2.json @@ -2778,6 +2778,40 @@ ] } }, + "trusted-types-xss": { + "id": "trusted-types-xss", + "title": "Mitigate DOM-based XSS with Trusted Types", + "description": "The `require-trusted-types-for` directive in the `Content-Security-Policy` (CSP) header instructs user agents to control the data passed to DOM XSS sink functions. [Learn more about mitigating DOM-based XSS with Trusted Types](https://web.dev/articles/trusted-types).", + "score": 1, + "scoreDisplayMode": "informative", + "details": { + "type": "table", + "headings": [ + { + "key": "description", + "valueType": "text", + "subItemsHeading": { + "key": "description" + }, + "label": "Description" + }, + { + "key": "severity", + "valueType": "text", + "subItemsHeading": { + "key": "severity" + }, + "label": "Severity" + } + ], + "items": [ + { + "severity": "High", + "description": "No `Content-Security-Policy` header with Trusted Types directive found" + } + ] + } + }, "script-treemap-data": { "id": "script-treemap-data", "title": "Script Treemap Data", @@ -7949,6 +7983,11 @@ "weight": 0, "group": "best-practices-trust-safety" }, + { + "id": "trusted-types-xss", + "weight": 0, + "group": "best-practices-trust-safety" + }, { "id": "paste-preventing-inputs", "weight": 3, @@ -9859,6 +9898,12 @@ "duration": 100, "entryType": "measure" }, + { + "startTime": 0, + "name": "lh:audit:trusted-types-xss", + "duration": 100, + "entryType": "measure" + }, { "startTime": 0, "name": "lh:audit:script-treemap-data", @@ -11019,7 +11064,8 @@ "audits[csp-xss].details.headings[0].label", "audits[has-hsts].details.headings[0].label", "audits[origin-isolation].details.headings[0].label", - "audits[clickjacking-mitigation].details.headings[0].label" + "audits[clickjacking-mitigation].details.headings[0].label", + "audits[trusted-types-xss].details.headings[0].label" ], "core/audits/server-response-time.js | title": [ "audits[server-response-time].title" @@ -11468,7 +11514,8 @@ "audits[csp-xss].details.items[0].severity", "audits[has-hsts].details.items[0].severity", "audits[origin-isolation].details.items[0].severity", - "audits[clickjacking-mitigation].details.items[0].severity" + "audits[clickjacking-mitigation].details.items[0].severity", + "audits[trusted-types-xss].details.items[0].severity" ], "core/audits/csp-xss.js | noCsp": [ "audits[csp-xss].details.items[0].description" @@ -11515,6 +11562,18 @@ "core/audits/clickjacking-mitigation.js | noClickjackingMitigation": [ "audits[clickjacking-mitigation].details.items[0].description" ], + "core/audits/trusted-types-xss.js | title": [ + "audits[trusted-types-xss].title" + ], + "core/audits/trusted-types-xss.js | description": [ + "audits[trusted-types-xss].description" + ], + "core/audits/trusted-types-xss.js | columnSeverity": [ + "audits[trusted-types-xss].details.headings[1].label" + ], + "core/audits/trusted-types-xss.js | noTrustedTypesToMitigateXss": [ + "audits[trusted-types-xss].details.items[0].description" + ], "core/audits/accessibility/accesskeys.js | title": [ "audits.accesskeys.title" ], diff --git a/shared/localization/locales/en-US.json b/shared/localization/locales/en-US.json index 98522ff5b80f..bfe20c8248e3 100644 --- a/shared/localization/locales/en-US.json +++ b/shared/localization/locales/en-US.json @@ -1493,6 +1493,18 @@ "core/audits/third-party-summary.js | title": { "message": "Minimize third-party usage" }, + "core/audits/trusted-types-xss.js | columnSeverity": { + "message": "Severity" + }, + "core/audits/trusted-types-xss.js | description": { + "message": "The `require-trusted-types-for` directive in the `Content-Security-Policy` (CSP) header instructs user agents to control the data passed to DOM XSS sink functions. [Learn more about mitigating DOM-based XSS with Trusted Types](https://web.dev/articles/trusted-types)." + }, + "core/audits/trusted-types-xss.js | noTrustedTypesToMitigateXss": { + "message": "No `Content-Security-Policy` header with Trusted Types directive found" + }, + "core/audits/trusted-types-xss.js | title": { + "message": "Mitigate DOM-based XSS with Trusted Types" + }, "core/audits/unsized-images.js | description": { "message": "Set an explicit width and height on image elements to reduce layout shifts and improve CLS. [Learn how to set image dimensions](https://web.dev/articles/optimize-cls#images_without_dimensions)" }, diff --git a/shared/localization/locales/en-XL.json b/shared/localization/locales/en-XL.json index 35a54b52869d..3d83eb8048d0 100644 --- a/shared/localization/locales/en-XL.json +++ b/shared/localization/locales/en-XL.json @@ -1493,6 +1493,18 @@ "core/audits/third-party-summary.js | title": { "message": "M̂ín̂ím̂íẑé t̂h́îŕd̂-ṕâŕt̂ý ûśâǵê" }, + "core/audits/trusted-types-xss.js | columnSeverity": { + "message": "Ŝév̂ér̂ít̂ý" + }, + "core/audits/trusted-types-xss.js | description": { + "message": "T̂h́ê `require-trusted-types-for` d́îŕêćt̂ív̂é îń t̂h́ê `Content-Security-Policy` (ĆŜṔ) ĥéâd́êŕ îńŝt́r̂úĉt́ŝ úŝér̂ áĝén̂t́ŝ t́ô ćôńt̂ŕôĺ t̂h́ê d́ât́â ṕâśŝéd̂ t́ô D́ÔḾ X̂ŚŜ śîńk̂ f́ûńĉt́îón̂ś. [L̂éâŕn̂ ḿôŕê áb̂óût́ m̂ít̂íĝát̂ín̂ǵ D̂ÓM̂-b́âśêd́ X̂ŚŜ ẃît́ĥ T́r̂úŝt́êd́ T̂ýp̂éŝ](https://web.dev/articles/trusted-types)." + }, + "core/audits/trusted-types-xss.js | noTrustedTypesToMitigateXss": { + "message": "N̂ó `Content-Security-Policy` ĥéâd́êŕ ŵít̂h́ T̂ŕûśt̂éd̂ T́ŷṕêś d̂ír̂éĉt́îv́ê f́ôún̂d́" + }, + "core/audits/trusted-types-xss.js | title": { + "message": "M̂ít̂íĝát̂é D̂ÓM̂-b́âśêd́ X̂ŚŜ ẃît́ĥ T́r̂úŝt́êd́ T̂ýp̂éŝ" + }, "core/audits/unsized-images.js | description": { "message": "Ŝét̂ án̂ éx̂ṕl̂íĉít̂ ẃîd́t̂h́ âńd̂ h́êíĝh́t̂ ón̂ ím̂áĝé êĺêḿêńt̂ś t̂ó r̂éd̂úĉé l̂áŷóût́ ŝh́îf́t̂ś âńd̂ ím̂ṕr̂óv̂é ĈĹŜ. [Ĺêár̂ń ĥóŵ t́ô śêt́ îḿâǵê d́îḿêńŝíôńŝ](https://web.dev/articles/optimize-cls#images_without_dimensions)" },