pompelmi 0.35.4 → 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.
- package/README.md +49 -8
- package/dist/pompelmi.browser.cjs +99 -43
- package/dist/pompelmi.browser.cjs.map +1 -1
- package/dist/pompelmi.browser.esm.js +99 -43
- package/dist/pompelmi.browser.esm.js.map +1 -1
- package/dist/pompelmi.cjs +99 -43
- package/dist/pompelmi.cjs.map +1 -1
- package/dist/pompelmi.esm.js +99 -43
- package/dist/pompelmi.esm.js.map +1 -1
- package/dist/pompelmi.react.cjs +99 -43
- package/dist/pompelmi.react.cjs.map +1 -1
- package/dist/pompelmi.react.esm.js +99 -43
- package/dist/pompelmi.react.esm.js.map +1 -1
- package/dist/types/src/scanners/zip-bomb-guard.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
<!-- ════════════════════════════════════════════════════════════════════
|
|
2
|
+
PIVOT NOTICE — read before using this package
|
|
3
|
+
════════════════════════════════════════════════════════════════════ -->
|
|
4
|
+
> [!CAUTION]
|
|
5
|
+
> **v0.x is an experimental prototype. Do NOT use it in production.**
|
|
6
|
+
>
|
|
7
|
+
> Pompelmi v0.x has **known Event Loop blocking issues** and makes overreaching
|
|
8
|
+
> "scanner" claims that it cannot keep. This version is in **soft maintenance
|
|
9
|
+
> only** — no new features, security patches only.
|
|
10
|
+
>
|
|
11
|
+
> ---
|
|
12
|
+
>
|
|
13
|
+
> **Pompelmi is pivoting for v1.0.**
|
|
14
|
+
>
|
|
15
|
+
> The new identity is a **dead-simple, one-line utility wrapper** for Node.js
|
|
16
|
+
> file uploads — not an "ultimate malware scanner". v1.0 will bundle standard,
|
|
17
|
+
> well-understood checks (size limits, magic bytes, basic heuristics) into a
|
|
18
|
+
> single convenient call that returns a traffic-light verdict:
|
|
19
|
+
>
|
|
20
|
+
> ```js
|
|
21
|
+
> const result = await pompelmi.scan(file);
|
|
22
|
+
> // returns 'green', 'suspicious', or 'malicious'
|
|
23
|
+
> ```
|
|
24
|
+
>
|
|
25
|
+
> No magic promises. No impossible guarantees. Just saved developer time.
|
|
26
|
+
>
|
|
27
|
+
> Follow the pivot → [GitHub Issues](https://github.com/pompelmi/pompelmi/issues) · [Discussions](https://github.com/pompelmi/pompelmi/discussions)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
1
31
|
<div align="center">
|
|
2
32
|
<img src="./assets/logo.svg" alt="Pompelmi logo" width="120" />
|
|
3
33
|
|
|
@@ -24,11 +54,18 @@
|
|
|
24
54
|
|
|
25
55
|
<p>
|
|
26
56
|
<a href="https://www.npmjs.com/package/pompelmi"><img alt="npm version" src="https://img.shields.io/npm/v/pompelmi" /></a>
|
|
57
|
+
<a href="https://www.npmjs.com/package/pompelmi"><img alt="npm total downloads" src="https://img.shields.io/npm/dt/pompelmi" /></a>
|
|
27
58
|
<a href="https://github.com/pompelmi/pompelmi/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/pompelmi/pompelmi/ci.yml?label=ci" /></a>
|
|
28
59
|
<a href="https://codecov.io/gh/pompelmi/pompelmi"><img alt="codecov" src="https://codecov.io/gh/pompelmi/pompelmi/graph/badge.svg" /></a>
|
|
29
60
|
<a href="https://github.com/pompelmi/pompelmi/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/pompelmi/pompelmi?style=social" /></a>
|
|
61
|
+
<a href="https://github.com/pompelmi/pompelmi/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-green.svg" /></a>
|
|
62
|
+
<a href="https://nodejs.org"><img alt="Node.js 18+" src="https://img.shields.io/badge/node-%3E%3D18-brightgreen" /></a>
|
|
30
63
|
</p>
|
|
31
64
|
|
|
65
|
+
> If Pompelmi fits how you think about upload security at the route
|
|
66
|
+
> level, starring the repo helps other Node.js teams find it.
|
|
67
|
+
> [★ Star on GitHub](https://github.com/pompelmi/pompelmi/stargazers)
|
|
68
|
+
|
|
32
69
|
<p>
|
|
33
70
|
<a href="https://pompelmi.app/"><strong>Docs</strong></a>
|
|
34
71
|
·
|
|
@@ -50,12 +87,10 @@
|
|
|
50
87
|
<a href="https://stackoverflow.blog/2026/02/23/defense-against-uploads-oss-file-scanner-pompelmi/">Stack Overflow</a>,
|
|
51
88
|
<a href="https://www.helpnetsecurity.com/2026/02/02/pompelmi-open-source-secure-file-upload-scanning-node-js/">Help Net Security</a>,
|
|
52
89
|
<a href="https://github.com/sorrycc/awesome-javascript">Awesome JavaScript</a>,
|
|
90
|
+
<a href="https://github.com/dzharii/awesome-typescript">Awesome TypeScript</a>,
|
|
91
|
+
<a href="https://bytes.dev/archives/429">Bytes</a>,
|
|
53
92
|
and
|
|
54
|
-
<a href="https://
|
|
55
|
-
</p>
|
|
56
|
-
|
|
57
|
-
<p align="center">
|
|
58
|
-
<sub>If you want upload security to start at the route boundary instead of after storage, consider starring the repo.</sub>
|
|
93
|
+
<a href="https://www.detectionengineering.net/p/det-eng-weekly-124">Detection Engineering Weekly</a>
|
|
59
94
|
</p>
|
|
60
95
|
|
|
61
96
|
> Upload endpoints are part of your attack surface. Pompelmi helps Node.js teams scan files before storage and make the decision while the route still has context: accept, quarantine, or reject.
|
|
@@ -99,6 +134,8 @@ Start with [Getting started](https://pompelmi.app/getting-started/) for a local
|
|
|
99
134
|
- Pompelmi keeps the first trust decision inside the application path, where the route still knows the file class, trust level, storage path, and failure mode.
|
|
100
135
|
- It gives Node.js teams a practical way to build secure file uploads with route-level decisions instead of bolting checks on after persistence.
|
|
101
136
|
|
|
137
|
+
File upload vulnerabilities are the root cause of real CVEs across web frameworks. Application-layer inspection is the earliest point where the route still has full policy context — file class, trust level, storage path, and failure mode. Waiting until after storage removes most of that context and limits what the application can decide.
|
|
138
|
+
|
|
102
139
|
## What it checks
|
|
103
140
|
|
|
104
141
|
- MIME sniffing, magic-byte validation, and extension mismatch detection
|
|
@@ -125,6 +162,8 @@ That inspect-first, store-later shape is where Pompelmi is strongest.
|
|
|
125
162
|
| Antivirus or YARA only | Known malicious matches and signature-style detection | Route context, spoofing checks, and before-storage handling |
|
|
126
163
|
| Pompelmi at the upload route | Node.js file upload security, scan files before storage, and verdict-driven workflow decisions | It is not a full antivirus replacement on its own |
|
|
127
164
|
|
|
165
|
+
<!-- search: file upload security Node.js, MIME spoofing protection, archive bomb defense -->
|
|
166
|
+
|
|
128
167
|
## Supported frameworks and workflows
|
|
129
168
|
|
|
130
169
|
| Stack or workflow | Links |
|
|
@@ -147,9 +186,7 @@ That inspect-first, store-later shape is where Pompelmi is strongest.
|
|
|
147
186
|
- [Examples index](./examples/README.md) for framework-specific and production-oriented patterns
|
|
148
187
|
- [Docs home](https://pompelmi.app/) for guides, comparisons, use cases, and tutorials
|
|
149
188
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
Pompelmi is focused on a real gap in most Node.js stacks: secure file uploads that make a decision before storage, not after. If that matches how you want upload security to work, star the repo to follow the project and help more teams discover the inspect-before-storage model.
|
|
189
|
+
Listed in Awesome JavaScript and Awesome TypeScript, and featured by Node Weekly, Stack Overflow, Help Net Security, Bytes, and Detection Engineering Weekly.
|
|
153
190
|
|
|
154
191
|
<!-- MENTIONS:START -->
|
|
155
192
|
|
|
@@ -160,6 +197,10 @@ Pompelmi is focused on a real gap in most Node.js stacks: secure file uploads th
|
|
|
160
197
|
- [Pompelmi: Open-source secure file upload scanning for Node.js](https://www.helpnetsecurity.com/2026/02/02/pompelmi-open-source-secure-file-upload-scanning-node-js/) — Help Net Security
|
|
161
198
|
- [Awesome JavaScript](https://github.com/sorrycc/awesome-javascript)
|
|
162
199
|
- [Awesome TypeScript](https://github.com/dzharii/awesome-typescript)
|
|
200
|
+
- [Bytes #429](https://bytes.dev/archives/429) — bytes.dev (2025-10-03)
|
|
201
|
+
- [Det. Eng. Weekly #124](https://www.detectionengineering.net/p/det-eng-weekly-124) — detectionengineering.net (2025-08-13)
|
|
202
|
+
- [The Overflow #319](https://stackoverflow.blog/2026/03/04/the-overflow-319/) — stackoverflow.blog (2026-03-04)
|
|
203
|
+
- [Hottest OSS tools Feb 2026](https://www.helpnetsecurity.com/2026/02/26/hottest-oss-tools-feb-2026/) — helpnetsecurity.com (2026-02-26)
|
|
163
204
|
- [See all mentions](https://pompelmi.app/featured-in/)
|
|
164
205
|
|
|
165
206
|
<!-- MENTIONS:END -->
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var crypto = require('crypto');
|
|
4
|
+
var zlib = require('zlib');
|
|
4
5
|
|
|
5
6
|
const MB$1 = 1024 * 1024;
|
|
6
7
|
const DEFAULT_POLICY = {
|
|
@@ -1053,12 +1054,15 @@ async function scanFiles(files, opts = {}) {
|
|
|
1053
1054
|
return out;
|
|
1054
1055
|
}
|
|
1055
1056
|
|
|
1057
|
+
const ARCHIVE_BOMB_DETECTED = "ARCHIVE_BOMB_DETECTED";
|
|
1058
|
+
const SIG_LFH = 0x04034b50;
|
|
1056
1059
|
const SIG_CEN = 0x02014b50;
|
|
1057
1060
|
const DEFAULTS = {
|
|
1058
1061
|
maxEntries: 1000,
|
|
1059
1062
|
maxTotalUncompressedBytes: 500 * 1024 * 1024,
|
|
1063
|
+
maxPerEntryUncompressedBytes: 100 * 1024 * 1024,
|
|
1060
1064
|
maxEntryNameLength: 255,
|
|
1061
|
-
maxCompressionRatio:
|
|
1065
|
+
maxCompressionRatio: 100,
|
|
1062
1066
|
eocdSearchWindow: 70000,
|
|
1063
1067
|
};
|
|
1064
1068
|
function r16(buf, off) {
|
|
@@ -1068,7 +1072,6 @@ function r32(buf, off) {
|
|
|
1068
1072
|
return buf.readUInt32LE(off);
|
|
1069
1073
|
}
|
|
1070
1074
|
function isZipLike(buf) {
|
|
1071
|
-
// local file header at start is common
|
|
1072
1075
|
return (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04);
|
|
1073
1076
|
}
|
|
1074
1077
|
function lastIndexOfEOCD(buf, window) {
|
|
@@ -1080,6 +1083,47 @@ function lastIndexOfEOCD(buf, window) {
|
|
|
1080
1083
|
function hasTraversal(name) {
|
|
1081
1084
|
return (name.includes("../") || name.includes("..\\") || name.startsWith("/") || /^[A-Za-z]:/.test(name));
|
|
1082
1085
|
}
|
|
1086
|
+
function makeBombError() {
|
|
1087
|
+
return Object.assign(new Error("Archive bomb detected: decompression limits exceeded"), {
|
|
1088
|
+
code: ARCHIVE_BOMB_DETECTED,
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Feeds `compressed` into a raw DEFLATE inflate stream and counts the actual
|
|
1093
|
+
* output bytes. Resolves with bombed=true and aborts early if any limit fires:
|
|
1094
|
+
* - decompressed bytes > maxPerEntry
|
|
1095
|
+
* - totalSoFar + decompressed > maxTotal
|
|
1096
|
+
* - decompressed / compressed > maxRatio (ratio measured on real bytes, not headers)
|
|
1097
|
+
*
|
|
1098
|
+
* Malformed DEFLATE is treated as safe (bombed=false, decompressed=0).
|
|
1099
|
+
*/
|
|
1100
|
+
function streamInflate(compressed, maxPerEntry, maxTotal, alreadySeen, maxRatio) {
|
|
1101
|
+
return new Promise((resolve) => {
|
|
1102
|
+
const inf = zlib.createInflateRaw();
|
|
1103
|
+
let out = 0;
|
|
1104
|
+
const compBytes = compressed.length;
|
|
1105
|
+
let done = false;
|
|
1106
|
+
const finish = (bombed) => {
|
|
1107
|
+
if (done)
|
|
1108
|
+
return;
|
|
1109
|
+
done = true;
|
|
1110
|
+
inf.destroy();
|
|
1111
|
+
resolve({ decompressed: out, bombed });
|
|
1112
|
+
};
|
|
1113
|
+
inf.on("data", (chunk) => {
|
|
1114
|
+
out += chunk.length;
|
|
1115
|
+
if (out > maxPerEntry ||
|
|
1116
|
+
alreadySeen + out > maxTotal ||
|
|
1117
|
+
(compBytes > 0 && out / compBytes > maxRatio)) {
|
|
1118
|
+
finish(true);
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
inf.on("end", () => finish(false));
|
|
1122
|
+
// Malformed DEFLATE stream → not a bomb, just corrupt
|
|
1123
|
+
inf.on("error", () => finish(false));
|
|
1124
|
+
inf.end(compressed);
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1083
1127
|
function createZipBombGuard(opts = {}) {
|
|
1084
1128
|
const cfg = { ...DEFAULTS, ...opts };
|
|
1085
1129
|
return {
|
|
@@ -1088,43 +1132,36 @@ function createZipBombGuard(opts = {}) {
|
|
|
1088
1132
|
const matches = [];
|
|
1089
1133
|
if (!isZipLike(buf))
|
|
1090
1134
|
return matches;
|
|
1091
|
-
//
|
|
1135
|
+
// ── 1. Locate EOCD ──────────────────────────────────────────────────────
|
|
1092
1136
|
const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
|
|
1093
1137
|
if (eocdPos < 0 || eocdPos + 22 > buf.length) {
|
|
1094
|
-
// ZIP but no EOCD — malformed or polyglot → suspicious
|
|
1095
1138
|
matches.push({ rule: "zip_eocd_not_found", severity: "medium" });
|
|
1096
1139
|
return matches;
|
|
1097
1140
|
}
|
|
1098
1141
|
const totalEntries = r16(buf, eocdPos + 10);
|
|
1099
1142
|
const cdSize = r32(buf, eocdPos + 12);
|
|
1100
1143
|
const cdOffset = r32(buf, eocdPos + 16);
|
|
1101
|
-
// Bounds check
|
|
1102
1144
|
if (cdOffset + cdSize > buf.length) {
|
|
1103
1145
|
matches.push({ rule: "zip_cd_out_of_bounds", severity: "medium" });
|
|
1104
1146
|
return matches;
|
|
1105
1147
|
}
|
|
1106
|
-
|
|
1148
|
+
const lfhIndex = [];
|
|
1107
1149
|
let ptr = cdOffset;
|
|
1108
1150
|
let seen = 0;
|
|
1109
|
-
let sumComp = 0;
|
|
1110
|
-
let sumUnc = 0;
|
|
1111
1151
|
while (ptr + 46 <= cdOffset + cdSize && seen < totalEntries) {
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
const compSize = r32(buf, ptr + 20);
|
|
1116
|
-
const uncSize = r32(buf, ptr + 24);
|
|
1152
|
+
if (r32(buf, ptr) !== SIG_CEN)
|
|
1153
|
+
break;
|
|
1154
|
+
const cdCompSize = r32(buf, ptr + 20);
|
|
1117
1155
|
const fnLen = r16(buf, ptr + 28);
|
|
1118
1156
|
const exLen = r16(buf, ptr + 30);
|
|
1119
1157
|
const cmLen = r16(buf, ptr + 32);
|
|
1120
|
-
const
|
|
1121
|
-
const nameEnd =
|
|
1158
|
+
const lfhOffset = r32(buf, ptr + 42);
|
|
1159
|
+
const nameEnd = ptr + 46 + fnLen;
|
|
1122
1160
|
if (nameEnd > buf.length)
|
|
1123
1161
|
break;
|
|
1124
|
-
const name = buf.toString("utf8",
|
|
1125
|
-
sumComp += compSize;
|
|
1126
|
-
sumUnc += uncSize;
|
|
1162
|
+
const name = buf.toString("utf8", ptr + 46, nameEnd);
|
|
1127
1163
|
seen++;
|
|
1164
|
+
lfhIndex.push({ lfhOffset, cdCompSize });
|
|
1128
1165
|
if (name.length > cfg.maxEntryNameLength) {
|
|
1129
1166
|
matches.push({
|
|
1130
1167
|
rule: "zip_entry_name_too_long",
|
|
@@ -1135,48 +1172,67 @@ function createZipBombGuard(opts = {}) {
|
|
|
1135
1172
|
if (hasTraversal(name)) {
|
|
1136
1173
|
matches.push({ rule: "zip_path_traversal_entry", severity: "medium", meta: { name } });
|
|
1137
1174
|
}
|
|
1138
|
-
// move to next entry
|
|
1139
1175
|
ptr = nameEnd + exLen + cmLen;
|
|
1140
1176
|
}
|
|
1141
1177
|
if (seen !== totalEntries) {
|
|
1142
|
-
// central dir truncated/odd, still report what we found
|
|
1143
1178
|
matches.push({
|
|
1144
1179
|
rule: "zip_cd_truncated",
|
|
1145
1180
|
severity: "medium",
|
|
1146
1181
|
meta: { seen, totalEntries },
|
|
1147
1182
|
});
|
|
1148
1183
|
}
|
|
1149
|
-
// Heuristics thresholds
|
|
1150
1184
|
if (seen > cfg.maxEntries) {
|
|
1151
1185
|
matches.push({
|
|
1152
1186
|
rule: "zip_too_many_entries",
|
|
1153
1187
|
severity: "medium",
|
|
1154
1188
|
meta: { seen, limit: cfg.maxEntries },
|
|
1155
1189
|
});
|
|
1190
|
+
// Return early — decompressing thousands of entries would be a DoS vector
|
|
1191
|
+
return matches;
|
|
1156
1192
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
const
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1193
|
+
// ── 3. True streaming decompression — archive bomb detection ────────────
|
|
1194
|
+
// For every DEFLATE entry (method=8) we feed the raw compressed bytes into
|
|
1195
|
+
// zlib.createInflateRaw() and count the bytes that come OUT. We abort the
|
|
1196
|
+
// moment any limit fires; we NEVER trust the header-reported uncompressed
|
|
1197
|
+
// size for the ratio decision.
|
|
1198
|
+
//
|
|
1199
|
+
// For STORED entries (method=0) compressed == uncompressed by spec, so the
|
|
1200
|
+
// byte count is immediate.
|
|
1201
|
+
let totalDecompressed = 0;
|
|
1202
|
+
for (const { lfhOffset, cdCompSize } of lfhIndex) {
|
|
1203
|
+
if (lfhOffset + 30 > buf.length)
|
|
1204
|
+
continue;
|
|
1205
|
+
if (r32(buf, lfhOffset) !== SIG_LFH)
|
|
1206
|
+
continue;
|
|
1207
|
+
const gpbf = r16(buf, lfhOffset + 6);
|
|
1208
|
+
const method = r16(buf, lfhOffset + 8);
|
|
1209
|
+
let lfhCompSz = r32(buf, lfhOffset + 18);
|
|
1210
|
+
const fnLen = r16(buf, lfhOffset + 26);
|
|
1211
|
+
const exLen = r16(buf, lfhOffset + 28);
|
|
1212
|
+
const dataOff = lfhOffset + 30 + fnLen + exLen;
|
|
1213
|
+
// If the data-descriptor flag is set (GPBF bit 3), the LFH sizes are 0.
|
|
1214
|
+
// Fall back to the CD size purely for navigation — not for bomb detection.
|
|
1215
|
+
if ((gpbf & 0x08) !== 0 && lfhCompSz === 0) {
|
|
1216
|
+
lfhCompSz = cdCompSize;
|
|
1217
|
+
}
|
|
1218
|
+
if (dataOff + lfhCompSz > buf.length)
|
|
1219
|
+
continue; // truncated entry — skip
|
|
1220
|
+
if (method === 8 /* DEFLATE */) {
|
|
1221
|
+
const compressed = buf.slice(dataOff, dataOff + lfhCompSz);
|
|
1222
|
+
const { decompressed, bombed } = await streamInflate(compressed, cfg.maxPerEntryUncompressedBytes, cfg.maxTotalUncompressedBytes, totalDecompressed, cfg.maxCompressionRatio);
|
|
1223
|
+
if (bombed)
|
|
1224
|
+
throw makeBombError();
|
|
1225
|
+
totalDecompressed += decompressed;
|
|
1226
|
+
}
|
|
1227
|
+
else if (method === 0 /* STORED */) {
|
|
1228
|
+
// Compressed == uncompressed for stored entries
|
|
1229
|
+
if (lfhCompSz > cfg.maxPerEntryUncompressedBytes)
|
|
1230
|
+
throw makeBombError();
|
|
1231
|
+
totalDecompressed += lfhCompSz;
|
|
1232
|
+
if (totalDecompressed > cfg.maxTotalUncompressedBytes)
|
|
1233
|
+
throw makeBombError();
|
|
1179
1234
|
}
|
|
1235
|
+
// Other methods (bzip2=12, lzma=14, zstd=93, …) — skip; no built-in support
|
|
1180
1236
|
}
|
|
1181
1237
|
return matches;
|
|
1182
1238
|
},
|