pompelmi 0.35.3 → 0.35.5

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.
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'crypto';
2
+ import { createInflateRaw } from 'zlib';
2
3
  import { useState, useCallback } from 'react';
3
4
 
4
5
  const MB$1 = 1024 * 1024;
@@ -1052,12 +1053,15 @@ async function scanFiles(files, opts = {}) {
1052
1053
  return out;
1053
1054
  }
1054
1055
 
1056
+ const ARCHIVE_BOMB_DETECTED = "ARCHIVE_BOMB_DETECTED";
1057
+ const SIG_LFH = 0x04034b50;
1055
1058
  const SIG_CEN = 0x02014b50;
1056
1059
  const DEFAULTS = {
1057
1060
  maxEntries: 1000,
1058
1061
  maxTotalUncompressedBytes: 500 * 1024 * 1024,
1062
+ maxPerEntryUncompressedBytes: 100 * 1024 * 1024,
1059
1063
  maxEntryNameLength: 255,
1060
- maxCompressionRatio: 1000,
1064
+ maxCompressionRatio: 100,
1061
1065
  eocdSearchWindow: 70000,
1062
1066
  };
1063
1067
  function r16(buf, off) {
@@ -1067,7 +1071,6 @@ function r32(buf, off) {
1067
1071
  return buf.readUInt32LE(off);
1068
1072
  }
1069
1073
  function isZipLike(buf) {
1070
- // local file header at start is common
1071
1074
  return (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04);
1072
1075
  }
1073
1076
  function lastIndexOfEOCD(buf, window) {
@@ -1079,6 +1082,47 @@ function lastIndexOfEOCD(buf, window) {
1079
1082
  function hasTraversal(name) {
1080
1083
  return (name.includes("../") || name.includes("..\\") || name.startsWith("/") || /^[A-Za-z]:/.test(name));
1081
1084
  }
1085
+ function makeBombError() {
1086
+ return Object.assign(new Error("Archive bomb detected: decompression limits exceeded"), {
1087
+ code: ARCHIVE_BOMB_DETECTED,
1088
+ });
1089
+ }
1090
+ /**
1091
+ * Feeds `compressed` into a raw DEFLATE inflate stream and counts the actual
1092
+ * output bytes. Resolves with bombed=true and aborts early if any limit fires:
1093
+ * - decompressed bytes > maxPerEntry
1094
+ * - totalSoFar + decompressed > maxTotal
1095
+ * - decompressed / compressed > maxRatio (ratio measured on real bytes, not headers)
1096
+ *
1097
+ * Malformed DEFLATE is treated as safe (bombed=false, decompressed=0).
1098
+ */
1099
+ function streamInflate(compressed, maxPerEntry, maxTotal, alreadySeen, maxRatio) {
1100
+ return new Promise((resolve) => {
1101
+ const inf = createInflateRaw();
1102
+ let out = 0;
1103
+ const compBytes = compressed.length;
1104
+ let done = false;
1105
+ const finish = (bombed) => {
1106
+ if (done)
1107
+ return;
1108
+ done = true;
1109
+ inf.destroy();
1110
+ resolve({ decompressed: out, bombed });
1111
+ };
1112
+ inf.on("data", (chunk) => {
1113
+ out += chunk.length;
1114
+ if (out > maxPerEntry ||
1115
+ alreadySeen + out > maxTotal ||
1116
+ (compBytes > 0 && out / compBytes > maxRatio)) {
1117
+ finish(true);
1118
+ }
1119
+ });
1120
+ inf.on("end", () => finish(false));
1121
+ // Malformed DEFLATE stream → not a bomb, just corrupt
1122
+ inf.on("error", () => finish(false));
1123
+ inf.end(compressed);
1124
+ });
1125
+ }
1082
1126
  function createZipBombGuard(opts = {}) {
1083
1127
  const cfg = { ...DEFAULTS, ...opts };
1084
1128
  return {
@@ -1087,43 +1131,36 @@ function createZipBombGuard(opts = {}) {
1087
1131
  const matches = [];
1088
1132
  if (!isZipLike(buf))
1089
1133
  return matches;
1090
- // Find EOCD near the end
1134
+ // ── 1. Locate EOCD ──────────────────────────────────────────────────────
1091
1135
  const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
1092
1136
  if (eocdPos < 0 || eocdPos + 22 > buf.length) {
1093
- // ZIP but no EOCD — malformed or polyglot → suspicious
1094
1137
  matches.push({ rule: "zip_eocd_not_found", severity: "medium" });
1095
1138
  return matches;
1096
1139
  }
1097
1140
  const totalEntries = r16(buf, eocdPos + 10);
1098
1141
  const cdSize = r32(buf, eocdPos + 12);
1099
1142
  const cdOffset = r32(buf, eocdPos + 16);
1100
- // Bounds check
1101
1143
  if (cdOffset + cdSize > buf.length) {
1102
1144
  matches.push({ rule: "zip_cd_out_of_bounds", severity: "medium" });
1103
1145
  return matches;
1104
1146
  }
1105
- // Iterate central directory entries
1147
+ const lfhIndex = [];
1106
1148
  let ptr = cdOffset;
1107
1149
  let seen = 0;
1108
- let sumComp = 0;
1109
- let sumUnc = 0;
1110
1150
  while (ptr + 46 <= cdOffset + cdSize && seen < totalEntries) {
1111
- const sig = r32(buf, ptr);
1112
- if (sig !== SIG_CEN)
1113
- break; // stop if structure breaks
1114
- const compSize = r32(buf, ptr + 20);
1115
- const uncSize = r32(buf, ptr + 24);
1151
+ if (r32(buf, ptr) !== SIG_CEN)
1152
+ break;
1153
+ const cdCompSize = r32(buf, ptr + 20);
1116
1154
  const fnLen = r16(buf, ptr + 28);
1117
1155
  const exLen = r16(buf, ptr + 30);
1118
1156
  const cmLen = r16(buf, ptr + 32);
1119
- const nameStart = ptr + 46;
1120
- const nameEnd = nameStart + fnLen;
1157
+ const lfhOffset = r32(buf, ptr + 42);
1158
+ const nameEnd = ptr + 46 + fnLen;
1121
1159
  if (nameEnd > buf.length)
1122
1160
  break;
1123
- const name = buf.toString("utf8", nameStart, nameEnd);
1124
- sumComp += compSize;
1125
- sumUnc += uncSize;
1161
+ const name = buf.toString("utf8", ptr + 46, nameEnd);
1126
1162
  seen++;
1163
+ lfhIndex.push({ lfhOffset, cdCompSize });
1127
1164
  if (name.length > cfg.maxEntryNameLength) {
1128
1165
  matches.push({
1129
1166
  rule: "zip_entry_name_too_long",
@@ -1134,48 +1171,67 @@ function createZipBombGuard(opts = {}) {
1134
1171
  if (hasTraversal(name)) {
1135
1172
  matches.push({ rule: "zip_path_traversal_entry", severity: "medium", meta: { name } });
1136
1173
  }
1137
- // move to next entry
1138
1174
  ptr = nameEnd + exLen + cmLen;
1139
1175
  }
1140
1176
  if (seen !== totalEntries) {
1141
- // central dir truncated/odd, still report what we found
1142
1177
  matches.push({
1143
1178
  rule: "zip_cd_truncated",
1144
1179
  severity: "medium",
1145
1180
  meta: { seen, totalEntries },
1146
1181
  });
1147
1182
  }
1148
- // Heuristics thresholds
1149
1183
  if (seen > cfg.maxEntries) {
1150
1184
  matches.push({
1151
1185
  rule: "zip_too_many_entries",
1152
1186
  severity: "medium",
1153
1187
  meta: { seen, limit: cfg.maxEntries },
1154
1188
  });
1189
+ // Return early — decompressing thousands of entries would be a DoS vector
1190
+ return matches;
1155
1191
  }
1156
- if (sumUnc > cfg.maxTotalUncompressedBytes) {
1157
- matches.push({
1158
- rule: "zip_total_uncompressed_too_large",
1159
- severity: "medium",
1160
- meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes },
1161
- });
1162
- }
1163
- if (sumComp === 0 && sumUnc > 0) {
1164
- matches.push({
1165
- rule: "zip_suspicious_ratio",
1166
- severity: "medium",
1167
- meta: { ratio: Infinity },
1168
- });
1169
- }
1170
- else if (sumComp > 0) {
1171
- const ratio = sumUnc / Math.max(1, sumComp);
1172
- if (ratio >= cfg.maxCompressionRatio) {
1173
- matches.push({
1174
- rule: "zip_suspicious_ratio",
1175
- severity: "medium",
1176
- meta: { ratio, limit: cfg.maxCompressionRatio },
1177
- });
1192
+ // ── 3. True streaming decompression — archive bomb detection ────────────
1193
+ // For every DEFLATE entry (method=8) we feed the raw compressed bytes into
1194
+ // zlib.createInflateRaw() and count the bytes that come OUT. We abort the
1195
+ // moment any limit fires; we NEVER trust the header-reported uncompressed
1196
+ // size for the ratio decision.
1197
+ //
1198
+ // For STORED entries (method=0) compressed == uncompressed by spec, so the
1199
+ // byte count is immediate.
1200
+ let totalDecompressed = 0;
1201
+ for (const { lfhOffset, cdCompSize } of lfhIndex) {
1202
+ if (lfhOffset + 30 > buf.length)
1203
+ continue;
1204
+ if (r32(buf, lfhOffset) !== SIG_LFH)
1205
+ continue;
1206
+ const gpbf = r16(buf, lfhOffset + 6);
1207
+ const method = r16(buf, lfhOffset + 8);
1208
+ let lfhCompSz = r32(buf, lfhOffset + 18);
1209
+ const fnLen = r16(buf, lfhOffset + 26);
1210
+ const exLen = r16(buf, lfhOffset + 28);
1211
+ const dataOff = lfhOffset + 30 + fnLen + exLen;
1212
+ // If the data-descriptor flag is set (GPBF bit 3), the LFH sizes are 0.
1213
+ // Fall back to the CD size purely for navigation — not for bomb detection.
1214
+ if ((gpbf & 0x08) !== 0 && lfhCompSz === 0) {
1215
+ lfhCompSz = cdCompSize;
1216
+ }
1217
+ if (dataOff + lfhCompSz > buf.length)
1218
+ continue; // truncated entry — skip
1219
+ if (method === 8 /* DEFLATE */) {
1220
+ const compressed = buf.slice(dataOff, dataOff + lfhCompSz);
1221
+ const { decompressed, bombed } = await streamInflate(compressed, cfg.maxPerEntryUncompressedBytes, cfg.maxTotalUncompressedBytes, totalDecompressed, cfg.maxCompressionRatio);
1222
+ if (bombed)
1223
+ throw makeBombError();
1224
+ totalDecompressed += decompressed;
1225
+ }
1226
+ else if (method === 0 /* STORED */) {
1227
+ // Compressed == uncompressed for stored entries
1228
+ if (lfhCompSz > cfg.maxPerEntryUncompressedBytes)
1229
+ throw makeBombError();
1230
+ totalDecompressed += lfhCompSz;
1231
+ if (totalDecompressed > cfg.maxTotalUncompressedBytes)
1232
+ throw makeBombError();
1178
1233
  }
1234
+ // Other methods (bzip2=12, lzma=14, zstd=93, …) — skip; no built-in support
1179
1235
  }
1180
1236
  return matches;
1181
1237
  },