|
| 1 | +'use strict'; |
| 2 | + |
| 3 | +const fs = require('fs'); |
| 4 | +const os = require('os'); |
| 5 | +const path = require('path'); |
| 6 | +const zlib = require('zlib'); |
| 7 | +const uuid = require('uuid'); |
| 8 | +const assert = require('assert'); |
| 9 | +const compressing = require('../..'); |
| 10 | +const { createTarBuffer } = require('../util'); |
| 11 | + |
| 12 | +describe('test/tar/security-GHSA-4c3q-x735-j3r5.test.js', () => { |
| 13 | + let tempDir; |
| 14 | + |
| 15 | + beforeEach(() => { |
| 16 | + tempDir = path.join(os.tmpdir(), uuid.v4()); |
| 17 | + fs.mkdirSync(tempDir, { recursive: true }); |
| 18 | + }); |
| 19 | + |
| 20 | + afterEach(() => { |
| 21 | + fs.rmSync(tempDir, { recursive: true, force: true }); |
| 22 | + }); |
| 23 | + |
| 24 | + function gzipBuffer(buf) { |
| 25 | + return new Promise((resolve, reject) => { |
| 26 | + zlib.gzip(buf, (err, result) => { |
| 27 | + if (err) return reject(err); |
| 28 | + resolve(result); |
| 29 | + }); |
| 30 | + }); |
| 31 | + } |
| 32 | + |
| 33 | + describe('pre-existing symlink file pointing outside destDir', () => { |
| 34 | + it('should block file write through pre-existing symlink to external file', async () => { |
| 35 | + const destDir = path.join(tempDir, 'dest'); |
| 36 | + const outsideDir = path.join(tempDir, 'outside'); |
| 37 | + const sensitiveFile = path.join(outsideDir, 'target.txt'); |
| 38 | + |
| 39 | + // Setup: create the sensitive file and a pre-existing symlink in destDir |
| 40 | + fs.mkdirSync(outsideDir, { recursive: true }); |
| 41 | + fs.writeFileSync(sensitiveFile, 'ORIGINAL_SAFE_CONTENT'); |
| 42 | + fs.mkdirSync(destDir, { recursive: true }); |
| 43 | + fs.symlinkSync(sensitiveFile, path.join(destDir, 'config_file')); |
| 44 | + |
| 45 | + // Create a tar with a regular file entry matching the symlink name |
| 46 | + const tarBuffer = await createTarBuffer([ |
| 47 | + { name: 'config_file', type: 'file', content: 'MALICIOUS_OVERWRITE' }, |
| 48 | + ]); |
| 49 | + |
| 50 | + await compressing.tar.uncompress(tarBuffer, destDir); |
| 51 | + |
| 52 | + // The sensitive file should NOT have been overwritten |
| 53 | + assert.strictEqual( |
| 54 | + fs.readFileSync(sensitiveFile, 'utf8'), |
| 55 | + 'ORIGINAL_SAFE_CONTENT', |
| 56 | + 'Sensitive file should not be overwritten through pre-existing symlink' |
| 57 | + ); |
| 58 | + }); |
| 59 | + }); |
| 60 | + |
| 61 | + describe('pre-existing symlink directory pointing outside destDir', () => { |
| 62 | + it('should block file write through pre-existing symlink directory', async () => { |
| 63 | + const destDir = path.join(tempDir, 'dest'); |
| 64 | + const outsideDir = path.join(tempDir, 'outside'); |
| 65 | + |
| 66 | + // Setup: create outside dir and a symlink directory in destDir |
| 67 | + fs.mkdirSync(outsideDir, { recursive: true }); |
| 68 | + fs.mkdirSync(destDir, { recursive: true }); |
| 69 | + fs.symlinkSync(outsideDir, path.join(destDir, 'subdir')); |
| 70 | + |
| 71 | + // Create a tar with a file inside the symlink directory |
| 72 | + const tarBuffer = await createTarBuffer([ |
| 73 | + { name: 'subdir/secret.txt', type: 'file', content: 'MALICIOUS_DATA' }, |
| 74 | + ]); |
| 75 | + |
| 76 | + await compressing.tar.uncompress(tarBuffer, destDir); |
| 77 | + |
| 78 | + // The file should NOT exist in the outside directory |
| 79 | + assert.strictEqual( |
| 80 | + fs.existsSync(path.join(outsideDir, 'secret.txt')), |
| 81 | + false, |
| 82 | + 'File should not be written through pre-existing symlink directory' |
| 83 | + ); |
| 84 | + }); |
| 85 | + }); |
| 86 | + |
| 87 | + describe('deeply nested pre-existing symlink', () => { |
| 88 | + it('should block file write through nested symlink escape', async () => { |
| 89 | + const destDir = path.join(tempDir, 'dest'); |
| 90 | + const outsideDir = path.join(tempDir, 'outside'); |
| 91 | + |
| 92 | + // Setup: create real directories and a symlink deep in the tree |
| 93 | + fs.mkdirSync(path.join(destDir, 'a', 'b'), { recursive: true }); |
| 94 | + fs.mkdirSync(outsideDir, { recursive: true }); |
| 95 | + fs.symlinkSync(outsideDir, path.join(destDir, 'a', 'b', 'c')); |
| 96 | + |
| 97 | + // Create a tar with a file through the deep symlink |
| 98 | + const tarBuffer = await createTarBuffer([ |
| 99 | + { name: 'a/b/c/file.txt', type: 'file', content: 'ESCAPED_DATA' }, |
| 100 | + ]); |
| 101 | + |
| 102 | + await compressing.tar.uncompress(tarBuffer, destDir); |
| 103 | + |
| 104 | + // The file should NOT exist in the outside directory |
| 105 | + assert.strictEqual( |
| 106 | + fs.existsSync(path.join(outsideDir, 'file.txt')), |
| 107 | + false, |
| 108 | + 'File should not be written through deeply nested symlink' |
| 109 | + ); |
| 110 | + }); |
| 111 | + }); |
| 112 | + |
| 113 | + describe('pre-existing symlink pointing within destDir (should be allowed)', () => { |
| 114 | + it('should allow file write through symlink that stays within destDir', async () => { |
| 115 | + const destDir = path.join(tempDir, 'dest'); |
| 116 | + const realDir = path.join(destDir, 'real'); |
| 117 | + |
| 118 | + // Setup: create real directory and internal symlink |
| 119 | + fs.mkdirSync(realDir, { recursive: true }); |
| 120 | + fs.symlinkSync(realDir, path.join(destDir, 'link')); |
| 121 | + |
| 122 | + // Create a tar with a file through the internal symlink |
| 123 | + const tarBuffer = await createTarBuffer([ |
| 124 | + { name: 'link/newfile.txt', type: 'file', content: 'safe content' }, |
| 125 | + ]); |
| 126 | + |
| 127 | + await compressing.tar.uncompress(tarBuffer, destDir); |
| 128 | + |
| 129 | + // The file SHOULD exist since the symlink points within destDir |
| 130 | + assert.strictEqual( |
| 131 | + fs.readFileSync(path.join(realDir, 'newfile.txt'), 'utf8'), |
| 132 | + 'safe content', |
| 133 | + 'File should be written through internal symlink' |
| 134 | + ); |
| 135 | + }); |
| 136 | + }); |
| 137 | + |
| 138 | + describe('directory entry through pre-existing external symlink', () => { |
| 139 | + it('should block directory creation through pre-existing symlink', async () => { |
| 140 | + const destDir = path.join(tempDir, 'dest'); |
| 141 | + const outsideDir = path.join(tempDir, 'outside'); |
| 142 | + |
| 143 | + // Setup |
| 144 | + fs.mkdirSync(outsideDir, { recursive: true }); |
| 145 | + fs.mkdirSync(destDir, { recursive: true }); |
| 146 | + fs.symlinkSync(outsideDir, path.join(destDir, 'escape')); |
| 147 | + |
| 148 | + // Create a tar with a directory entry through the symlink |
| 149 | + const tarBuffer = await createTarBuffer([ |
| 150 | + { name: 'escape/newdir/', type: 'directory' }, |
| 151 | + ]); |
| 152 | + |
| 153 | + await compressing.tar.uncompress(tarBuffer, destDir); |
| 154 | + |
| 155 | + // The directory should NOT exist in the outside directory |
| 156 | + assert.strictEqual( |
| 157 | + fs.existsSync(path.join(outsideDir, 'newdir')), |
| 158 | + false, |
| 159 | + 'Directory should not be created through pre-existing symlink' |
| 160 | + ); |
| 161 | + }); |
| 162 | + }); |
| 163 | + |
| 164 | + describe('tgz format shares the same protection', () => { |
| 165 | + it('should block file write through pre-existing symlink in tgz extraction', async () => { |
| 166 | + const destDir = path.join(tempDir, 'dest'); |
| 167 | + const outsideDir = path.join(tempDir, 'outside'); |
| 168 | + const sensitiveFile = path.join(outsideDir, 'target.txt'); |
| 169 | + |
| 170 | + // Setup |
| 171 | + fs.mkdirSync(outsideDir, { recursive: true }); |
| 172 | + fs.writeFileSync(sensitiveFile, 'ORIGINAL_SAFE_CONTENT'); |
| 173 | + fs.mkdirSync(destDir, { recursive: true }); |
| 174 | + fs.symlinkSync(sensitiveFile, path.join(destDir, 'config_file')); |
| 175 | + |
| 176 | + // Create a tgz buffer |
| 177 | + const tarBuffer = await createTarBuffer([ |
| 178 | + { name: 'config_file', type: 'file', content: 'MALICIOUS_OVERWRITE' }, |
| 179 | + ]); |
| 180 | + const tgzBuffer = await gzipBuffer(tarBuffer); |
| 181 | + |
| 182 | + await compressing.tgz.uncompress(tgzBuffer, destDir); |
| 183 | + |
| 184 | + // The sensitive file should NOT have been overwritten |
| 185 | + assert.strictEqual( |
| 186 | + fs.readFileSync(sensitiveFile, 'utf8'), |
| 187 | + 'ORIGINAL_SAFE_CONTENT', |
| 188 | + 'TGZ: Sensitive file should not be overwritten through pre-existing symlink' |
| 189 | + ); |
| 190 | + }); |
| 191 | + }); |
| 192 | + |
| 193 | + describe('normal extraction still works (regression)', () => { |
| 194 | + it('should extract files normally when no pre-existing symlinks', async () => { |
| 195 | + const destDir = path.join(tempDir, 'dest'); |
| 196 | + |
| 197 | + const tarBuffer = await createTarBuffer([ |
| 198 | + { name: 'file1.txt', type: 'file', content: 'content1' }, |
| 199 | + { name: 'subdir/', type: 'directory' }, |
| 200 | + { name: 'subdir/file2.txt', type: 'file', content: 'content2' }, |
| 201 | + ]); |
| 202 | + |
| 203 | + await compressing.tar.uncompress(tarBuffer, destDir); |
| 204 | + |
| 205 | + assert.strictEqual(fs.readFileSync(path.join(destDir, 'file1.txt'), 'utf8'), 'content1'); |
| 206 | + assert.strictEqual(fs.readFileSync(path.join(destDir, 'subdir/file2.txt'), 'utf8'), 'content2'); |
| 207 | + }); |
| 208 | + }); |
| 209 | +}); |
0 commit comments