Skip to content

Commit

Permalink
Merge branch 'main' into extrarenames
Browse files Browse the repository at this point in the history
  • Loading branch information
paulirish authored Dec 10, 2024
2 parents 0f3f53d + 36cac18 commit 156634c
Show file tree
Hide file tree
Showing 63 changed files with 4,237 additions and 344 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 @@ -21,6 +21,8 @@ import fpsMaxPassive from './test-definitions/fps-max-passive.js';
import fpsScaled from './test-definitions/fps-scaled.js';
import fpsOverflowX from './test-definitions/fps-overflow-x.js';
import issuesMixedContent from './test-definitions/issues-mixed-content.js';
import hstsFullyPresent from './test-definitions/hsts-fully-present.js';
import hstsMissingDirectives from './test-definitions/hsts-missing-directives.js';
import lanternFetch from './test-definitions/lantern-fetch.js';
import lanternIdleCallbackLong from './test-definitions/lantern-idle-callback-long.js';
import lanternIdleCallbackShort from './test-definitions/lantern-idle-callback-short.js';
Expand Down Expand Up @@ -79,6 +81,8 @@ const smokeTests = [
fpsOverflowX,
fpsScaled,
issuesMixedContent,
hstsFullyPresent,
hstsMissingDirectives,
lanternFetch,
lanternIdleCallbackLong,
lanternIdleCallbackShort,
Expand Down
26 changes: 26 additions & 0 deletions cli/test/smokehouse/test-definitions/hsts-fully-present.js
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 a site with full HSTS deployed.
*/
const expectations = {
lhr: {
requestedUrl: 'https://hstspreload.org/',
finalDisplayedUrl: 'https://hstspreload.org/',
audits: {
'has-hsts': {
score: null,
},
},
},
};

export default {
id: 'hsts-fully-present',
expectations,
};
40 changes: 40 additions & 0 deletions cli/test/smokehouse/test-definitions/hsts-missing-directives.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* @type {Smokehouse.ExpectedRunnerResult}
* Expected Lighthouse results a site with HSTS header issues.
*/
const expectations = {
lhr: {
requestedUrl: 'https://developer.mozilla.org/en-US/',
finalDisplayedUrl: 'https://developer.mozilla.org/en-US/',
audits: {
'has-hsts': {
score: 1,
details: {
items: [
{
directive: 'includeSubDomains',
description: 'No `includeSubDomains` directive found',
severity: 'Medium',
},
{
directive: 'preload',
description: 'No `preload` directive found',
severity: 'Medium',
},
],
},
},
},
},
};

export default {
id: 'hsts-missing-directives',
expectations,
};
10 changes: 6 additions & 4 deletions cli/test/smokehouse/test-definitions/oopif-scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ const expectations = {
// From in-process iframe
{url: 'http://localhost:10200/simple-script.js', resourceType: 'Script', sessionTargetType: 'page'},
{url: 'http://localhost:10200/simple-script.js', resourceType: 'Fetch', sessionTargetType: 'page'},
{url: 'http://localhost:10200/simple-worker.js', sessionTargetType: 'page'},
// This target type can vary depending on if Chrome's field trial config is being used
// The target type of worker requests can vary depending on the Chrome version and if
// the field trial config is being used.
{url: 'http://localhost:10200/simple-worker.js', sessionTargetType: /(page|worker)/},
{url: 'http://localhost:10200/simple-worker.mjs', sessionTargetType: /(page|worker)/},
// From in-process iframe -> simple-worker.js
{url: 'http://localhost:10200/simple-script.js?importScripts', resourceType: 'Other', sessionTargetType: 'worker'},
Expand All @@ -61,8 +62,9 @@ const expectations = {
// From OOPIF
{url: 'http://localhost:10503/simple-script.js', resourceType: 'Script', sessionTargetType: 'iframe'},
{url: 'http://localhost:10503/simple-script.js', resourceType: 'Fetch', sessionTargetType: 'iframe'},
{url: 'http://localhost:10503/simple-worker.js', sessionTargetType: 'iframe'},
// This target type can vary depending on if Chrome's field trial config is being used
// The target type of worker requests can vary depending on the Chrome version and if
// the field trial config is being used.
{url: 'http://localhost:10503/simple-worker.js', sessionTargetType: /(iframe|worker)/},
{url: 'http://localhost:10503/simple-worker.mjs', sessionTargetType: /(iframe|worker)/},
// From OOPIF -> simple-worker.js
{url: 'http://localhost:10503/simple-script.js?importScripts', resourceType: 'Other', sessionTargetType: 'worker'},
Expand Down
208 changes: 208 additions & 0 deletions core/audits/has-hsts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* @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 HSTS header. "HSTS" stands for "HTTP Strict Transport Security". */
title: 'Use a strong HSTS policy',
/** Description of a Lighthouse audit that evaluates the security of a page's HSTS header. 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. "HSTS" stands for "HTTP Strict Transport Security". */
description: 'Deployment of the HSTS header significantly ' +
'reduces the risk of downgrading HTTP connections and eavesdropping attacks. ' +
'A rollout in stages, starting with a low max-age is recommended. ' +
'[Learn more about using a strong HSTS policy.](https://developer.chrome.com/docs/lighthouse/best-practices/has-hsts)',
/** Summary text for the results of a Lighthouse audit that evaluates the HSTS header. This is displayed if no HSTS header is deployed. "HSTS" stands for "HTTP Strict Transport Security". */
noHsts: 'No HSTS header found',
/** Summary text for the results of a Lighthouse audit that evaluates the HSTS header. This is displayed if the preload directive is missing. "HSTS" stands for "HTTP Strict Transport Security". */
noPreload: 'No `preload` directive found',
/** Summary text for the results of a Lighthouse audit that evaluates the HSTS header. This is displayed if the includeSubDomains directive is missing. "HSTS" stands for "HTTP Strict Transport Security". */
noSubdomain: 'No `includeSubDomains` directive found',
/** Summary text for the results of a Lighthouse audit that evaluates the HSTS header. This is displayed if the max-age directive is missing. "HSTS" stands for "HTTP Strict Transport Security". */
noMaxAge: 'No `max-age` directive',
/** Summary text for the results of a Lighthouse audit that evaluates the HSTS header. This is displayed if the provided duration for the max-age directive is too low. "HSTS" stands for "HTTP Strict Transport Security". */
lowMaxAge: '`max-age` is too low',
/** 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 HSTS header. "HSTS" stands for "HTTP Strict Transport Security". */
columnDirective: 'Directive',
/** Label for a column in a data table; entries will be the severity of an issue with the HSTS header. "HSTS" stands for "HTTP Strict Transport Security". */
columnSeverity: 'Severity',
};

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

class HasHsts extends Audit {
/**
* @return {LH.Audit.Meta}
*/
static get meta() {
return {
id: 'has-hsts',
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 getRawHsts(artifacts, context) {
const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
const mainResource =
await MainResource.request({devtoolsLog, URL: artifacts.URL}, context);

let hstsHeaders =
mainResource.responseHeaders
.filter(h => {
return h.name.toLowerCase() === 'strict-transport-security';
})
.flatMap(h => h.value.split(';'));

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

return hstsHeaders;
}

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

/**
* @param {string[]} hstsHeaders
* @return {{score: number, results: LH.Audit.Details.TableItem[]}}
*/
static constructResults(hstsHeaders) {
const rawHsts = [...hstsHeaders];
const allowedDirectives = ['max-age', 'includesubdomains', 'preload'];
const violations = [];
const warnings = [];
const syntax = [];

if (!rawHsts.length) {
return {
score: 0,
results: [{
severity: str_(i18n.UIStrings.itemSeverityHigh),
description: str_(UIStrings.noHsts),
directive: undefined,
}],
};
}

// No max-age is a violation and renders the HSTS header useless.
if (!hstsHeaders.toString().includes('max-age')) {
violations.push({
severity: str_(i18n.UIStrings.itemSeverityHigh),
description: str_(UIStrings.noMaxAge),
directive: 'max-age',
});
}

if (!hstsHeaders.toString().includes('includesubdomains')) {
// No includeSubdomains might be even wanted. But would be preferred.
warnings.push({
severity: str_(i18n.UIStrings.itemSeverityMedium),
description: str_(UIStrings.noSubdomain),
directive: 'includeSubDomains',
});
}

if (!hstsHeaders.toString().includes('preload')) {
// No preload might be even wanted. But would be preferred.
warnings.push({
severity: str_(i18n.UIStrings.itemSeverityMedium),
description: str_(UIStrings.noPreload),
directive: 'preload',
});
}

for (const actualDirective of hstsHeaders) {
// We recommend 2y max-age. But if it's lower than 1y, it's a violation.
if (actualDirective.includes('max-age') &&
parseInt(actualDirective.split('=')[1], 10) < 31536000) {
violations.push({
severity: str_(i18n.UIStrings.itemSeverityHigh),
description: str_(UIStrings.lowMaxAge),
directive: 'max-age',
});
}

// If there is a directive that's not an official HSTS directive.
if (!allowedDirectives.includes(actualDirective) &&
!actualDirective.includes('max-age')) {
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))),
...warnings.map(
f => this.findingToTableItem(
f.directive, f.description,
str_(i18n.UIStrings.itemSeverityMedium))),
...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 hstsHeaders = await this.getRawHsts(artifacts, context);
const {score, results} = this.constructResults(hstsHeaders);

/** @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 HasHsts;
export {UIStrings};
28 changes: 27 additions & 1 deletion core/computed/trace-engine-result.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import * as i18n from '../lib/i18n/i18n.js';
import * as TraceEngine from '../lib/trace-engine.js';
import {makeComputedArtifact} from './computed-artifact.js';
import {CumulativeLayoutShift} from './metrics/cumulative-layout-shift.js';
Expand Down Expand Up @@ -35,7 +36,32 @@ class TraceEngineResult {
), {});
if (!processor.parsedTrace) throw new Error('No data');
if (!processor.insights) throw new Error('No insights');
return {parsedTrace: processor.parsedTrace, insights: processor.insights};
this.localizeInsights(processor.insights);
return {data: processor.parsedTrace, insights: processor.insights};

Check failure on line 40 in core/computed/trace-engine-result.js

View workflow job for this annotation

GitHub Actions / basics

Object literal may only specify known properties, and 'data' does not exist in type 'TraceEngineResult'.

Check failure on line 40 in core/computed/trace-engine-result.js

View workflow job for this annotation

GitHub Actions / Package Test

Object literal may only specify known properties, and 'data' does not exist in type 'TraceEngineResult'.
}

/**
* @param {import('@paulirish/trace_engine/models/trace/insights/types.js').TraceInsightSets} insightSets
*/
static localizeInsights(insightSets) {
for (const insightSet of insightSets.values()) {
for (const [name, model] of Object.entries(insightSet.model)) {
if (model instanceof Error) {
continue;
}

const key = `node_modules/@paulirish/trace_engine/models/trace/insights/${name}.js`;
const str_ = i18n.createIcuMessageFn(key, {
title: model.title,
description: model.description,
});

// @ts-expect-error coerce to string, should be fine
model.title = str_(model.title);
// @ts-expect-error coerce to string, should be fine
model.description = str_(model.description);
}
}
}

/**
Expand Down
2 changes: 2 additions & 0 deletions core/config/default-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ const defaultConfig = {
'valid-source-maps',
'prioritize-lcp-image',
'csp-xss',
'has-hsts',
'script-treemap-data',
'accessibility/accesskeys',
'accessibility/aria-allowed-attr',
Expand Down Expand Up @@ -541,6 +542,7 @@ const defaultConfig = {
{id: 'geolocation-on-start', weight: 1, group: 'best-practices-trust-safety'},
{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'},
// User Experience
{id: 'paste-preventing-inputs', weight: 3, group: 'best-practices-ux'},
{id: 'image-aspect-ratio', weight: 1, group: 'best-practices-ux'},
Expand Down
Loading

0 comments on commit 156634c

Please sign in to comment.