domma-cms 1.7.1 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Reading a .zip without trusting it - for the WordPress export (an .xml in a
3
+ * .zip) and the site's uploads folder (wp-content/uploads zipped up).
4
+ *
5
+ * Defences:
6
+ * - zip-slip: an entry name that is absolute, has a drive letter, a
7
+ * backslash, a NUL or a ".." segment is refused outright - and nothing here
8
+ * ever writes an entry to disk under its own name anyway; callers get bytes
9
+ * and choose a sanitised name themselves.
10
+ * - zip bombs: a ceiling on entries, on each entry's size, on the total, and
11
+ * on the compression ratio; inflation is capped at the size the entry
12
+ * declares (`maxOutputLength`), so a lying header cannot expand further.
13
+ * - encrypted entries and ZIP64 archives are refused with a clear message.
14
+ *
15
+ * The archive is read with positioned reads from its file, never loaded whole.
16
+ *
17
+ * @module services/wpImport/zip
18
+ */
19
+ import fs from 'node:fs/promises';
20
+ import zlib from 'node:zlib';
21
+ import {promisify} from 'node:util';
22
+
23
+ const inflateRaw = promisify(zlib.inflateRaw);
24
+
25
+ export const ZIP_LIMITS = Object.freeze({
26
+ maxEntries: 50_000,
27
+ maxEntryBytes: 64 * 1024 * 1024,
28
+ maxTotalBytes: 4 * 1024 * 1024 * 1024,
29
+ maxRatio: 200
30
+ });
31
+
32
+ export class ZipError extends Error {
33
+ constructor(message) { super(message); this.code = 'EZIP'; }
34
+ }
35
+
36
+ /**
37
+ * Is this a name we will look at? Rejects every path-escape trick.
38
+ *
39
+ * @param {string} name
40
+ * @returns {boolean}
41
+ */
42
+ export function safeEntryName(name) {
43
+ const s = String(name ?? '');
44
+ if (!s || s.length > 1024) return false;
45
+ if (s.includes('\0') || s.includes('\\')) return false;
46
+ if (s.startsWith('/') || /^[a-zA-Z]:/.test(s)) return false;
47
+ return !s.split('/').some(seg => seg === '..');
48
+ }
49
+
50
+ /**
51
+ * Open a zip file.
52
+ *
53
+ * @param {string} file
54
+ * @param {Partial<typeof ZIP_LIMITS>} [limits]
55
+ * @returns {Promise<{entries: {name: string, size: number, compressedSize: number, method: number, offset: number}[],
56
+ * unsafe: string[], read: (entry: object) => Promise<Buffer>, close: () => Promise<void>}>}
57
+ */
58
+ export async function openZip(file, limits = {}) {
59
+ const lim = {...ZIP_LIMITS, ...limits};
60
+ const fh = await fs.open(file, 'r');
61
+ try {
62
+ const {size} = await fh.stat();
63
+ if (size < 22) throw new ZipError('Not a zip file.');
64
+ const tailLen = Math.min(size, 22 + 0xffff);
65
+ const tail = Buffer.alloc(tailLen);
66
+ await fh.read(tail, 0, tailLen, size - tailLen);
67
+ let eocd = -1;
68
+ for (let p = tailLen - 22; p >= 0; p--) {
69
+ if (tail.readUInt32LE(p) === 0x06054b50) { eocd = p; break; }
70
+ }
71
+ if (eocd === -1) throw new ZipError('Not a zip file (no end-of-archive record).');
72
+ const total = tail.readUInt16LE(eocd + 10);
73
+ const cdSize = tail.readUInt32LE(eocd + 12);
74
+ const cdOffset = tail.readUInt32LE(eocd + 16);
75
+ if (total === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) {
76
+ throw new ZipError('This zip is in the ZIP64 format (over 4 GB or 65,535 files), which is not supported. Zip a smaller part of the folder.');
77
+ }
78
+ if (total > lim.maxEntries) throw new ZipError(`The zip holds ${total} files - more than ${lim.maxEntries}.`);
79
+ if (cdOffset + cdSize > size) throw new ZipError('The zip is damaged (its directory runs past the end).');
80
+ const cd = Buffer.alloc(cdSize);
81
+ await fh.read(cd, 0, cdSize, cdOffset);
82
+
83
+ const entries = [];
84
+ const unsafe = [];
85
+ let sum = 0;
86
+ let p = 0;
87
+ for (let n = 0; n < total; n++) {
88
+ if (p + 46 > cd.length || cd.readUInt32LE(p) !== 0x02014b50) throw new ZipError('The zip is damaged (a bad directory entry).');
89
+ const flags = cd.readUInt16LE(p + 8);
90
+ const method = cd.readUInt16LE(p + 10);
91
+ const compressedSize = cd.readUInt32LE(p + 20);
92
+ const uncompressed = cd.readUInt32LE(p + 24);
93
+ const nameLen = cd.readUInt16LE(p + 28);
94
+ const extraLen = cd.readUInt16LE(p + 30);
95
+ const commentLen = cd.readUInt16LE(p + 32);
96
+ const offset = cd.readUInt32LE(p + 42);
97
+ const name = cd.toString(flags & 0x800 ? 'utf8' : 'latin1', p + 46, p + 46 + nameLen);
98
+ p += 46 + nameLen + extraLen + commentLen;
99
+ if (name.endsWith('/')) continue; // a folder
100
+ if (!safeEntryName(name)) { unsafe.push(name.slice(0, 200)); continue; }
101
+ if (flags & 0x1) throw new ZipError(`"${name}" is encrypted - zip the folder without a password.`);
102
+ if (method !== 0 && method !== 8) throw new ZipError(`"${name}" uses a compression method this importer cannot read (only stored and deflate).`);
103
+ if (uncompressed > lim.maxEntryBytes) continue; // too big to import - reported by the caller as missing
104
+ if (compressedSize > 0 && uncompressed > 1024 * 1024 && uncompressed / compressedSize > lim.maxRatio) {
105
+ throw new ZipError(`"${name}" expands ${Math.round(uncompressed / compressedSize)} times - refused as a likely zip bomb.`);
106
+ }
107
+ sum += uncompressed;
108
+ if (sum > lim.maxTotalBytes) throw new ZipError('The zip expands to more than the importer accepts.');
109
+ entries.push({name, size: uncompressed, compressedSize, method, offset});
110
+ }
111
+
112
+ return {
113
+ entries,
114
+ unsafe,
115
+ async read(entry) {
116
+ const head = Buffer.alloc(30);
117
+ await fh.read(head, 0, 30, entry.offset);
118
+ if (head.readUInt32LE(0) !== 0x04034b50) throw new ZipError(`"${entry.name}" is damaged.`);
119
+ const start = entry.offset + 30 + head.readUInt16LE(26) + head.readUInt16LE(28);
120
+ if (start + entry.compressedSize > size) throw new ZipError(`"${entry.name}" runs past the end of the zip.`);
121
+ const raw = Buffer.alloc(entry.compressedSize);
122
+ if (entry.compressedSize) await fh.read(raw, 0, entry.compressedSize, start);
123
+ if (entry.method === 0) {
124
+ if (entry.compressedSize !== entry.size) throw new ZipError(`"${entry.name}" is damaged.`);
125
+ return raw;
126
+ }
127
+ let out;
128
+ try {
129
+ out = await inflateRaw(raw, {maxOutputLength: Math.max(1, entry.size)});
130
+ } catch (err) {
131
+ throw new ZipError(`"${entry.name}" could not be unpacked: ${err.code === 'ERR_BUFFER_TOO_LARGE' ? 'it is bigger than it says' : err.message}.`);
132
+ }
133
+ if (out.length !== entry.size) throw new ZipError(`"${entry.name}" is not the size it says.`);
134
+ return out;
135
+ },
136
+ close: () => fh.close()
137
+ };
138
+ } catch (err) {
139
+ await fh.close().catch(() => {});
140
+ throw err;
141
+ }
142
+ }