fix: patch decompress@4.2.1 for CVE-2026-53486 path traversal

Upstream decompress is unmaintained (last release 4.2.1). The fix
is in the maintained fork @xhmikosr/decompress, but its API is
incompatible with bin-build@3.0.0 (the dep chain tauri > optipng-bin
> bin-build > decompress).

Apply pnpm's local-patch mechanism to backport the fix to 4.2.1:
- Replace indexOf prefix check with path.relative
- Strip setuid/setgid/sticky bits from extracted file modes
- Resolve and validate symlink/hardlink targets before creation

Advisory: https://github.com/advisories/GHSA-mp2f-45pm-3cg9
This commit is contained in:
John Smith 2026-07-24 12:16:55 -04:00
parent f646866794
commit 2be06d7a34
3 changed files with 88 additions and 4 deletions

View file

@ -0,0 +1,78 @@
diff --git a/index.js b/index.js
index 6aa67ca9cda4bac1262915ca6e47e97b224d336e..17fcc21d96543efa7d1bdd4266e4c1a2b40ef635 100644
--- a/index.js
+++ b/index.js
@@ -26,7 +26,8 @@ const safeMakeDir = (dir, realOutputPath) => {
return safeMakeDir(parent, realOutputPath);
})
.then(realParentPath => {
- if (realParentPath.indexOf(realOutputPath) !== 0) {
+ const rel = path.relative(realOutputPath, realParentPath);
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw (new Error('Refusing to create a directory outside the output path.'));
}
@@ -51,6 +52,35 @@ const preventWritingThroughSymlink = (destination, realOutputPath) => {
});
};
+// Security: strip setuid, setgid, and sticky bits from extracted file modes.
+// The original code used `x.mode & ~process.umask()` which only clears bits
+// already masked by the process umask, leaving setuid/setgid/sticky intact.
+// A crafted archive could create a setuid binary on extraction, which is
+// especially dangerous when extraction runs as root (CI, containers).
+const safeMode = (mode) => (mode & ~process.umask()) & 0o777 & ~0o7000;
+
+// Security: resolve a symlink/hardlink target to its realpath and verify
+// it stays inside realOutputPath. Prevents a crafted archive from creating
+// a link to /etc/passwd (hardlink) or a symlink to /tmp (symlink) inside
+// the output directory.
+const resolveAndCheckLinkTarget = (linkname, realOutputPath) => {
+ const resolved = path.resolve(realOutputPath, linkname);
+ return fsP.realpath(resolved)
+ .catch(_ => {
+ // Target doesn't exist yet — for hardlinks, check the parent dir;
+ // for symlinks, the target is checked at write time below.
+ return null;
+ })
+ .then(realTarget => {
+ if (realTarget) {
+ const rel = path.relative(realOutputPath, realTarget);
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
+ throw new Error('Refusing to create a link to outside output directory: ' + realTarget);
+ }
+ }
+ });
+};
+
const extractFile = (input, output, opts) => runPlugins(input, opts).then(files => {
if (opts.strip > 0) {
files = files
@@ -75,7 +105,7 @@ const extractFile = (input, output, opts) => runPlugins(input, opts).then(files
return Promise.all(files.map(x => {
const dest = path.join(output, x.path);
- const mode = x.mode & ~process.umask();
+ const mode = safeMode(x.mode);
const now = new Date();
if (x.type === 'directory') {
@@ -103,11 +133,17 @@ const extractFile = (input, output, opts) => runPlugins(input, opts).then(files
.then(realOutputPath => {
return fsP.realpath(path.dirname(dest))
.then(realDestinationDir => {
- if (realDestinationDir.indexOf(realOutputPath) !== 0) {
+ const rel = path.relative(realOutputPath, realDestinationDir);
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw (new Error('Refusing to write outside output directory: ' + realDestinationDir));
}
});
})
+ .then(realOutputPath => {
+ if (x.type === 'link' || x.type === 'symlink') {
+ return resolveAndCheckLinkTarget(x.linkname, realOutputPath);
+ }
+ })
.then(() => {
if (x.type === 'link') {
return fsP.link(x.linkname, dest);

11
pnpm-lock.yaml generated
View file

@ -53,6 +53,9 @@ overrides:
tough-cookie@<4.1.3: '>=4.1.3'
trim-newlines@<3.0.1: '>=3.0.1'
patchedDependencies:
decompress@4.2.1: 45158ed496346a01b560e676c853793c22191f0f6056b5633694f408fcb7372b
importers:
.:
@ -15858,7 +15861,7 @@ snapshots:
bin-build@3.0.0:
dependencies:
decompress: 4.2.1
decompress: 4.2.1(patch_hash=45158ed496346a01b560e676c853793c22191f0f6056b5633694f408fcb7372b)
download: 6.2.5
execa: 0.7.0
p-map-series: 1.0.0
@ -16643,7 +16646,7 @@ snapshots:
pify: 2.3.0
yauzl: 2.10.0
decompress@4.2.1:
decompress@4.2.1(patch_hash=45158ed496346a01b560e676c853793c22191f0f6056b5633694f408fcb7372b):
dependencies:
decompress-tar: 4.1.1
decompress-tarbz2: 4.1.1
@ -16795,7 +16798,7 @@ snapshots:
dependencies:
caw: 2.0.1
content-disposition: 0.5.4
decompress: 4.2.1
decompress: 4.2.1(patch_hash=45158ed496346a01b560e676c853793c22191f0f6056b5633694f408fcb7372b)
ext-name: 5.0.0
file-type: 5.2.0
filenamify: 2.1.0
@ -16810,7 +16813,7 @@ snapshots:
archive-type: 4.0.0
caw: 2.0.1
content-disposition: 0.5.4
decompress: 4.2.1
decompress: 4.2.1(patch_hash=45158ed496346a01b560e676c853793c22191f0f6056b5633694f408fcb7372b)
ext-name: 5.0.0
file-type: 8.1.0
filenamify: 2.1.0

View file

@ -87,4 +87,7 @@ overrides:
tough-cookie@<4.1.3: ">=4.1.3"
trim-newlines@<3.0.1: ">=3.0.1"
patchedDependencies:
decompress@4.2.1: patches/decompress@4.2.1.patch
shamefullyHoist: true