From adb97bd260799f9897c21d02ef6ca9260dcc3ac8 Mon Sep 17 00:00:00 2001 From: ekuboo100 Date: Sun, 28 Dec 2025 06:28:01 +0700 Subject: [PATCH 1/2] fix: Escape special characters in regex pattern --- packages/cli/src/collect/collect.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/collect/collect.js b/packages/cli/src/collect/collect.js index 248c52665..fcf1b0f4b 100644 --- a/packages/cli/src/collect/collect.js +++ b/packages/cli/src/collect/collect.js @@ -18,6 +18,15 @@ const { killProcessTree, } = require('@lhci/utils/src/child-process-helper.js'); +/** + * Escapes special characters in a string so it can be used as a literal pattern in a RegExp. + * @param {string} value + * @return {string} + */ +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * @param {import('yargs').Argv} yargs */ @@ -159,7 +168,8 @@ async function startServerAndDetermineUrls(options) { let close = async () => undefined; if (options.startServerCommand) { - const regexPattern = new RegExp(options.startServerReadyPattern, 'i'); + const safePattern = escapeRegExp(String(options.startServerReadyPattern || '')); + const regexPattern = new RegExp(safePattern, 'i'); const {child, patternMatch, stdout, stderr} = await runCommandAndWaitForPattern( options.startServerCommand, regexPattern, From 4b7e05e7180a4e601c7f18c82a8e6ecc6565a76a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rmungandrk?= Date: Sat, 27 Dec 2025 23:33:41 +0000 Subject: [PATCH 2/2] fix: regular expression escape special characters in regex pattern --- packages/utils/src/assertions.js | 12 ++++++++- packages/utils/src/build-context.js | 12 ++++++++- packages/utils/src/saved-reports.js | 39 ++++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/utils/src/assertions.js b/packages/utils/src/assertions.js index 2a7463eec..80dfcd451 100644 --- a/packages/utils/src/assertions.js +++ b/packages/utils/src/assertions.js @@ -284,7 +284,17 @@ function getCategoryAssertionResults(auditProperty, assertionOptions, lhrs) { * @return {boolean} */ function doesLHRMatchPattern(pattern, lhr) { - return new RegExp(pattern).test(lhr.finalUrl); + let regex; + try { + regex = new RegExp(pattern); + } catch (err) { + // Surface a clearer error message when the provided pattern is not a valid regular expression. + throw new Error( + `Invalid matchingUrlPattern "${pattern}": ${(/** @type {Error} */ (err)).message}` + ); + } + + return regex.test(lhr.finalUrl); } /** diff --git a/packages/utils/src/build-context.js b/packages/utils/src/build-context.js index 5223842cf..d555f2ba9 100644 --- a/packages/utils/src/build-context.js +++ b/packages/utils/src/build-context.js @@ -8,6 +8,15 @@ const crypto = require('crypto'); const childProcess = require('child_process'); +/** + * Escape special characters in a string so it can be used safely inside a RegExp. + * @param {string} string + * @return {string} + */ +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** @param {Array} namesInPriorityOrder @return {string|undefined} */ function getEnvVarIfSet(namesInPriorityOrder) { for (const name of namesInPriorityOrder) { @@ -372,7 +381,8 @@ function getGitHubRepoSlug(apiHost = undefined) { if (remote && apiHost && !apiHost.includes('github.com')) { const hostMatch = apiHost.match(/:\/\/(.*?)(\/|$)/); if (!hostMatch) return undefined; - const remoteRegex = new RegExp(`${hostMatch[1]}(:|\\/)([^/]+\\/.+)\\.git`); + const safeHost = escapeRegExp(hostMatch[1]); + const remoteRegex = new RegExp(`${safeHost}(:|\\/)([^/]+\\/.+)\\.git`); const remoteMatch = remote.match(remoteRegex); if (remoteMatch) return remoteMatch[2]; } diff --git a/packages/utils/src/saved-reports.js b/packages/utils/src/saved-reports.js index 739f2da2a..f7c5e620a 100644 --- a/packages/utils/src/saved-reports.js +++ b/packages/utils/src/saved-reports.js @@ -13,6 +13,16 @@ const LH_HTML_REPORT_REGEX = /^lhr-\d+\.html$/; const ASSERTION_RESULTS_PATH = path.join(LHCI_DIR, 'assertion-results.json'); const URL_LINK_MAP_PATH = path.join(LHCI_DIR, 'links.json'); +/** + * Escape special characters in a string to be used in a RegExp as a literal. + * This mirrors lodash's _.escapeRegExp behavior. + * @param {string} string + * @return {string} + */ +function escapeRegExp(string) { + return string.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); +} + function ensureDirectoryExists(baseDir = LHCI_DIR) { if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, {recursive: true}); } @@ -101,10 +111,33 @@ function replaceUrlPatterns(url, sedLikeReplacementPatterns) { let replaced = url; for (const pattern of sedLikeReplacementPatterns) { - const match = pattern.match(/^s(.)(.*)\1(.*)\1([gim]*)$/); + // sed-like syntax: sneedlereplacement[flags] + // Supports standard JS flags g/i/m and an optional custom flag "L" + // which makes the pattern literal (needle is escaped before use). + const match = pattern.match(/^s(.)(.*)\1(.*)\1([gimL]*)$/); if (!match) throw new Error(`Invalid URL replacement pattern "${pattern}"`); - const [needle, replacement, flags] = match.slice(2); - const regex = new RegExp(needle, flags); + let [needle, replacement, flags] = match.slice(2); + + const flagChars = flags.split(''); + const literalIndex = flagChars.indexOf('L'); + const isLiteral = literalIndex !== -1; + if (isLiteral) flagChars.splice(literalIndex, 1); + + const allowedFlags = ['g', 'i', 'm']; + const seen = new Set(); + for (const flag of flagChars) { + if (!allowedFlags.includes(flag) || seen.has(flag)) { + throw new Error(`Invalid flags in URL replacement pattern "${pattern}"`); + } + seen.add(flag); + } + const finalFlags = flagChars.join(''); + + if (isLiteral) { + needle = escapeRegExp(needle); + } + + const regex = new RegExp(needle, finalFlags); replaced = replaced.replace(regex, replacement); }