diff --git a/NOTICE.txt b/NOTICE.txt index 2b73f677..22a4cb8b 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -831,6 +831,36 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------ +cross-spawn@7.0.6 +License: MIT +Repository: https://github.com/moxystudio/node-cross-spawn +Publisher: André Cruz +------------------------------------------------------------------------ + +The MIT License (MIT) + +Copyright (c) 2018 Made With MOXY Lda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + ------------------------------------------------------------------------ csv-parse@7.0.1 License: MIT @@ -1144,6 +1174,30 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------ +isexe@2.0.0 +License: ISC +Repository: https://github.com/isaacs/isexe +Publisher: Isaac Z. Schlueter (http://blog.izs.me/) +------------------------------------------------------------------------ + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ------------------------------------------------------------------------ json-schema-traverse@0.4.1 License: MIT @@ -1463,6 +1517,24 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------ +path-key@3.1.1 +License: MIT +Repository: https://github.com/sindresorhus/path-key +Publisher: Sindre Sorhus (sindresorhus.com) +------------------------------------------------------------------------ + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ------------------------------------------------------------------------ punycode@2.3.1 License: MIT @@ -1553,6 +1625,42 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------ +shebang-command@2.0.0 +License: MIT +Repository: https://github.com/kevva/shebang-command +Publisher: Kevin Mårtensson (github.com/kevva) +------------------------------------------------------------------------ + +MIT License + +Copyright (c) Kevin Mårtensson (github.com/kevva) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +------------------------------------------------------------------------ +shebang-regex@3.0.0 +License: MIT +Repository: https://github.com/sindresorhus/shebang-regex +Publisher: Sindre Sorhus (sindresorhus.com) +------------------------------------------------------------------------ + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ------------------------------------------------------------------------ skin-tone@2.0.0 License: MIT @@ -1754,6 +1862,30 @@ THIS SOFTWARE IS PROVIDED BY GARY COURT "AS IS" AND ANY EXPRESS OR IMPLIED WARRA The views and conclusions contained in the software and documentation are those of the authors and should not be interpreted as representing official policies, either expressed or implied, of Gary Court. +------------------------------------------------------------------------ +which@2.0.2 +License: ISC +Repository: https://github.com/isaacs/node-which +Publisher: Isaac Z. Schlueter (http://blog.izs.me) +------------------------------------------------------------------------ + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ------------------------------------------------------------------------ wrap-ansi@7.0.0 License: MIT diff --git a/package-lock.json b/package-lock.json index 140e4e93..1fd5919d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "ajv": "^6.14.0", "cli-table3": "^0.6.5", "commander": "^15.0.0", + "cross-spawn": "^7.0.6", "csv-parse": "^7.0.0", "marked": "^14.1.4", "marked-terminal": "^7.3.0", @@ -31,6 +32,7 @@ }, "devDependencies": { "@eslint/js": "10.0.1", + "@types/cross-spawn": "^6.0.6", "@types/marked-terminal": "6.1.1", "@types/node": "25.9.4", "@yao-pkg/pkg": "6.22.0", @@ -1840,6 +1842,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "dev": true, @@ -3033,7 +3045,8 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "dev": true, + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4349,7 +4362,6 @@ }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, "license": "ISC" }, "node_modules/js-tokens": { @@ -5831,7 +5843,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6473,7 +6484,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6484,7 +6494,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7373,7 +7382,6 @@ }, "node_modules/which": { "version": "2.0.2", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 35d88669..cc1da7d2 100644 --- a/package.json +++ b/package.json @@ -64,17 +64,19 @@ "ajv": "^6.14.0", "cli-table3": "^0.6.5", "commander": "^15.0.0", + "cross-spawn": "^7.0.6", "csv-parse": "^7.0.0", "marked": "^14.1.4", "marked-terminal": "^7.3.0", "yaml": "^2.8.3" }, "devDependencies": { - "@yao-pkg/pkg": "6.22.0", "@eslint/js": "10.0.1", - "esbuild": "0.28.1", + "@types/cross-spawn": "^6.0.6", "@types/marked-terminal": "6.1.1", "@types/node": "25.9.4", + "@yao-pkg/pkg": "6.22.0", + "esbuild": "0.28.1", "eslint": "10.6.0", "license-checker": "25.0.1", "mega-linter-runner": "9.5.0", diff --git a/src/extension/installer.ts b/src/extension/installer.ts index 6c622030..253e3aee 100644 --- a/src/extension/installer.ts +++ b/src/extension/installer.ts @@ -18,18 +18,30 @@ * If the repo/package is not prefixed with `elastic-`, the full name is used. * * Security: - * All child processes are spawned with shell: false and an explicit args array. + * All child processes are spawned with an explicit args array, never a shell- + * interpreted string. On Windows, npm is a `.cmd` shim that Node's own + * spawnSync cannot invoke without shell:true, so `run()` uses `cross-spawn`, + * which resolves `.cmd`/`.bat` shims and escapes arguments itself instead of + * relying on unsafe shell string concatenation. * The derived entrypoint is validated to sit within the install directory. */ import { access, chmod, constants, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { realpath as realpathCb } from 'node:fs' import { homedir } from 'node:os' import { join, isAbsolute, resolve, relative } from 'node:path' -import { spawnSync } from 'node:child_process' +import { sync as spawnSync } from 'cross-spawn' +import { promisify } from 'node:util' import { readExtensions, upsertExtension, findExtension, removeExtension as removeFromStore } from './store.ts' import type { InstalledExtension } from './store.ts' import { buildExtensionEnvironment } from './env.ts' +// fs.promises.realpath does not expand Windows 8.3 short names (e.g. `RUNNER~1`), +// so two paths that are the same directory can resolve to different strings and +// fail a containment check that should pass. realpath.native calls +// GetFinalPathNameByHandle on Windows, which does expand them. +const realpath = promisify(realpathCb.native) + // --------------------------------------------------------------------------- // Test seams // --------------------------------------------------------------------------- @@ -118,7 +130,9 @@ function parseSource (source: string): ParsedSource { } /** - * Runs a command with an explicit args array (never shell: true). + * Runs a command with an explicit args array. Uses `cross-spawn` so `.cmd`/`.bat` + * shims (e.g. npm on Windows) resolve correctly without falling back to an + * unescaped shell string. * Throws a descriptive error if the process exits non-zero or fails to start. */ function run (cmd: string, args: string[], cwd: string, env?: Record): void { @@ -131,7 +145,6 @@ function run (cmd: string, args: string[], cwd: string, env?: Record { + let realEntrypoint: string + let realInstallDir: string + try { + [realEntrypoint, realInstallDir] = await Promise.all([ + realpath(entrypoint), + realpath(installDir), + ]) + } catch (err) { + // Both callers create installDir and write/verify the entrypoint before reaching + // this check, so ENOENT here means a caller invariant broke, not a normal outcome. + // Surface a clear message instead of letting a raw ENOENT bubble up. + const path = (err as NodeJS.ErrnoException).path ?? entrypoint + throw new Error(`Cannot verify entrypoint containment: "${path}" does not exist.`, { cause: err }) + } + // Use path.relative rather than a hardcoded "/" separator so this works on + // Windows too, where realpath returns backslash-separated paths. A target + // is contained when the relative path doesn't escape upward (doesn't start + // with "..") and isn't absolute (which relative() returns when the paths + // are on different drives on Windows, i.e. no relative path exists). + const rel = relative(realInstallDir, realEntrypoint) const within = rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) if (!within) { throw new Error( - `Resolved entrypoint "${entrypoint}" is outside the install directory "${installDir}". ` + + `Entrypoint "${entrypoint}" resolves to "${realEntrypoint}", which is outside the install directory "${realInstallDir}". ` + 'Refusing to register this extension.' ) } @@ -266,7 +310,7 @@ export async function installExtension (source: string): Promise<{ entry: Instal entrypoint = found } - assertWithinInstallDir(resolve(entrypoint), resolve(installDir)) + await assertWithinInstallDir(resolve(entrypoint), resolve(installDir)) const entry: InstalledExtension = { name: parsed.name, @@ -334,6 +378,8 @@ export async function createLocalExtension (name: string, targetPath?: string): entrypoint = defaultEntrypoint } + await assertWithinInstallDir(resolve(entrypoint), resolve(installDir)) + const entry: InstalledExtension = { name, source: `local:${installDir}`, @@ -382,11 +428,15 @@ export async function upgradeExtension (name: string): Promise { it('rejects names with path traversal characters', async () => { await assert.rejects(createLocalExtension('../escape'), /invalid characters/) }) + + it('rejects a --path entrypoint that is a symlink escaping the install directory (#500)', async () => { + const outsideDir = await mkdtemp(join(tmpdir(), 'elastic-outside-')) + try { + const payload = join(outsideDir, 'payload.sh') + await writeFile(payload, '#!/bin/sh\necho PAYLOAD RAN FROM OUTSIDE\n', { mode: 0o755 }) + + const targetDir = join(tmpDir, 'symlink-escape-ext') + await mkdir(targetDir, { recursive: true }) + await symlink(payload, join(targetDir, 'elastic-symlinktest')) + + await assert.rejects( + createLocalExtension('symlinktest', targetDir), + /outside the install directory/ + ) + + // Refusing to register also means the store stays empty. + assert.deepEqual(await readExtensions(), []) + } finally { + await rm(outsideDir, { recursive: true, force: true }) + } + }) + + it('accepts a --path entrypoint that is a real (non-symlink) file inside the install directory', async () => { + const targetDir = join(tmpDir, 'real-entrypoint-ext') + await mkdir(targetDir, { recursive: true }) + const entrypointPath = join(targetDir, 'elastic-realtest') + await writeFile(entrypointPath, '#!/bin/sh\necho hi\n', { mode: 0o755 }) + await chmod(entrypointPath, 0o755) + + const { entry } = await createLocalExtension('realtest', targetDir) + assert.equal(entry.entrypoint, entrypointPath) + const extensions = await readExtensions() + assert.equal(extensions.length, 1) + assert.equal(extensions[0]!.entrypoint, entrypointPath) + }) }) describe('upgradeExtension', () => { it('throws when the extension is not installed', async () => { await assert.rejects(upgradeExtension('nonexistent'), /not installed/) }) + + it('rejects a post-pull entrypoint that is a symlink escaping the install directory (#500)', async () => { + const remoteDir = await mkdtemp(join(tmpdir(), 'elastic-remote-')) + const outsideDir = await mkdtemp(join(tmpdir(), 'elastic-outside-')) + const extPath = join(extDir, 'elastic-symupgrade') + try { + // Bootstrap a local git remote so git pull --ff-only succeeds (already up to date). + const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 't@t.com', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 't@t.com' } + spawnSync('git', ['init', remoteDir], { encoding: 'utf-8' }) + spawnSync('git', ['-C', remoteDir, 'commit', '--allow-empty', '-m', 'init'], { encoding: 'utf-8', env: gitEnv }) + spawnSync('git', ['clone', remoteDir, extPath], { encoding: 'utf-8' }) + + // Place a symlink whose target is outside the install dir — simulates a + // malicious commit pulled in by git pull. + const payload = join(outsideDir, 'elastic-symupgrade') + await writeFile(payload, '#!/bin/sh\necho PAYLOAD\n', { mode: 0o755 }) + await symlink(payload, join(extPath, 'elastic-symupgrade')) + + const entry: InstalledExtension = { + name: 'symupgrade', + source: 'github:elastic/elastic-symupgrade', + path: extPath, + entrypoint: join(extPath, 'elastic-symupgrade'), + } + await writeExtensions([entry]) + + await assert.rejects(upgradeExtension('symupgrade'), /outside the install directory/) + } finally { + await rm(remoteDir, { recursive: true, force: true }) + await rm(outsideDir, { recursive: true, force: true }) + } + }) + + it('rejects a stored entrypoint that is a symlink escaping the install directory after npm update (#500)', async () => { + const outsideDir = await mkdtemp(join(tmpdir(), 'elastic-outside-')) + const extPath = join(extDir, 'elastic-npmupgrade') + try { + await mkdir(extPath, { recursive: true }) + await writeFile(join(extPath, 'package.json'), JSON.stringify({ name: 'elastic-npmupgrade', version: '1.0.0' }), 'utf-8') + + // Simulates a symlink left behind under node_modules/.bin by npm update. + const payload = join(outsideDir, 'payload.sh') + await writeFile(payload, '#!/bin/sh\necho PAYLOAD\n', { mode: 0o755 }) + await symlink(payload, join(extPath, 'elastic-npmupgrade')) + + const entry: InstalledExtension = { + name: 'npmupgrade', + source: 'npm:elastic-npmupgrade', + path: extPath, + entrypoint: join(extPath, 'elastic-npmupgrade'), + } + await writeExtensions([entry]) + + await assert.rejects(upgradeExtension('npmupgrade'), /outside the install directory/) + } finally { + await rm(outsideDir, { recursive: true, force: true }) + } + }) }) describe('upgradeAllExtensions', () => {