Skip to content

[Detail Bug] Lighthouse: Custom settings leak across sequential getConfig calls using the same preset #697

@detail-app

Description

@detail-app

Detail Bug Report

https://app.detail.dev/org_06887db3-bf54-40ab-976d-46c66ab2b840/bugs/bug_60f97f7c-e8b7-4e77-b932-170ddabe38c0

Summary

  • Context: The get-config.js module loads lighthouse preset configurations and allows users to customize settings by merging user-provided settings with preset defaults.
  • Bug: The function mutates the cached preset configuration object instead of creating a new config object, causing settings from one call to leak into subsequent calls that use the same preset.
  • Actual vs. expected: Settings from previous calls persist in the cached preset object, whereas each call should receive an isolated configuration based only on the preset defaults and the current call's parameters.
  • Impact: In multi-request scenarios (production servers, CLI tools with multiple runs), user-provided settings from one request pollute subsequent requests using the same preset, leading to incorrect lighthouse configurations and potentially incorrect audit results.

Code with Bug

module.exports = async ({ preset: presetName, ...settings } = {}) => {
  const config = (await preset(presetName)) || { extends: 'lighthouse:default' }
  if (Object.keys(settings).length > 0) config.settings = { ...config.settings, ...settings }  // <-- BUG 🔴 mutates cached preset config object via module cache
  return config
}

Explanation

Preset configs are loaded via dynamic import(), which is cached by Node.js. config is a reference to the cached preset object; assigning config.settings = ... updates that cached object. Subsequent calls using the same preset in the same process therefore start from the already-modified settings instead of the preset defaults.

Failing Test

Repro shown via two sequential calls:

  • Call 1: getConfig({ preset: 'lr-desktop', skipAudits: ['custom-audit-1'], output: 'html' }) produces config1.settings.skipAudits === ['custom-audit-1'].
  • Call 2: getConfig({ preset: 'lr-desktop', output: 'json' }) expected preset defaults (e.g. ['modern-http-insight','bf-cache']), but actual is ['custom-audit-1'] (polluted from call 1).

Module-cache mutation is confirmed by importing the same preset module before/after calling getConfig and observing original.default.settings.output change while original1.default === original2.default remains true.

Recommended Fix

Copy the preset config (and settings) before merging user overrides so the cached preset object is never mutated:

module.exports = async ({ preset: presetName, ...settings } = {}) => {
  const presetConfig = (await preset(presetName)) || { extends: 'lighthouse:default' }
  const config = { ...presetConfig }
  if (Object.keys(settings).length > 0) {
    config.settings = { ...presetConfig.settings, ...settings }
  }
  return config
}

History

This bug was introduced in commit 75c31e5, which refactored config loading from synchronous require() to cached async import() and changed behavior from returning a new config object to mutating the imported preset config (config.settings = {...}), enabling cross-call settings pollution.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions