| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 1c1b725 commit 18def23
4 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -21,6 +21,63 @@ function isPathWithinParent(childPath, parentPath) { | |||
| 21 | 21 | normalizedChild.startsWith(parentWithSep); | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | + /** | ||
| 25 | + * Check if the real filesystem path stays within parentDir, | ||
| 26 | + * accounting for pre-existing symlinks on disk. | ||
| 27 | + * Walks each path segment from parentDir to targetPath using lstat. | ||
| 28 | + * If any segment is a symlink, resolves it and verifies it stays within parentDir. | ||
| 29 | + * @param {string} targetPath - Absolute path to validate | ||
| 30 | + * @param {string} parentDir - Absolute path of the extraction root | ||
| 31 | + * @param {string} realParentDir - Pre-resolved real path of parentDir (handles OS-level symlinks like /var -> /private/var on macOS) | ||
| 32 | + * @param {function} callback - callback(err, safe) | ||
| 33 | + */ | ||
| 34 | + function isRealPathSafe(targetPath, parentDir, realParentDir, callback) { | ||
| 35 | + function isWithinParent(p) { | ||
| 36 | + return isPathWithinParent(p, parentDir) || isPathWithinParent(p, realParentDir); | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + var relative = path.relative(parentDir, targetPath); | ||
| 40 | + var segments = relative.split(path.sep); | ||
| 41 | + var i = 0; | ||
| 42 | + var current = parentDir; | ||
| 43 | + | ||
| 44 | + function checkNext() { | ||
| 45 | + if (i >= segments.length) return callback(null, true); | ||
| 46 | + var segment = segments[i++]; | ||
| 47 | + if (!segment || segment === '.') return checkNext(); | ||
| 48 | + | ||
| 49 | + current = path.join(current, segment); | ||
| 50 | + fs.lstat(current, function(err, stat) { | ||
| 51 | + if (err) { | ||
| 52 | + if (err.code === 'ENOENT') return callback(null, true); // doesn't exist yet, safe | ||
| 53 | + // Fail closed: unexpected filesystem errors are unsafe | ||
| 54 | + return callback(null, false); | ||
| 55 | + } | ||
| 56 | + if (!stat.isSymbolicLink()) return checkNext(); | ||
| 57 | + | ||
| 58 | + fs.realpath(current, function(err, resolved) { | ||
| 59 | + if (err) { | ||
| 60 | + if (err.code === 'ENOENT') { | ||
| 61 | + // Dangling symlink - check textual target | ||
| 62 | + return fs.readlink(current, function(err, linkTarget) { | ||
| 63 | + if (err) return callback(null, false); | ||
| 64 | + var absTarget = path.resolve(path.dirname(current), linkTarget); | ||
| 65 | + callback(null, isWithinParent(absTarget)); | ||
| 66 | + }); | ||
| 67 | + } | ||
| 68 | + // Fail closed: unexpected errors during symlink resolution are unsafe | ||
| 69 | + return callback(null, false); | ||
| 70 | + } | ||
| 71 | + if (!isWithinParent(resolved)) return callback(null, false); | ||
| 72 | + current = resolved; | ||
| 73 | + checkNext(); | ||
| 74 | + }); | ||
| 75 | + }); | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + checkNext(); | ||
| 79 | + } | ||
| 80 | + | ||
| 24 | 81 | // file/fileBuffer/stream | |
| 25 | 82 | exports.sourceType = source => { | |
| 26 | 83 | if (!source) return undefined; | |
@@ -112,74 +169,99 @@ exports.makeUncompressFn = StreamClass => { | |||
| 112 | 169 | // Resolve destDir to absolute path for security validation | |
| 113 | 170 | const resolvedDestDir = path.resolve(destDir); | |
| 114 | 171 | ||
| 115 | - let entryCount = 0; | ||
| 116 | - let successCount = 0; | ||
| 117 | - let isFinish = false; | ||
| 118 | - function done() { | ||
| 119 | - // resolve when both stream finish and file write finish | ||
| 120 | - if (isFinish && entryCount === successCount) resolve(); | ||
| 121 | - } | ||
| 122 | - | ||
| 123 | - new StreamClass(opts) | ||
| 124 | - .on('finish', () => { | ||
| 125 | - isFinish = true; | ||
| 126 | - done(); | ||
| 127 | - }) | ||
| 128 | - .on('error', reject) | ||
| 129 | - .on('entry', (header, stream, next) => { | ||
| 130 | - stream.on('end', next); | ||
| 131 | - const destFilePath = path.join(resolvedDestDir, header.name); | ||
| 132 | - const resolvedDestPath = path.resolve(destFilePath); | ||
| 133 | - | ||
| 134 | - // Security: Validate that the entry path doesn't escape the destination directory | ||
| 135 | - if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) { | ||
| 136 | - console.warn(`[compressing] Skipping entry with path traversal: "${header.name}" -> "${resolvedDestPath}"`); | ||
| 137 | - stream.resume(); | ||
| 138 | - return; | ||
| 139 | - } | ||
| 140 | - | ||
| 141 | - if (header.type === 'file') { | ||
| 142 | - const dir = path.dirname(destFilePath); | ||
| 143 | - mkdirp(dir, err => { | ||
| 144 | - if (err) return reject(err); | ||
| 145 | - | ||
| 146 | - entryCount++; | ||
| 147 | - pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => { | ||
| 148 | - if (err) return reject(err); | ||
| 149 | - successCount++; | ||
| 150 | - done(); | ||
| 151 | - }); | ||
| 152 | - }); | ||
| 153 | - } else if (header.type === 'symlink') { | ||
| 154 | - const dir = path.dirname(destFilePath); | ||
| 155 | - const target = path.resolve(dir, header.linkname); | ||
| 156 | - | ||
| 157 | - // Security: Validate that the symlink target doesn't escape the destination directory | ||
| 158 | - if (!isPathWithinParent(target, resolvedDestDir)) { | ||
| 159 | - console.warn(`[compressing] Skipping symlink "${header.name}": target "${target}" escapes extraction directory`); | ||
| 172 | + // Resolve once for the entire extraction to handle OS-level symlinks | ||
| 173 | + // (e.g. /var -> /private/var on macOS) | ||
| 174 | + let realDestDir = resolvedDestDir; | ||
| 175 | + fs.realpath(resolvedDestDir, (err, resolved) => { | ||
| 176 | + if (!err) realDestDir = resolved; | ||
| 177 | + | ||
| 178 | + let entryCount = 0; | ||
| 179 | + let successCount = 0; | ||
| 180 | + let isFinish = false; | ||
| 181 | + function done() { | ||
| 182 | + // resolve when both stream finish and file write finish | ||
| 183 | + if (isFinish && entryCount === successCount) resolve(); | ||
| 184 | + } | ||
| 185 | + | ||
| 186 | + new StreamClass(opts) | ||
| 187 | + .on('finish', () => { | ||
| 188 | + isFinish = true; | ||
| 189 | + done(); | ||
| 190 | + }) | ||
| 191 | + .on('error', reject) | ||
| 192 | + .on('entry', (header, stream, next) => { | ||
| 193 | + stream.on('end', next); | ||
| 194 | + const destFilePath = path.join(resolvedDestDir, header.name); | ||
| 195 | + const resolvedDestPath = path.resolve(destFilePath); | ||
| 196 | + | ||
| 197 | + // Security: Validate that the entry path doesn't escape the destination directory | ||
| 198 | + if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) { | ||
| 199 | + console.warn('[compressing] Skipping entry with path traversal: "' + header.name + '" -> "' + resolvedDestPath + '"'); | ||
| 160 | 200 | stream.resume(); | |
| 161 | 201 | return; | |
| 162 | 202 | } | |
| 163 | 203 | ||
| 164 | - entryCount++; | ||
| 165 | - | ||
| 166 | - mkdirp(dir, err => { | ||
| 167 | - if (err) return reject(err); | ||
| 168 | - | ||
| 169 | - const relativeTarget = path.relative(dir, target); | ||
| 170 | - fs.symlink(relativeTarget, destFilePath, err => { | ||
| 171 | - if (err) return reject(err); | ||
| 172 | - successCount++; | ||
| 204 | + // Security: Validate no pre-existing symlink in the path escapes the extraction directory | ||
| 205 | + isRealPathSafe(resolvedDestPath, resolvedDestDir, realDestDir, (err, safe) => { | ||
| 206 | + if (err || !safe) { | ||
| 207 | + console.warn('[compressing] Skipping entry "' + header.name + '": a symlink in its path resolves outside the extraction directory'); | ||
| 173 | 208 | stream.resume(); | |
| 174 | - }); | ||
| 175 | - }); | ||
| 176 | - } else { // directory | ||
| 177 | - mkdirp(destFilePath, err => { | ||
| 178 | - if (err) return reject(err); | ||
| 179 | - stream.resume(); | ||
| 209 | + return; | ||
| 210 | + } | ||
| 211 | + | ||
| 212 | + if (header.type === 'file') { | ||
| 213 | + const dir = path.dirname(destFilePath); | ||
| 214 | + mkdirp(dir, err => { | ||
| 215 | + if (err) return reject(err); | ||
| 216 | + | ||
| 217 | + entryCount++; | ||
| 218 | + pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => { | ||
| 219 | + if (err) return reject(err); | ||
| 220 | + successCount++; | ||
| 221 | + done(); | ||
| 222 | + }); | ||
| 223 | + }); | ||
| 224 | + } else if (header.type === 'symlink') { | ||
| 225 | + const dir = path.dirname(destFilePath); | ||
| 226 | + const target = path.resolve(dir, header.linkname); | ||
| 227 | + | ||
| 228 | + // Security: Validate that the symlink target doesn't escape the destination directory | ||
| 229 | + if (!isPathWithinParent(target, resolvedDestDir)) { | ||
| 230 | + console.warn('[compressing] Skipping symlink "' + header.name + '": target "' + target + '" escapes extraction directory'); | ||
| 231 | + stream.resume(); | ||
| 232 | + return; | ||
| 233 | + } | ||
| 234 | + | ||
| 235 | + // Security: Validate no pre-existing symlink in the target path escapes the extraction directory | ||
| 236 | + isRealPathSafe(target, resolvedDestDir, realDestDir, (err, targetSafe) => { | ||
| 237 | + if (err || !targetSafe) { | ||
| 238 | + console.warn('[compressing] Skipping symlink "' + header.name + '": target resolves outside extraction directory via existing symlink'); | ||
| 239 | + stream.resume(); | ||
| 240 | + return; | ||
| 241 | + } | ||
| 242 | + | ||
| 243 | + entryCount++; | ||
| 244 | + | ||
| 245 | + mkdirp(dir, err => { | ||
| 246 | + if (err) return reject(err); | ||
| 247 | + | ||
| 248 | + const relativeTarget = path.relative(dir, target); | ||
| 249 | + fs.symlink(relativeTarget, destFilePath, err => { | ||
| 250 | + if (err) return reject(err); | ||
| 251 | + successCount++; | ||
| 252 | + stream.resume(); | ||
| 253 | + }); | ||
| 254 | + }); | ||
| 255 | + }); | ||
| 256 | + } else { // directory | ||
| 257 | + mkdirp(destFilePath, err => { | ||
| 258 | + if (err) return reject(err); | ||
| 259 | + stream.resume(); | ||
| 260 | + }); | ||
| 261 | + } | ||
| 180 | 262 | }); | |
| 181 | - } | ||
| 182 | - }); | ||
| 263 | + }); | ||
| 264 | + }); | ||
| 183 | 265 | }); | |
| 184 | 266 | }); | |
| 185 | 267 | }; | |
| Back | FazBrowse Home | New Git URL |
0 commit comments