fix(security): address multiple security vulnerabilities across codebase (#14570)

* fix(security): address multiple security vulnerabilities across codebase

* further improvements

* fix unit tests

* Potential fix for code scanning alert no. 65: Incomplete multi-character sanitization

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 69: Incomplete multi-character sanitization

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* properly escape regexp

* minor formatting

* improvements

* Potential fix for code scanning alert no. 72: Bad HTML filtering regexp

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 76: Incomplete multi-character sanitization

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* more stable comment removal implementation

* fix tests

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Christian Bromann 2025-07-16 11:18:01 -07:00 committed by GitHub
parent 69b2f3f90b
commit 37b4536580
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 497 additions and 56 deletions

View file

@ -1,7 +1,6 @@
import cp, { type ExecException } from 'node:child_process'
import url from 'node:url'
import path from 'node:path'
import util from 'node:util'
import chalk from 'chalk'
import { rimraf } from 'rimraf'
import { copy } from 'esbuild-plugin-copy'
@ -10,7 +9,6 @@ import type { PackageJson } from 'type-fest'
const MAX_RETRIES = 3
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const exec = util.promisify(cp.exec)
const tscPath = path.resolve(path.dirname(url.fileURLToPath(import.meta.resolve('typescript'))), '..', 'bin', 'tsc')
const l = {
@ -74,8 +72,36 @@ export function clear(config: BuildOptions): Plugin {
* @return {Promise<void>} A promise that resolves when the types are generated.
*/
function generateTypes(cwd: string, pkg: PackageJson, retry = MAX_RETRIES): Promise<void> {
const child = exec(`node ${tscPath} --emitDeclarationOnly`, { cwd })
return child.then(() => { }, (err) => {
return new Promise<void>((resolve, reject) => {
const child = cp.spawn('node', [tscPath, '--emitDeclarationOnly'], { cwd })
let stdout = ''
let stderr = ''
child.stdout?.on('data', (data) => {
stdout += data
})
child.stderr?.on('data', (data) => {
stderr += data
})
child.on('close', (code) => {
if (code === 0) {
resolve(undefined)
} else {
const error = new Error(`TypeScript compilation failed with code ${code}`)
// @ts-ignore
error.stdout = stdout
// @ts-ignore
error.stderr = stderr
reject(error)
}
})
child.on('error', (error) => {
reject(error)
})
}).catch((err) => {
if (retry > 0) {
console.log(`${l.name(pkg.name)} ↻ Retrying (${MAX_RETRIES - (retry - 1)}/${MAX_RETRIES}) building types for ${pkg.name}`)
return generateTypes(cwd, pkg, retry - 1)

View file

@ -437,7 +437,7 @@ export const QUESTIONNAIRE = [{
default: 'LT_USERNAME',
when: /* istanbul ignore next */ (answers: Questionnair) => (
answers.backend && answers.backend.indexOf('LambdaTest') > -1 &&
answers.hostname!.indexOf('lambdatest.com') > -1
(answers.hostname === 'lambdatest.com' || answers.hostname?.endsWith('.lambdatest.com'))
)
}, {
type: 'input',
@ -446,7 +446,7 @@ export const QUESTIONNAIRE = [{
default: 'LT_ACCESS_KEY',
when: /* istanbul ignore next */ (answers: Questionnair) => (
answers.backend && answers.backend.indexOf('LambdaTest') > -1 &&
answers.hostname!.indexOf('lambdatest.com') > -1
(answers.hostname === 'lambdatest.com' || answers.hostname?.endsWith('.lambdatest.com'))
)
}, {
type: 'input',

View file

@ -61,7 +61,7 @@ class AiHandler {
const sessionId = browser.sessionId
// a utility function to escape single and double quotes
const escapeString = (str: string) => str.replace(/'/g, "\\'").replace(/"/g, '\\"')
const escapeString = (str: string) => str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')
const tcgDetails = escapeString(JSON.stringify({
region: TCG_INFO.tcgRegion,

View file

@ -1129,7 +1129,7 @@ export function isBrowserstackInfra(config: BrowserstackConfig & Options.Testrun
// In case hostname is not present anywhere in the config, it returns true by default as hostname is not a mandatory parameter in the config
const isBrowserstack = (str: string ): boolean => {
return str.includes('browserstack.com')
return str === 'browserstack.com' || str.endsWith('.browserstack.com')
}
if ((config.hostname) && !isBrowserstack(config.hostname)) {
@ -1652,12 +1652,27 @@ export function getBooleanValueFromString(value: string | undefined): boolean {
return ['true'].includes(value.trim().toLowerCase())
}
/**
* Checks if a key is safe to use for object property assignment to prevent prototype pollution
* @param key - The key to check
* @returns true if the key is safe, false otherwise
*/
function isSafeKey(key: string): boolean {
const dangerousKeys = ['__proto__', 'constructor', 'prototype']
return !dangerousKeys.includes(key)
}
export function mergeDeep(target: Record<string, any>, ...sources: any[]): Record<string, any> {
if (!sources.length) {return target}
const source = sources.shift()
if (isObject(target) && isObject(source)) {
for (const key in source) {
// Skip dangerous keys that could lead to prototype pollution
if (!isSafeKey(key)) {
continue
}
const sourceValue = source[key]
const targetValue = target[key]

View file

@ -222,7 +222,7 @@ export function generateSkipTagsFromCapabilities(capabilities: Capabilities.Reso
.find((filter: WebdriverIO.Capabilities) => Object.keys(filter)
.every((key: keyof WebdriverIO.Capabilities) => match((capabilities as Record<string, string>)[key], filter[key] as RegExp)))
if (isSkip) {
generatedTags.push(`(not ${tag.replace(/(\(|\))/g, '\\$1')})`)
generatedTags.push(`(not ${tag.replace(/[()[\]{}^$*+?.|\\]/g, '\\$&')})`)
}
}
})

View file

@ -38,7 +38,8 @@
"typeScriptVersion": "3.8.3",
"dependencies": {
"@wdio/reporter": "workspace:*",
"@wdio/types": "workspace:*"
"@wdio/types": "workspace:*",
"safe-regex2": "^5.0.0"
},
"publishConfig": {
"access": "public"

View file

@ -1,5 +1,8 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import safeRegexTest from 'safe-regex2'
import type { ResultSet } from './types.js'
const DEFAULT_FILENAME = 'wdio-merged.json'
@ -28,7 +31,25 @@ export default async function mergeResults(
}
async function getDataFromFiles (dir: string, filePattern: string | RegExp) {
const fileNames = (await fs.readdir(dir)).filter((file) => file.match(filePattern))
let safePattern: RegExp
if (filePattern instanceof RegExp) {
// For existing RegExp objects, test them for safety
safePattern = safeRegexTest(filePattern) ? filePattern : /\.json$/
} else if (typeof filePattern === 'string') {
try {
// Test the created RegExp for safety
safePattern = safeRegexTest(filePattern) ? new RegExp(filePattern) : /\.json$/
} catch {
// If the pattern syntax is invalid, fall back to a safe default
safePattern = /\.json$/
}
} else {
// If pattern is unsafe or invalid, use a safe default
safePattern = /\.json$/
}
const fileNames = (await fs.readdir(dir)).filter((file) => file.match(safePattern))
const data: unknown[] = []
await Promise.all(fileNames.map(async (fileName) => {

View file

@ -3,7 +3,8 @@
"compilerOptions": {
"baseUrl": ".",
"outDir": "./build",
"rootDir": "./src"
"rootDir": "./src",
"allowSyntheticDefaultImports": true
},
"include": [
"src/**/*",

View file

@ -40,6 +40,7 @@
"chalk": "^5.1.2",
"loglevel": "^1.6.0",
"loglevel-plugin-prefix": "^0.8.4",
"safe-regex2": "^5.0.0",
"strip-ansi": "^7.1.0"
},
"publishConfig": {

View file

@ -1,3 +1,5 @@
import safeRegexTest from 'safe-regex2'
export const SENSITIVE_DATA_REPLACER = '**MASKED**'
const skipError = (aFunction: Function) => {
@ -21,15 +23,20 @@ export const parseMaskingPatterns = (maskingRegexString: string | undefined) =>
return regexStrings?.map((regexStr) => {
const regexParts = regexStr.match(/^\/(.*?)\/([gimsuy]*)$/)
if (!regexParts) {
if (!regexParts && safeRegexTest(regexStr)) {
// When passing only a simple string without `/` or flags, aka `(--key=)([^ ]*)`
return skipError(() => new RegExp(regexStr))
} else if (regexParts?.[1]) {
// Case with flag `/(--key=)([^ ]*)/i` or without flag `/(--key=)([^ ]*)/`
return skipError(() => regexParts[2] ? new RegExp(regexParts[1], regexParts[2]) : new RegExp(regexParts[1]))
}
return undefined
if (regexParts?.[1] && safeRegexTest(regexParts[1])) {
// Case with flag `/(--key=)([^ ]*)/i` or without flag `/(--key=)([^ ]*)/`
return skipError(() => (
regexParts[2]
? new RegExp(regexParts[1], regexParts[2])
: new RegExp(regexParts[1])))
}
return undefined
}).filter((regex) => regex !== undefined)
}

View file

@ -229,4 +229,4 @@ describe('wdio-logger utils', () => {
expect(maskedValue).toEqual('RESULT **MASKED**\n')
})
})
})
})

View file

@ -3,7 +3,8 @@
"compilerOptions": {
"baseUrl": ".",
"outDir": "./build",
"rootDir": "./src"
"rootDir": "./src",
"allowSyntheticDefaultImports": true
},
"include": [
"src/**/*",

View file

@ -119,7 +119,7 @@ export function requireExternalModules (mods: string[], loader = loadModule) {
return Promise.resolve()
}
mod = mod.replace(/.*:/, '')
mod = mod.includes(':') ? mod.substring(mod.lastIndexOf(':') + 1) : mod
if (mod.startsWith('./') && globalThis.process) {
mod = `${globalThis.process.cwd()}/${mod.slice(2)}`

View file

@ -42,7 +42,7 @@
"@wdio/types": "workspace:*",
"decamelize": "^6.0.0",
"deepmerge-ts": "^7.0.3",
"edgedriver": "^6.1.1",
"edgedriver": "^6.1.2",
"geckodriver": "^5.0.0",
"get-port": "^7.0.0",
"import-meta-resolve": "^4.0.0",

View file

@ -66,10 +66,18 @@ export function getBuildIdByChromePath(chromePath?: string) {
return oldest
}
const versionString = cp.execSync(`"${chromePath}" --version --no-sandbox`).toString()
const versionSanitized = versionString.trim().split(' ').find((s) => s.split('.').length === 4)
const result = cp.spawnSync(chromePath, ['--version', '--no-sandbox'], {
encoding: 'utf8',
env: process.env
})
if (result.error) {
throw result.error
}
const versionSanitized = result.stdout.trim().split(' ').find((s) => s.split('.').length === 4)
if (!versionSanitized) {
throw new Error(`Couldn't find valid Chrome version from "${versionString}", please raise an issue in the WebdriverIO project (https://github.com/webdriverio/webdriverio/issues/new/choose)`)
throw new Error(`Couldn't find valid Chrome version from "${result.stdout}", please raise an issue in the WebdriverIO project (https://github.com/webdriverio/webdriverio/issues/new/choose)`)
}
return versionSanitized
}
@ -85,12 +93,20 @@ export async function getBuildIdByFirefoxPath(firefoxPath?: string) {
return contents
.split('\n')
.filter((line) => line.startsWith('Version='))
.map((line) => line.replace('Version=', '').replace(/\r/, ''))
.map((line) => line.replace(/Version=/g, '').replace(/\r/g, ''))
.pop()
}
const versionString = cp.execSync(`"${firefoxPath}" --version`).toString()
return versionString.trim().split(' ').pop()?.trim()
const result = cp.spawnSync(firefoxPath, ['--version'], {
encoding: 'utf8',
env: process.env
})
if (result.error) {
throw result.error
}
return result.stdout.trim().split(' ').pop()?.trim()
}
let lastTimeCalled = Date.now()

View file

@ -88,7 +88,7 @@ export function commandCallStructure (commandName: string, args: unknown[], unfu
* - Returned async functions: `return async function` or `return (async function`
* - Arrow functions: `() =>` or `param =>` or `(param1, param2) =>`
*/
/^\s*(?:(?:async\s+)?function\s*[\w\s]*\(|!function\(|return\s+\(?(?:async\s+)?function|\(?\w*(?:\s*,\s*\w+)*\)?\s*=>)/.test(arg.trim())
/^\s*(?:(?:async\s+)?function(?:\s+\w+)?\s*\(|!function\(|return\s+\(?(?:async\s+)?function|\([^)]*\)\s*=>|\w+\s*=>)/.test(arg.trim())
) {
arg = '<fn>'
} else if (

View file

@ -59,7 +59,8 @@ vi.mock('node:fs/promises', () => ({
vi.mock('node:child_process', () => ({
default: {
execSync: vi.fn()
execSync: vi.fn(),
spawnSync: vi.fn()
}
}))
@ -75,7 +76,14 @@ vi.mock('@puppeteer/browsers', () => ({
describe('driver utils', () => {
beforeEach(() => {
vi.mocked(cp.execSync).mockReturnValue(Buffer.from('Google Chrome 116.0.5845.110 \n'))
vi.mocked(cp.spawnSync).mockReturnValue({
pid: 123,
output: [],
stdout: 'Google Chrome 116.0.5845.110 \n',
stderr: '',
status: 0,
signal: null
})
})
it('should parse params', () => {
@ -86,10 +94,24 @@ describe('driver utils', () => {
it('getBuildIdByChromePath', () => {
expect(getBuildIdByChromePath()).toBe(undefined)
expect(getBuildIdByChromePath('/foo/bar')).toBe('116.0.5845.110')
expect(cp.execSync).toBeCalledWith('"/foo/bar" --version --no-sandbox')
vi.mocked(cp.execSync).mockReturnValue(Buffer.from('Chromium 117.0.5938.88 Fedora Project \n'))
expect(cp.spawnSync).toBeCalledWith('/foo/bar', ['--version', '--no-sandbox'], expect.any(Object))
vi.mocked(cp.spawnSync).mockReturnValue({
pid: 123,
output: [],
stdout: 'Chromium 117.0.5938.88 Fedora Project \n',
stderr: '',
status: 0,
signal: null
})
expect(getBuildIdByChromePath('/foo/bar')).toBe('117.0.5938.88')
vi.mocked(cp.execSync).mockReturnValue(Buffer.from('Chromium 117.0.5938.92 snap \n'))
vi.mocked(cp.spawnSync).mockReturnValue({
pid: 123,
output: [],
stdout: 'Chromium 117.0.5938.92 snap \n',
stderr: '',
status: 0,
signal: null
})
expect(getBuildIdByChromePath('/foo/bar')).toBe('117.0.5938.92')
vi.mocked(os.platform).mockReturnValueOnce('win32')
expect(getBuildIdByChromePath('/foo/bar')).toBe('115.0.5790.110')

View file

@ -376,7 +376,7 @@ export const getSessionError = (err: JSONWPCommandError, params: Partial<Options
'\nIf you use a grid server ' + w3cCapMessage
}
if (err.message.includes('failed serving request POST /wd/hub/session: Unauthorized') && params.hostname?.endsWith('saucelabs.com')) {
if (err.message.includes('failed serving request POST /wd/hub/session: Unauthorized') && (params.hostname === 'saucelabs.com' || params.hostname?.endsWith('.saucelabs.com'))) {
return 'Session request was not authorized because you either did provide a wrong access key or tried to run ' +
'in a region that has not been enabled for your user. If have registered a free trial account it is connected ' +
'to a specific region. Ensure this region is set in your configuration (https://webdriver.io/docs/options.html#region).'

View file

@ -18,7 +18,7 @@ export * from './element/getElement.js'
* don't expose this method as it should not be attached to the `WebdriverIO.Element` prototype.
*/
// export * from './element/getElements.js'
export * from './element/getHTML.js'
export { getHTML } from './element/getHTML.js'
export * from './element/getLocation.js'
export * from './element/getProperty.js'
export * from './element/getSize.js'

View file

@ -201,7 +201,7 @@ function populateHTML (
* @param options command options
* @returns a string with the cleaned up HTML
*/
function sanitizeHTML ($: CheerioAPI | string, options: GetHTMLOptions = {}): string {
export function sanitizeHTML ($: CheerioAPI | string, options: GetHTMLOptions = {}): string {
/**
* delete data-wdio-shadow-id attribute as it contains random ids that
* can cause failures when taking a snapshot of a Shadow DOM element
@ -215,12 +215,31 @@ function sanitizeHTML ($: CheerioAPI | string, options: GetHTMLOptions = {}): st
for (const elemToRemove of (options.excludeElements || [])) {
$(elemToRemove).remove()
}
/**
* Remove HTML comments using Cheerio's built-in functionality
* This is more secure and reliable than regex-based removal
*/
if (options.removeCommentNodes) {
// Find all comment nodes and remove them
$('*').contents().filter(function() {
return this.type === 'comment'
}).remove()
}
}
let returnHTML = isCheerioObject ? $('body').html() as string : $
if (options.removeCommentNodes) {
returnHTML = returnHTML?.replace(/<!--[\s\S]*?-->/g, '')
/**
* Fallback regex-based comment removal for string input
* Only used when we don't have a Cheerio object
*/
if (!isCheerioObject && options.removeCommentNodes && returnHTML) {
// Use a simpler, safer regex that avoids catastrophic backtracking
// This regex matches complete HTML comments only
returnHTML = returnHTML.replace(/<!--[\s\S]*?-->/g, '')
}
return options.prettify
? prettifyFn(returnHTML)
: returnHTML

View file

@ -18,7 +18,7 @@ const XPATH_SELECTOR_REGEXP = [
// optional . or # + class or id
/(?:(\.|#)(-?[_a-zA-Z]+[_a-zA-Z0-9-]*))?/,
// optional [attribute-name="attribute-selector"]
/(?:\[(-?[_a-zA-Z]+[_a-zA-Z0-9-]*)(?:=(?:"|')([a-zA-z0-9\-_. ]+)(?:"|'))?\])?/,
/(?:\[(-?[_a-zA-Z]+[_a-zA-Z0-9-]*)(?:=(?:"|')([a-zA-Z0-9\-_. ]+)(?:"|'))?\])?/,
// optional case insensitive
/(\.)?/,
// *=query or =query

View file

@ -2,6 +2,7 @@ import path from 'node:path'
import { expect, describe, it, afterEach, vi } from 'vitest'
import { remote } from '../../../src/index.js'
import { sanitizeHTML } from '../../../src/commands/element/getHTML.js'
vi.mock('fetch')
vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger')))
@ -14,8 +15,7 @@ describe('getHTML test', () => {
browserName: 'foobar'
}
})
const elem = await browser.$('#foo')
// @ts-expect-error mock feature
const elem = await browser.$('#foo') as any
elem.elementId = {
outerHTML: '<some>outer html</some>',
innerHTML: 'some inner html'
@ -31,6 +31,255 @@ describe('getHTML test', () => {
expect(result).toBe('some inner html')
})
describe('sanitizeHTML', () => {
it('should remove simple HTML comments by default', () => {
const html = '<!-- comment -->'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('')
})
it('should remove comments with content around them', () => {
const html = 'Hello <!-- comment --> World'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('Hello World')
})
it('should preserve comments when removeCommentNodes is false', () => {
const html = 'Hello <!-- comment --> World'
const result = sanitizeHTML(html, { removeCommentNodes: false, prettify: false })
expect(result).toBe('Hello <!-- comment --> World')
})
it('should remove multiple comments', () => {
const html = '<!-- start -->Text<!-- middle -->More<!-- end -->'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('TextMore')
})
it('should handle framework-specific comments (e.g., Lit)', () => {
const html = 'Content<!--?lit$206212805$--><!--?lit$206212805$-->After'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('ContentAfter')
})
it('should handle multiline comments', () => {
const html = `Text<!--
multiline
comment
-->End`
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('TextEnd')
})
it('should handle multiline comments with various content', () => {
const html = `<div>
<!-- This is a comment
that spans multiple lines
and contains various content:
- List items
- Special chars: <>&'"
- Even code: function() { return true; }
-->
<p>Content</p>
</div>`
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).not.toContain('<!--')
expect(result).toContain('<p>Content</p>')
expect(result).not.toContain('List items')
expect(result).not.toContain('Special chars')
})
it('should handle multiple multiline comments', () => {
const html = `Start
<!--
First multiline
comment block
-->
Middle
<!--
Second multiline
comment block
-->
End`
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result.replace(/\s+/g, ' ').trim()).toBe('Start Middle End')
})
it('should handle multiline comments with nested HTML-like content', () => {
const html = `<div>
<!--
<div class="commented-out">
<p>This HTML is commented out</p>
<script>console.log('also commented')</script>
</div>
-->
<p>This is visible</p>
</div>`
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).not.toContain('commented-out')
expect(result).not.toContain('console.log')
expect(result).toContain('<p>This is visible</p>')
})
it('should handle Windows-style line endings in multiline comments', () => {
const html = 'Before<!--\r\nWindows\r\nstyle\r\ncomment\r\n-->After'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('BeforeAfter')
})
it('should handle mixed line endings in multiline comments', () => {
const html = 'Text<!--\nUnix line\r\nWindows line\rOld Mac line-->End'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('TextEnd')
})
it('should handle multiline comments in Cheerio mode', async () => {
const { load } = await import('cheerio')
const html = `
<html>
<body>
<!--
This is a multiline comment
in the document
-->
<div>
Content
<!--
Another multiline
comment inside div
-->
</div>
</body>
</html>
`
const $ = load(html)
const result = sanitizeHTML($, { removeCommentNodes: true, prettify: false })
expect(result).not.toContain('<!--')
expect(result).not.toContain('multiline comment')
expect(result).toContain('<div>')
expect(result).toContain('Content')
})
it('should handle comments with special characters', () => {
const html = 'Before<!-- comment with <tags> & special chars -->After'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('BeforeAfter')
})
it('should handle empty comments', () => {
const html = 'Text<!---->More'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('TextMore')
})
it('should not be vulnerable to ReDoS attacks', () => {
// Create a string that could cause catastrophic backtracking in vulnerable regex
const maliciousInput = '<!--' + 'a'.repeat(10000) + '-->' + '<!--' + 'b'.repeat(10000) + '-->'
const startTime = Date.now()
const result = sanitizeHTML(maliciousInput, { removeCommentNodes: true, prettify: false })
const executionTime = Date.now() - startTime
// Should complete quickly (under 50ms) even with malicious input
expect(executionTime).toBeLessThan(50)
// The simplified regex should handle it gracefully
expect(result).not.toContain('<!--')
expect(result).toBe('')
})
it('should handle nested comment-like structures', () => {
// Note: In valid HTML, comments cannot be nested, but we should handle malformed input gracefully
const html = '<!-- outer <!-- inner --> still in comment --> Text'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe(' still in comment --> Text')
})
it('should handle incomplete comments', () => {
// Comments without closing tags should remain as-is (safer than trying to guess)
const html = 'Text <!--incomplete comment'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('Text <!--incomplete comment')
})
it('should remove comments in complex HTML when using Cheerio', async () => {
// Import cheerio dynamically to test the Cheerio path
const { load } = await import('cheerio')
const complexHtml = `
<div>
<!-- Comment 1 -->
<p>Text <!-- inline comment --> content</p>
<!-- Comment 2 -->
<span>More<!--comment-->text</span>
</div>
`
const $ = load(complexHtml)
const result = sanitizeHTML($, { removeCommentNodes: true, prettify: false })
expect(result).not.toContain('<!--')
expect(result).toContain('<p>Text content</p>')
expect(result).toContain('<span>Moretext</span>')
})
it('should exclude elements when specified', async () => {
const { load } = await import('cheerio')
const html = `
<div>
<style>body { color: red; }</style>
<p>Keep this</p>
<script>console.log('remove')</script>
</div>
`
const $ = load(html)
const result = sanitizeHTML($, {
excludeElements: ['style', 'script'],
prettify: false
})
expect(result).not.toContain('<style>')
expect(result).not.toContain('<script>')
expect(result).toContain('<p>Keep this</p>')
})
it('should remove comments and exclude elements together', async () => {
const { load } = await import('cheerio')
const html = `
<div>
<!-- Remove this comment -->
<style>/* CSS comment */</style>
<p>Keep this<!-- but not this --></p>
</div>
`
const $ = load(html)
const result = sanitizeHTML($, {
removeCommentNodes: true,
excludeElements: ['style'],
prettify: false
})
expect(result).not.toContain('<!--')
expect(result).not.toContain('<style>')
expect(result).toContain('<p>Keep this</p>')
})
it('should handle comments at the beginning and end', () => {
const html = '<!--start-->Middle<!--end-->'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('Middle')
})
it('should handle consecutive comments', () => {
const html = 'Text<!-- comment1 --><!-- comment2 --><!-- comment3 -->End'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('TextEnd')
})
it('should handle HTML entities in comments', () => {
const html = 'Text<!-- comment with &lt;entities&gt; -->End'
const result = sanitizeHTML(html, { removeCommentNodes: true, prettify: false })
expect(result).toBe('TextEnd')
})
})
afterEach(() => {
vi.mocked(fetch).mockClear()
})

View file

@ -1035,6 +1035,9 @@ importers:
'@wdio/types':
specifier: workspace:*
version: link:../wdio-types
safe-regex2:
specifier: ^5.0.0
version: 5.0.0
packages/wdio-junit-reporter:
dependencies:
@ -1158,6 +1161,9 @@ importers:
loglevel-plugin-prefix:
specifier: ^0.8.4
version: 0.8.4
safe-regex2:
specifier: ^5.0.0
version: 5.0.0
strip-ansi:
specifier: ^7.1.0
version: 7.1.0
@ -1431,8 +1437,8 @@ importers:
specifier: ^7.0.3
version: 7.1.5
edgedriver:
specifier: ^6.1.1
version: 6.1.1
specifier: ^6.1.2
version: 6.1.2
geckodriver:
specifier: ^5.0.0
version: 5.0.0
@ -7769,8 +7775,8 @@ packages:
resolution: {integrity: sha512-sB7vSrDnFa4ezWQk9nZ/n0FdpdUuC6R1EOrlU3DL+bovcNFK28rqu2emmAUjujYEJTWIgQGqgVVWUZXMnc8iWg==}
engines: {node: '>=14.0.0'}
edgedriver@6.1.1:
resolution: {integrity: sha512-/dM/PoBf22Xg3yypMWkmRQrBKEnSyNaZ7wHGCT9+qqT14izwtFT+QvdR89rjNkMfXwW+bSFoqOfbcvM+2Cyc7w==}
edgedriver@6.1.2:
resolution: {integrity: sha512-UvFqd/IR81iPyWMcxXbUNi+xKWR7JjfoHjfuwjqsj9UHQKn80RpQmS0jf+U25IPi+gKVPcpOSKm0XkqgGMq4zQ==}
engines: {node: '>=18.0.0'}
hasBin: true
@ -8339,8 +8345,8 @@ packages:
resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==}
hasBin: true
fast-xml-parser@4.5.3:
resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==}
fast-xml-parser@5.2.5:
resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==}
hasBin: true
fastq@1.19.1:
@ -12243,6 +12249,10 @@ packages:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
ret@0.5.0:
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
engines: {node: '>=10'}
retry@0.12.0:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'}
@ -12343,6 +12353,9 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
safe-regex2@5.0.0:
resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
@ -12878,6 +12891,9 @@ packages:
strnum@1.1.2:
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
strnum@2.1.1:
resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==}
style-to-js@1.1.17:
resolution: {integrity: sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==}
@ -15863,7 +15879,7 @@ snapshots:
'@cucumber/ci-environment': 10.0.1
'@cucumber/cucumber-expressions': 17.1.0
'@cucumber/gherkin': 28.0.0
'@cucumber/gherkin-streams': 5.0.1(@cucumber/gherkin@28.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@26.0.1))(@cucumber/messages@24.1.0)
'@cucumber/gherkin-streams': 5.0.1(@cucumber/gherkin@28.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@24.1.0))(@cucumber/messages@24.1.0)
'@cucumber/gherkin-utils': 9.0.0
'@cucumber/html-formatter': 21.6.0(@cucumber/messages@24.1.0)
'@cucumber/message-streams': 4.0.1(@cucumber/messages@24.1.0)
@ -15903,7 +15919,7 @@ snapshots:
yaml: 2.8.0
yup: 1.2.0
'@cucumber/gherkin-streams@5.0.1(@cucumber/gherkin@28.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@26.0.1))(@cucumber/messages@24.1.0)':
'@cucumber/gherkin-streams@5.0.1(@cucumber/gherkin@28.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@24.1.0))(@cucumber/messages@24.1.0)':
dependencies:
'@cucumber/gherkin': 28.0.0
'@cucumber/message-streams': 4.0.1(@cucumber/messages@24.1.0)
@ -19942,7 +19958,7 @@ snapshots:
'@wdio/types': 9.16.2
decamelize: 6.0.0
deepmerge-ts: 7.1.5
edgedriver: 6.1.1
edgedriver: 6.1.2
geckodriver: 5.0.0
get-port: 7.1.0
import-meta-resolve: 4.1.0
@ -19961,7 +19977,7 @@ snapshots:
'@wdio/types': 9.16.2
decamelize: 6.0.0
deepmerge-ts: 7.1.5
edgedriver: 6.1.1
edgedriver: 6.1.2
geckodriver: 5.0.0
get-port: 7.1.0
import-meta-resolve: 4.1.0
@ -21817,13 +21833,13 @@ snapshots:
'@types/which': 2.0.2
which: 2.0.2
edgedriver@6.1.1:
edgedriver@6.1.2:
dependencies:
'@wdio/logger': 9.16.2
'@zip.js/zip.js': 2.7.62
decamelize: 6.0.0
edge-paths: 3.0.5
fast-xml-parser: 4.5.3
fast-xml-parser: 5.2.5
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
node-fetch: 3.3.2
@ -22646,9 +22662,9 @@ snapshots:
dependencies:
strnum: 1.1.2
fast-xml-parser@4.5.3:
fast-xml-parser@5.2.5:
dependencies:
strnum: 1.1.2
strnum: 2.1.1
fastq@1.19.1:
dependencies:
@ -27455,6 +27471,8 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
ret@0.5.0: {}
retry@0.12.0: {}
retry@0.13.1: {}
@ -27573,6 +27591,10 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
safe-regex2@5.0.0:
dependencies:
ret: 0.5.0
safe-stable-stringify@2.5.0: {}
safe-stringify@1.2.0: {}
@ -28208,6 +28230,8 @@ snapshots:
strnum@1.1.2: {}
strnum@2.1.1: {}
style-to-js@1.1.17:
dependencies:
style-to-object: 1.0.9

View file

@ -9,6 +9,37 @@ import { Readable } from 'node:stream'
import unzipper, { type Entry } from 'unzipper'
import { Octokit } from '@octokit/rest'
/**
* Validates and sanitizes a file path from a zip entry to prevent directory traversal attacks
* @param entryPath - The path from the zip entry
* @param targetDir - The target directory for extraction
* @returns The safe path for extraction, or null if the path is invalid
*/
function validateZipEntryPath(entryPath: string, targetDir: string): string | void {
// Normalize the entry path and remove any leading slashes
const normalizedPath = path.normalize(entryPath).replace(/^[/\\]+/, '')
// Check for directory traversal attempts
if (normalizedPath.includes('..') || path.isAbsolute(normalizedPath)) {
console.warn(`Skipping potentially dangerous path: ${entryPath}`)
return
}
// Construct the full target path
const fullPath = path.join(targetDir, normalizedPath)
// Ensure the resolved path is still within the target directory
const resolvedPath = path.resolve(fullPath)
const resolvedTargetDir = path.resolve(targetDir)
if (!resolvedPath.startsWith(resolvedTargetDir + path.sep) && resolvedPath !== resolvedTargetDir) {
console.warn(`Path traversal attempt detected: ${entryPath}`)
return
}
return fullPath
}
const MAIN_BRANCH = 'main'
export default async function downloadSpec (page = 1) {
@ -75,7 +106,14 @@ export default async function downloadSpec (page = 1) {
]
stream.on('entry', async (entry: Entry) => {
const unzippedFilePath = path.join(targetDir, entry.path)
// Validate the entry path to prevent directory traversal attacks
const unzippedFilePath = validateZipEntryPath(entry.path, targetDir)
if (!unzippedFilePath) {
// Skip entries with invalid paths
entry.autodrain()
return
}
if (entry.type === 'Directory') {
return
}