Skip to content

Commit fd3ae61

Browse files
priyanshu92Copilot
andcommitted
Harden export solution ZIP validation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 5381612 commit fd3ae61

2 files changed

Lines changed: 520 additions & 13 deletions

File tree

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
const test = require('node:test');
2+
const assert = require('node:assert/strict');
3+
const crypto = require('node:crypto');
4+
const fs = require('node:fs');
5+
const os = require('node:os');
6+
const path = require('node:path');
7+
const zlib = require('node:zlib');
8+
const { spawnSync } = require('node:child_process');
9+
10+
const VALIDATOR_PATH = path.join(
11+
__dirname,
12+
'..',
13+
'..',
14+
'skills',
15+
'export-solution',
16+
'scripts',
17+
'validate-export.js'
18+
);
19+
20+
function crc32(data) {
21+
let crc = 0xffffffff;
22+
for (const byte of data) {
23+
crc ^= byte;
24+
for (let bit = 0; bit < 8; bit++) {
25+
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
26+
}
27+
}
28+
return (crc ^ 0xffffffff) >>> 0;
29+
}
30+
31+
function createZip(entries) {
32+
const localRecords = [];
33+
const centralRecords = [];
34+
let localOffset = 0;
35+
36+
for (const entry of entries) {
37+
const name = Buffer.from(entry.name, 'utf8');
38+
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, 'utf8');
39+
const method = entry.method ?? 0;
40+
const compressedData = method === 8 ? zlib.deflateRawSync(data) : data;
41+
const checksum = crc32(data);
42+
43+
const localHeader = Buffer.alloc(30);
44+
localHeader.writeUInt32LE(0x04034b50, 0);
45+
localHeader.writeUInt16LE(20, 4);
46+
localHeader.writeUInt16LE(0x0800, 6);
47+
localHeader.writeUInt16LE(method, 8);
48+
localHeader.writeUInt32LE(checksum, 14);
49+
localHeader.writeUInt32LE(compressedData.length, 18);
50+
localHeader.writeUInt32LE(data.length, 22);
51+
localHeader.writeUInt16LE(name.length, 26);
52+
localRecords.push(localHeader, name, compressedData);
53+
54+
const centralHeader = Buffer.alloc(46);
55+
centralHeader.writeUInt32LE(0x02014b50, 0);
56+
centralHeader.writeUInt16LE(20, 4);
57+
centralHeader.writeUInt16LE(20, 6);
58+
centralHeader.writeUInt16LE(0x0800, 8);
59+
centralHeader.writeUInt16LE(method, 10);
60+
centralHeader.writeUInt32LE(checksum, 16);
61+
centralHeader.writeUInt32LE(compressedData.length, 20);
62+
centralHeader.writeUInt32LE(data.length, 24);
63+
centralHeader.writeUInt16LE(name.length, 28);
64+
centralHeader.writeUInt32LE(localOffset, 42);
65+
centralRecords.push(centralHeader, name);
66+
67+
localOffset += localHeader.length + name.length + compressedData.length;
68+
}
69+
70+
const centralDirectory = Buffer.concat(centralRecords);
71+
const endRecord = Buffer.alloc(22);
72+
endRecord.writeUInt32LE(0x06054b50, 0);
73+
endRecord.writeUInt16LE(entries.length, 8);
74+
endRecord.writeUInt16LE(entries.length, 10);
75+
endRecord.writeUInt32LE(centralDirectory.length, 12);
76+
endRecord.writeUInt32LE(localOffset, 16);
77+
78+
return Buffer.concat([...localRecords, centralDirectory, endRecord]);
79+
}
80+
81+
function makeProject(t) {
82+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-export-'));
83+
t.after(() => fs.rmSync(projectRoot, { recursive: true, force: true }));
84+
return projectRoot;
85+
}
86+
87+
function validSolutionEntries(extraEntries = []) {
88+
return [
89+
{
90+
name: 'Solution.xml',
91+
data: `<ImportExportXml>${crypto.randomBytes(1400).toString('hex')}</ImportExportXml>`,
92+
method: 8,
93+
},
94+
...extraEntries,
95+
];
96+
}
97+
98+
function runValidator(projectRoot, env = process.env) {
99+
return spawnSync(process.execPath, [VALIDATOR_PATH], {
100+
cwd: projectRoot,
101+
input: JSON.stringify({ cwd: projectRoot }),
102+
encoding: 'utf8',
103+
timeout: 10000,
104+
env,
105+
});
106+
}
107+
108+
test('approves when no exported solution ZIP exists', (t) => {
109+
const projectRoot = makeProject(t);
110+
const result = runValidator(projectRoot);
111+
112+
assert.equal(result.status, 0, result.stderr);
113+
});
114+
115+
test('approves a valid deflated solution ZIP containing Solution.xml', (t) => {
116+
const projectRoot = makeProject(t);
117+
fs.writeFileSync(
118+
path.join(projectRoot, 'release_unmanaged.zip'),
119+
createZip(validSolutionEntries())
120+
);
121+
122+
const result = runValidator(projectRoot);
123+
124+
assert.equal(result.status, 0, result.stderr);
125+
});
126+
127+
test('handles malicious archive and path names without invoking a shell', (t) => {
128+
const projectRoot = makeProject(t);
129+
const markerName = 'validator-command-ran';
130+
const zipName = `release_$(touch ${markerName})_unmanaged.zip`;
131+
fs.writeFileSync(
132+
path.join(projectRoot, zipName),
133+
createZip(validSolutionEntries([
134+
{
135+
name: 'assets/"quoted"; $(ignored) & payload.txt',
136+
data: 'not executable',
137+
},
138+
]))
139+
);
140+
141+
const result = runValidator(projectRoot);
142+
143+
assert.equal(result.status, 0, result.stderr);
144+
assert.equal(fs.existsSync(path.join(projectRoot, markerName)), false);
145+
});
146+
147+
test('handles spaces, quotes, and cross-platform filename metacharacters', (t) => {
148+
const projectRoot = makeProject(t);
149+
const zipPath = path.join(projectRoot, "release 'review copy' & (final); 100%_managed.zip");
150+
fs.writeFileSync(zipPath, createZip(validSolutionEntries()));
151+
152+
const result = runValidator(projectRoot);
153+
154+
assert.equal(result.status, 0, result.stderr);
155+
});
156+
157+
test('blocks a corrupt ZIP instead of approving by file size', (t) => {
158+
const projectRoot = makeProject(t);
159+
fs.writeFileSync(
160+
path.join(projectRoot, 'corrupt_unmanaged.zip'),
161+
crypto.randomBytes(1500)
162+
);
163+
164+
const result = runValidator(projectRoot);
165+
166+
assert.equal(result.status, 2);
167+
assert.match(result.stderr, /could not be validated/i);
168+
});
169+
170+
test('blocks a structurally valid ZIP that does not contain Solution.xml', (t) => {
171+
const projectRoot = makeProject(t);
172+
fs.writeFileSync(
173+
path.join(projectRoot, 'missing_solution_managed.zip'),
174+
createZip([
175+
{
176+
name: 'Other.xml',
177+
data: crypto.randomBytes(1400),
178+
},
179+
])
180+
);
181+
182+
const result = runValidator(projectRoot);
183+
184+
assert.equal(result.status, 2);
185+
assert.match(result.stderr, /does not contain solution\.xml/i);
186+
});
187+
188+
test('blocks a ZIP when the Solution.xml payload fails its integrity check', (t) => {
189+
const projectRoot = makeProject(t);
190+
const archive = createZip([
191+
{
192+
name: 'Solution.xml',
193+
data: crypto.randomBytes(1400),
194+
method: 0,
195+
},
196+
]);
197+
archive[30 + Buffer.byteLength('Solution.xml')] ^= 0xff;
198+
fs.writeFileSync(path.join(projectRoot, 'damaged_managed.zip'), archive);
199+
200+
const result = runValidator(projectRoot);
201+
202+
assert.equal(result.status, 2);
203+
assert.match(result.stderr, /integrity check/i);
204+
});
205+
206+
test('blocks duplicate root Solution.xml entries', (t) => {
207+
const projectRoot = makeProject(t);
208+
fs.writeFileSync(
209+
path.join(projectRoot, 'duplicate_unmanaged.zip'),
210+
createZip([
211+
...validSolutionEntries(),
212+
{
213+
name: 'solution.XML',
214+
data: crypto.randomBytes(1400),
215+
},
216+
])
217+
);
218+
219+
const result = runValidator(projectRoot);
220+
221+
assert.equal(result.status, 2);
222+
assert.match(result.stderr, /duplicate Solution\.xml/i);
223+
});
224+
225+
test('blocks a ZIP with a corrupt non-manifest local header', (t) => {
226+
const projectRoot = makeProject(t);
227+
const archive = createZip(validSolutionEntries([
228+
{
229+
name: 'Customizations.xml',
230+
data: crypto.randomBytes(1400),
231+
},
232+
]));
233+
const secondLocalHeader = archive.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04]), 4);
234+
archive.writeUInt32LE(0, secondLocalHeader);
235+
fs.writeFileSync(path.join(projectRoot, 'corrupt_entry_managed.zip'), archive);
236+
237+
const result = runValidator(projectRoot);
238+
239+
assert.equal(result.status, 2);
240+
assert.match(result.stderr, /local header.*invalid signature/i);
241+
});
242+
243+
test('blocks local-header metadata that disagrees with the central directory', (t) => {
244+
const projectRoot = makeProject(t);
245+
const archive = createZip(validSolutionEntries());
246+
archive.writeUInt32LE(1, 18);
247+
fs.writeFileSync(path.join(projectRoot, 'corrupt_metadata_managed.zip'), archive);
248+
249+
const result = runValidator(projectRoot);
250+
251+
assert.equal(result.status, 2);
252+
assert.match(result.stderr, /sizes or checksum.*disagree/i);
253+
});
254+
255+
test('blocks oversized archives before reading them into memory', (t) => {
256+
const projectRoot = makeProject(t);
257+
const zipPath = path.join(projectRoot, 'oversized_unmanaged.zip');
258+
fs.writeFileSync(zipPath, Buffer.alloc(0));
259+
fs.truncateSync(zipPath, 100 * 1024 * 1024 + 1);
260+
261+
const result = runValidator(projectRoot);
262+
263+
assert.equal(result.status, 2);
264+
assert.match(result.stderr, /exceeds the supported 100 MiB/i);
265+
});
266+
267+
test('validates without unzip, grep, or any executable available on PATH', (t) => {
268+
const projectRoot = makeProject(t);
269+
fs.writeFileSync(
270+
path.join(projectRoot, 'windows-compatible_unmanaged.zip'),
271+
createZip(validSolutionEntries())
272+
);
273+
274+
const envWithoutPath = Object.fromEntries(
275+
Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'path')
276+
);
277+
const result = runValidator(projectRoot, { ...envWithoutPath, PATH: '' });
278+
279+
assert.equal(result.status, 0, result.stderr);
280+
});

0 commit comments

Comments
 (0)