mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-29 11:11:43 -04:00
fix(patching/apply-patch): reject patch paths that escape the patched directory (#11952)
* fix(patching/apply-patch): reject patch paths that escape the patched directory A malicious .patch file with `diff --git a/../../X` headers could otherwise write, delete, or rename files outside the patched package as the user running `pnpm install`. * refactor(patching/apply-patch): narrow caught errors via util.types.isNativeError Drops the `any`-typed catch + eslint-disable in favor of the cross-realm-safe narrowing pattern documented in CLAUDE.md. * refactor(patching/apply-patch): replace error helper with PatchPathEscapesError class * chore(patching/apply-patch): reword comment to satisfy cspell
This commit is contained in:
6
.changeset/patch-path-traversal.md
Normal file
6
.changeset/patch-path-traversal.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@pnpm/patching.apply-patch": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Reject patch files whose `diff --git` headers reference paths outside the patched package directory. Previously a malicious `.patch` file added via a pull request could write, delete, or rename arbitrary files reachable by the user running `pnpm install`.
|
||||
10
__typings__/local.d.ts
vendored
10
__typings__/local.d.ts
vendored
@@ -118,6 +118,16 @@ declare module '@pnpm/patch-package/dist/applyPatches' {
|
||||
export function applyPatch (opts: any): boolean
|
||||
}
|
||||
|
||||
declare module '@pnpm/patch-package/dist/patch/parse' {
|
||||
export interface PatchFilePart {
|
||||
type: 'file deletion' | 'file creation' | 'patch' | 'mode change' | 'rename'
|
||||
path?: string
|
||||
fromPath?: string
|
||||
toPath?: string
|
||||
}
|
||||
export function parsePatchFile (file: string): PatchFilePart[]
|
||||
}
|
||||
|
||||
declare module 'ramda/src/map' {
|
||||
function map <K extends string | number | symbol, V, U> (fn: (x: V) => U, obj: Record<K, V>): Record<K, U>
|
||||
export = map
|
||||
|
||||
7
patching/apply-patch/__fixtures__/path-traversal.patch
Normal file
7
patching/apply-patch/__fixtures__/path-traversal.patch
Normal file
@@ -0,0 +1,7 @@
|
||||
diff --git a/../../../../../../../../../../tmp/pnpm-patch-traversal-pwned b/../../../../../../../../../../tmp/pnpm-patch-traversal-pwned
|
||||
new file mode 100644
|
||||
index 0000000..3b18e51
|
||||
--- /dev/null
|
||||
+++ b/../../../../../../../../../../tmp/pnpm-patch-traversal-pwned
|
||||
@@ -0,0 +1 @@
|
||||
+pwned
|
||||
@@ -1,6 +1,11 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import util from 'node:util'
|
||||
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { applyPatch } from '@pnpm/patch-package/dist/applyPatches'
|
||||
import { globalWarn } from '@pnpm/logger'
|
||||
import { applyPatch } from '@pnpm/patch-package/dist/applyPatches'
|
||||
import { parsePatchFile } from '@pnpm/patch-package/dist/patch/parse'
|
||||
|
||||
export interface ApplyPatchToDirOpts {
|
||||
allowFailure?: boolean
|
||||
@@ -11,6 +16,7 @@ export interface ApplyPatchToDirOpts {
|
||||
export function applyPatchToDir (opts: ApplyPatchToDirOpts): boolean {
|
||||
// Ideally, we would just run "patch" or "git apply".
|
||||
// However, "patch" is not available on Windows and "git apply" is hard to execute on a subdirectory of an existing repository
|
||||
assertPatchPathsStayInside(opts)
|
||||
const cwd = process.cwd()
|
||||
process.chdir(opts.patchedDir)
|
||||
let success = false
|
||||
@@ -18,11 +24,12 @@ export function applyPatchToDir (opts: ApplyPatchToDirOpts): boolean {
|
||||
success = applyPatch({
|
||||
patchFilePath: opts.patchFilePath,
|
||||
})
|
||||
} catch (err: any) { // eslint-disable-line
|
||||
if (err.code === 'ENOENT') {
|
||||
} catch (err) {
|
||||
if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {
|
||||
throw new PnpmError('PATCH_NOT_FOUND', `Patch file not found: ${opts.patchFilePath}`)
|
||||
}
|
||||
throw new PnpmError('INVALID_PATCH', `Applying patch "${opts.patchFilePath}" failed: ${err.message as string}`)
|
||||
const message = util.types.isNativeError(err) ? err.message : String(err)
|
||||
throw new PnpmError('INVALID_PATCH', `Applying patch "${opts.patchFilePath}" failed: ${message}`)
|
||||
} finally {
|
||||
process.chdir(cwd)
|
||||
}
|
||||
@@ -36,3 +43,54 @@ export function applyPatchToDir (opts: ApplyPatchToDirOpts): boolean {
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
// A patch file is attacker-controlled data: `diff --git a/../../X b/../../X` headers
|
||||
// would otherwise let the applier traverse out of the package directory and write,
|
||||
// delete, or rename files anywhere the install user can.
|
||||
function assertPatchPathsStayInside (opts: ApplyPatchToDirOpts): void {
|
||||
let patchContent: string
|
||||
try {
|
||||
patchContent = fs.readFileSync(opts.patchFilePath, 'utf8')
|
||||
} catch (err) {
|
||||
if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {
|
||||
throw new PnpmError('PATCH_NOT_FOUND', `Patch file not found: ${opts.patchFilePath}`)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
let effects
|
||||
try {
|
||||
effects = parsePatchFile(patchContent)
|
||||
} catch {
|
||||
// Defer parse-error reporting to applyPatch so its existing
|
||||
// ERR_PNPM_INVALID_PATCH path produces the message and exit behavior.
|
||||
return
|
||||
}
|
||||
const root = path.resolve(opts.patchedDir)
|
||||
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep
|
||||
for (const effect of effects) {
|
||||
const candidates: Array<string | undefined> = effect.type === 'rename'
|
||||
? [effect.fromPath, effect.toPath]
|
||||
: [effect.path]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
if (
|
||||
path.isAbsolute(candidate) ||
|
||||
candidate.split(/[/\\]/).includes('..')
|
||||
) {
|
||||
throw new PatchPathEscapesError(opts, candidate)
|
||||
}
|
||||
const resolved = path.resolve(root, candidate)
|
||||
if (resolved !== root && !resolved.startsWith(rootWithSep)) {
|
||||
throw new PatchPathEscapesError(opts, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PatchPathEscapesError extends PnpmError {
|
||||
constructor (opts: ApplyPatchToDirOpts, badPath: string) {
|
||||
super('PATCH_FAILED',
|
||||
`Could not apply patch ${opts.patchFilePath} to ${opts.patchedDir}: ` +
|
||||
`patch path escapes target dir: ${badPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,3 +126,22 @@ describe('applyPatchToDir() with allowFailure', () => {
|
||||
}).toThrow('Patch file not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPatchToDir() path traversal', () => {
|
||||
it.each([false, true])('should reject paths that escape the patched directory (allowFailure=%s)', (allowFailure) => {
|
||||
const patchFilePath = f.find('path-traversal.patch')
|
||||
const patchedDir = tempDir()
|
||||
const sentinel = path.join('/tmp', 'pnpm-patch-traversal-pwned')
|
||||
try {
|
||||
fs.unlinkSync(sentinel)
|
||||
} catch {}
|
||||
expect(() => {
|
||||
applyPatchToDir({
|
||||
allowFailure,
|
||||
patchFilePath,
|
||||
patchedDir,
|
||||
})
|
||||
}).toThrow(/patch path escapes target dir/)
|
||||
expect(fs.existsSync(sentinel)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user