@@ -113,6 +113,72 @@ export function findEocdOffset(buffer: Buffer): number {
113113 return - 1
114114}
115115
116+ const EOCD_SIGNATURE_BYTES = Buffer . from ( [ 0x50 , 0x4b , 0x05 , 0x06 ] )
117+
118+ /**
119+ * Whether JSZip (which mammoth and the Word document helpers parse through)
120+ * would read central-directory records from a different place than the guard
121+ * inspects. JSZip does not anchor on the buffer tail: it takes the LAST EOCD
122+ * signature anywhere in the buffer, and treats any gap between that record and
123+ * the end of the central directory it declares as prepended data, rebasing
124+ * every offset past it. A buffer the guard reads as "not a ZIP" (or as a
125+ * different ZIP) can therefore still be inflated in full downstream.
126+ *
127+ * Returns `false` when JSZip would refuse the buffer or find no records — a
128+ * non-ZIP file that merely contains the EOCD bytes is not flagged.
129+ */
130+ function zipParserReadsDifferentArchive ( buffer : Buffer ) : boolean {
131+ const parserEocdOffset = buffer . lastIndexOf ( EOCD_SIGNATURE_BYTES )
132+ if ( parserEocdOffset < 0 || parserEocdOffset + EOCD_MIN_SIZE > buffer . length ) {
133+ return false
134+ }
135+
136+ let centralDirectorySize = buffer . readUInt32LE ( parserEocdOffset + 12 )
137+ let centralDirectoryOffset = buffer . readUInt32LE ( parserEocdOffset + 16 )
138+ let expectedEocdOffset = centralDirectoryOffset + centralDirectorySize
139+ const usesZip64 =
140+ buffer . readUInt16LE ( parserEocdOffset + 10 ) === UINT16_SENTINEL ||
141+ centralDirectorySize === UINT32_SENTINEL ||
142+ centralDirectoryOffset === UINT32_SENTINEL
143+ if ( usesZip64 ) {
144+ // JSZip falls back to a signature scan when the locator does not resolve,
145+ // so a ZIP64 chain the guard cannot follow from offset 0 is unverifiable.
146+ const locatorOffset = parserEocdOffset - ZIP64_EOCD_LOCATOR_SIZE
147+ if ( locatorOffset < 0 || buffer . readUInt32LE ( locatorOffset ) !== ZIP64_EOCD_LOCATOR_SIGNATURE ) {
148+ return true
149+ }
150+ const zip64EocdOffset = Number ( buffer . readBigUInt64LE ( locatorOffset + 8 ) )
151+ if (
152+ zip64EocdOffset + 56 > buffer . length ||
153+ buffer . readUInt32LE ( zip64EocdOffset ) !== ZIP64_EOCD_SIGNATURE
154+ ) {
155+ return true
156+ }
157+ centralDirectorySize = Number ( buffer . readBigUInt64LE ( zip64EocdOffset + 40 ) )
158+ centralDirectoryOffset = Number ( buffer . readBigUInt64LE ( zip64EocdOffset + 48 ) )
159+ expectedEocdOffset =
160+ centralDirectoryOffset +
161+ centralDirectorySize +
162+ ZIP64_EOCD_LOCATOR_SIZE +
163+ 12 +
164+ Number ( buffer . readBigUInt64LE ( zip64EocdOffset + 4 ) )
165+ }
166+
167+ const prependedBytes = parserEocdOffset - expectedEocdOffset
168+ if ( prependedBytes < 0 ) {
169+ return false
170+ }
171+ if ( prependedBytes === 0 && parserEocdOffset === findEocdOffset ( buffer ) ) {
172+ return false
173+ }
174+
175+ const parserCentralDirectoryStart = prependedBytes + centralDirectoryOffset
176+ return (
177+ parserCentralDirectoryStart + 4 <= buffer . length &&
178+ buffer . readUInt32LE ( parserCentralDirectoryStart ) === CENTRAL_DIRECTORY_HEADER_SIGNATURE
179+ )
180+ }
181+
116182export interface CentralDirectoryLocation {
117183 offset : number
118184 entryCount : number
@@ -503,12 +569,24 @@ export function readZipCentralDirectoryStats(buffer: Buffer): ZipCentralDirector
503569 * or whose entries cannot be verified, is rejected rather than passed through,
504570 * so a malformed archive that a downstream library still inflates cannot bypass
505571 * the guard. Genuinely non-ZIP inputs (legacy OLE `.xls`/`.doc`, misidentified
506- * plaintext) no-op and defer to the downstream parser's own validation.
572+ * plaintext) no-op and defer to the downstream parser's own validation. "Genuinely
573+ * non-ZIP" is judged by what JSZip would open, not by the leading bytes: an
574+ * archive with prepended data or a relocated EOCD is rejected, because JSZip
575+ * reads it from a place this guard never inspected.
507576 */
508577export function assertOoxmlArchiveWithinLimits (
509578 buffer : Buffer ,
510579 limits : OoxmlSizeLimits = DEFAULT_OOXML_SIZE_LIMITS
511580) : void {
581+ if ( zipParserReadsDifferentArchive ( buffer ) ) {
582+ logger . warn ( 'Rejected ZIP archive: parser would read records the guard cannot inspect' , {
583+ compressedBytes : buffer . length ,
584+ } )
585+ throw new ArchiveIntegrityError (
586+ 'Archive has prepended data or a relocated end-of-central-directory record; refusing to parse an unverifiable ZIP archive'
587+ )
588+ }
589+
512590 const declared = sumDeclaredUncompressedSize ( buffer , limits )
513591 if ( declared === null ) {
514592 if ( isZipShaped ( buffer ) ) {
0 commit comments