amicus 4.9.6 → 4.9.7

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,238 @@
1
+ /**
2
+ * THE OTHER TABLE AN ARCHIVE DECLARES ITS NAMES IN — the local file headers.
3
+ *
4
+ * SPLIT OUT of zip-name-scan.js (v4.9.7, council #239 round 2): that file reached
5
+ * the repo's 300-line gate, and this walk answers a different question from the
6
+ * central-directory scan beside it. `nameRefusal` is imported rather than
7
+ * reimplemented, so there is exactly ONE rule about what yauzl would refuse and
8
+ * the two tables cannot drift apart.
9
+ *
10
+ * WHY A SECOND WALK EXISTS. `scanEntryNames` reads the CENTRAL directory, and an
11
+ * archive can make that unreadable while leaving every local header whole — a
12
+ * truncation does it by accident, four one-field edits to a COMPLETE
13
+ * end-of-central-directory record do it on purpose. MEASURED before this shipped:
14
+ * seven such archives carrying `../../../PWNED-BY-NATIVE.txt` reached a real
15
+ * spawn, two of them running to completion. And the tables can DISAGREE, which is
16
+ * why both are read: on an archive declaring one name locally and another
17
+ * centrally, `tar.exe` wrote the LOCAL name and `Expand-Archive` the CENTRAL one.
18
+ *
19
+ * ONLY EVER A NARROWING. Nothing here can make a rescue HAPPEN: the sole output
20
+ * that changes control flow is a REFUSAL, and `complete`/`names` exist for the
21
+ * notice printed before a spawn and decide nothing.
22
+ *
23
+ * @module sidecar/zip-local-name-scan
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const { nameRefusal, MAX_ENTRIES } = require('./zip-name-scan');
29
+
30
+ /** The three signatures the local-header chain walks between. */
31
+ const LOCAL_SIG = 0x04034b50;
32
+ const CENTRAL_SIG = 0x02014b50;
33
+ const EOCD_SIG = 0x06054b50;
34
+ /** Bit 3: the sizes are BEHIND the payload, in a data descriptor. */
35
+ const FLAG_SIZES_DEFERRED = 0x08;
36
+ /** The same local signature as bytes, for scanning a span the walk would jump. */
37
+ const LOCAL_SIG_BYTES = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
38
+
39
+ /**
40
+ * Where this archive's END-OF-CENTRAL-DIRECTORY record says its directory begins,
41
+ * or -1 when no such record can be found.
42
+ *
43
+ * THE TERMINUS HAS TO BE ANCHORED TO SOMETHING THE ARCHIVE DECLARES. A walk that
44
+ * stops the moment it lands on `PK\x01\x02` believes four bytes it has not
45
+ * earned: MEASURED, planting that signature where an honest advance lands ends
46
+ * the chain with every local header in the archive untouched and no field lying,
47
+ * so neither the deferred-size demotion nor the span check fires -- and the walk
48
+ * reports it saw everything while `tar.exe` went on to reach a `../../../` entry
49
+ * sitting behind it.
50
+ */
51
+ function declaredCentralOffset(bytes) {
52
+ if (bytes.length < 22) { return -1; }
53
+ const floor = Math.max(0, bytes.length - 22 - 0xFFFF);
54
+ for (let i = bytes.length - 22; i >= floor; i -= 1) {
55
+ if (bytes.readUInt32LE(i) === EOCD_SIG) { return bytes.readUInt32LE(i + 16); }
56
+ }
57
+ return -1;
58
+ }
59
+
60
+ /**
61
+ * THE BYTES THE CHAIN COULD NOT REACH, ASKED THE SAME QUESTION ANYWAY.
62
+ *
63
+ * Every way the walk can stop early -- a size deferred to a data descriptor, the
64
+ * zip64 sentinel, a chain that runs off the end or lands on a non-signature --
65
+ * leaves the region AHEAD of it unexamined, and an entry the walk never reached
66
+ * is an entry it cannot refuse. That is the council #239 round-2 BLOCKER, and it
67
+ * needed no exotic archive: bit 3 with a zero size is a STANDARD encoding, so an
68
+ * attacker puts one entry in front of `../../../PWNED.txt`, blinds the central
69
+ * directory, and the walk stops before it ever sees the hostile name.
70
+ *
71
+ * WHY NOT SIMPLY REFUSE WHEN THE WALK CANNOT FINISH. Because that is the blanket
72
+ * fail-closed this cluster already rejected on MEASURED availability grounds: a
73
+ * truncated zip is the case the whole rescue exists for, and its walk cannot
74
+ * finish either. Refusing there kills the feature to close the hole.
75
+ *
76
+ * So the region is SWEPT rather than trusted or refused: every local-header
77
+ * signature in it is located and its declared name put through the SAME
78
+ * `nameRefusal`. It cannot follow the chain (that is what broke), so it does not
79
+ * pretend to -- `complete` stays false and the notice still says the names could
80
+ * not be confirmed. It can only ADD refusals, so the narrowing invariant holds
81
+ * and no archive that is rescued today stops being rescued unless it declares a
82
+ * hostile name.
83
+ *
84
+ * THE RESIDUAL: a `PK\x03\x04` occurring by chance inside a payload is read as a
85
+ * header, so a refusal can name something that is not an entry. MEASURED: zero
86
+ * spurious signatures across 777 MB of six real Electron artifacts, and a false
87
+ * hit must ALSO be followed by bytes that parse as a traversal or absolute name.
88
+ * It fails toward REFUSING, which costs a rescue and never grants one.
89
+ *
90
+ * Bounded: one pass, and at most MAX_ENTRIES candidates.
91
+ */
92
+ function sweepUnreached(bytes, from) {
93
+ let seen = 0;
94
+ let at = from;
95
+ while (at >= 0 && at + 30 <= bytes.length && seen < MAX_ENTRIES) {
96
+ const hit = bytes.indexOf(LOCAL_SIG_BYTES, at);
97
+ if (hit === -1 || hit + 30 > bytes.length) { return null; }
98
+ seen += 1;
99
+ const nameLen = bytes.readUInt16LE(hit + 26);
100
+ const stop = Math.min(hit + 30 + nameLen, bytes.length);
101
+ const refusal = nameRefusal(bytes.subarray(hit + 30, stop).toString('latin1'));
102
+ if (refusal) { return refusal; }
103
+ at = hit + 4;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * THE SAME QUESTION, ASKED OF THE LOCAL FILE HEADERS.
110
+ *
111
+ * WHY A SECOND WALK EXISTS. `scanEntryNames` reads the CENTRAL directory, and an
112
+ * archive can make that unreadable while leaving every local header whole.
113
+ * MEASURED: four ONE-FIELD edits to a COMPLETE end-of-central-directory record —
114
+ * entry count `0xFFFF`, cd offset `0xFFFFFFFF`, a lying comment length, a
115
+ * multi-disk marker — each blind yauzl on an archive whose `../../../` entry
116
+ * `tar` and `Expand-Archive` then read perfectly. On the comment-length one the
117
+ * rescue RAN TO COMPLETION and promoted the result. Cutting the tail off the file
118
+ * does the same thing by accident.
119
+ *
120
+ * AND IT IS THE TABLE ONE OF THE STRATEGIES ACTUALLY USES. MEASURED on an archive
121
+ * declaring one name locally and another centrally: `tar.exe` (bsdtar 3.8.4) wrote
122
+ * the LOCAL name; `Expand-Archive` wrote the CENTRAL one. Neither table is the
123
+ * right one to read. Both are. That is the whole ruling — the earlier candidates
124
+ * argued about which BLINDNESS to tolerate while looking in one table.
125
+ *
126
+ * ONE NAME RULE, NOT TWO. It calls `nameRefusal` above, deliberately: a second
127
+ * lexical rule free to drift from yauzl's would start costing the rescue archives
128
+ * yauzl accepts, which is the failure this module was written to avoid.
129
+ *
130
+ * ONLY `refusal` MAY CHANGE CONTROL FLOW. `complete` and `names` exist for the
131
+ * notice printed before a spawn and decide nothing, so the module's NARROWING
132
+ * invariant above holds verbatim: nothing here can make a rescue HAPPEN.
133
+ *
134
+ * NEVER THROWS, and synchronous: it reads headers and SKIPS payloads, so it never
135
+ * decompresses. MEASURED on six real electron artifacts (v28.0.0-v43.6.0,
136
+ * 107-151 MB): 73-75 names in 0-1 ms, no data descriptors, and the local names
137
+ * equal the central names entry for entry. Truncated, the central walk goes blind
138
+ * and this one still enumerates all 73-75.
139
+ *
140
+ * WHAT `complete` MEANS, AND WHY IT IS A VARIABLE. It is the claim "I saw every
141
+ * local header", it starts true, and it may only ever be turned OFF. Three things
142
+ * turn it off: an entry declaring bit 3 (its stated size is not authoritative),
143
+ * a declared size whose span HIDES another local signature, and a chain that ends
144
+ * anywhere but the offset this archive's own EOCD names. All three DEMOTE and
145
+ * keep walking. That distinction is the whole design: a name the walk can still
146
+ * read is a name it can still REFUSE, and MEASURED, turning any of these into an
147
+ * early `return` forfeits the refusal for every entry behind it — an archive
148
+ * with bit 3, an honest size and `../../../PWNED.txt` at entry 2 went from a
149
+ * terminal refusal to a completed rescue on 5.7% of forged shapes.
150
+ *
151
+ * THE RESIDUAL: the walk still trusts a local size to FIND the next header, so a
152
+ * desynchronised walk can read a "name" out of payload bytes and refuse a name
153
+ * that is not an entry. MEASURED constructible; MEASURED to need deliberate
154
+ * construction — zero spurious `PK\x03\x04` signatures across 777 MB of six real
155
+ * Electron artifacts. It fails toward REFUSING, which costs a rescue and never
156
+ * grants one.
157
+ *
158
+ * @param {Buffer} bytes the archive, in this process's heap
159
+ * @returns {{refusal:string|null, complete:boolean, names:number, why:string}}
160
+ * `complete` = the chain reached the directory, so EVERY local header was seen.
161
+ * A refusal stops the walk early, so it reports `complete:false` too: it did not
162
+ * see them all, and a field must not claim otherwise on any of its return sites.
163
+ */
164
+ function scanLocalNames(bytes) {
165
+ let at = 0;
166
+ let names = 0;
167
+ // `complete` MAY ONLY EVER BE TURNED OFF, and the walk is never SHORTENED by
168
+ // anything but a refusal or a genuinely unknowable next offset. Turning a
169
+ // doubt into an early `return` was MEASURED to destroy the refusal for every
170
+ // entry behind it -- an archive with bit 3, an HONEST size and
171
+ // `../../../PWNED.txt` at entry 2 went from a terminal UNZIP_UNSAFE_ARCHIVE to
172
+ // a completed rescue. Deleting the one control-flow-changing power this module
173
+ // has IS the control-flow change; demoting a claim is not.
174
+ let complete = true;
175
+ let why = '';
176
+ const demote = (m) => { if (complete) { complete = false; why = m; } };
177
+ const cdOffset = declaredCentralOffset(bytes);
178
+ // EVERY EARLY EXIT SWEEPS WHAT IT NEVER REACHED. A stop is not a clean bill,
179
+ // and it is not a refusal either -- but the bytes ahead of it may declare a
180
+ // name that IS one. See `sweepUnreached`.
181
+ const stopped = (from, m) => ({ refusal: sweepUnreached(bytes, from), complete: false, names, why: why || m });
182
+ try {
183
+ for (;;) {
184
+ if (at + 4 > bytes.length) { return stopped(at, `the local-header chain ran off the end at ${at}`); }
185
+ const sig = bytes.readUInt32LE(at);
186
+ if (sig === CENTRAL_SIG || sig === EOCD_SIG) {
187
+ // ANCHORED: the chain may claim it reached the directory only where this
188
+ // archive says its directory begins. See `declaredCentralOffset`.
189
+ if (at !== cdOffset) { demote(`the chain ended at ${at}, not where this archive declares its directory begins (${cdOffset})`); }
190
+ return { refusal: null, complete, names, why };
191
+ }
192
+ if (sig !== LOCAL_SIG || at + 30 > bytes.length) { return stopped(at, `no local file header at ${at}`); }
193
+ const flags = bytes.readUInt16LE(at + 6);
194
+ const compressed = bytes.readUInt32LE(at + 18);
195
+ const nameLen = bytes.readUInt16LE(at + 26);
196
+ const extraLen = bytes.readUInt16LE(at + 28);
197
+ if (at + 30 + nameLen > bytes.length) { return stopped(at, `a local file name was cut off at ${at}`); }
198
+ names += 1;
199
+ // Latin-1 for the reason the central walk gives: bytes to characters 1:1.
200
+ const refusal = nameRefusal(bytes.subarray(at + 30, at + 30 + nameLen).toString('latin1'));
201
+ if (refusal) { return { refusal, complete: false, names, why: '' }; }
202
+ // NO NEXT OFFSET AT ALL. These two are the only genuine stops: a zero size
203
+ // under bit 3 would advance by nothing and resynchronise on payload, and
204
+ // the zip64 sentinel names a size that is not here.
205
+ if (((flags & FLAG_SIZES_DEFERRED) && compressed === 0) || compressed === 0xFFFFFFFF) {
206
+ return stopped(at, `entry ${names} does not declare its size here`);
207
+ }
208
+ // BIT 3 SAYS THIS SIZE IS NOT AUTHORITATIVE. APPNOTE 4.4.4 has the writer
209
+ // set it to ZERO, so a nonzero value beside the flag is malformed by
210
+ // construction and `complete` may not rest on it -- but it is still the
211
+ // only lead to the next header, and a name the walk can still read is a
212
+ // name it can still REFUSE. Follow it; just stop claiming to have proved
213
+ // anything (the filed blocker: a lying nonzero size jumped a hostile entry
214
+ // and the walk reported it had seen them all).
215
+ if (flags & FLAG_SIZES_DEFERRED) { demote(`entry ${names} declares bit 3, so the size it states here is not authoritative`); }
216
+ const next = at + 30 + nameLen + extraLen + compressed;
217
+ // THE SPAN THE WALK NEVER LOOKS AT -- name, extra field and payload, every
218
+ // length the archive's to choose. A span holding a local signature may be
219
+ // hiding an entry, so the claim is demoted; the walk still follows the
220
+ // offset, because stopping here would forfeit the refusals behind it.
221
+ if (next <= bytes.length && next > at + 30) {
222
+ // BOUNDED TO THE SPAN. Searching to the end of the archive and only then
223
+ // comparing against `next` is the same answer for O(archive) work PER
224
+ // ENTRY -- quadratic on attacker-chosen input, synchronous, and on the
225
+ // rescue path before the hatch policy is even read (council #239 r2).
226
+ // `subarray` is a view, not a copy, so the total is one pass.
227
+ const hidden = bytes.subarray(at + 30, next).indexOf(LOCAL_SIG_BYTES);
228
+ if (hidden !== -1) { demote(`entry ${names}'s declared size jumps over a local file header at ${at + 30 + hidden}`); }
229
+ }
230
+ if (names >= MAX_ENTRIES) { return stopped(at, `stopped after ${MAX_ENTRIES} entries`); }
231
+ at = next;
232
+ }
233
+ } catch (e) {
234
+ return { refusal: null, complete: false, names, why: `the local-header chain could not be walked: ${(e && e.message) || e}` };
235
+ }
236
+ }
237
+
238
+ module.exports = { scanLocalNames, declaredCentralOffset };
@@ -38,6 +38,11 @@
38
38
  * It also cannot see a SYMLINK whose TARGET escapes the root: that is bytes, not
39
39
  * a name, and `zip-from-buffer.js` refuses it only because it reads the payload.
40
40
  *
41
+ * THE BLINDNESS IN THE FIRST PARAGRAPH IS WHY `scanLocalNames` EXISTS (v4.9.7,
42
+ * B3). An archive carries its names TWICE, and an unreadable central directory
43
+ * says nothing about the local file headers — which is the table `tar` was
44
+ * MEASURED to act on. The caller asks both.
45
+ *
41
46
  * ── AND IT IS ONLY EVER A NARROWING ───────────────────────────────────────
42
47
  * Nothing here can make a rescue happen. A refusal it forms turns a
43
48
  * `UNZIP_BUFFER_FAILED` into the TERMINAL `UNZIP_UNSAFE_ARCHIVE` its caller