@blamejs/core 0.12.6 → 0.12.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.
- package/CHANGELOG.md +2 -0
- package/README.md +1 -1
- package/index.js +15 -1
- package/lib/archive-adapters.js +629 -0
- package/lib/archive-read.js +781 -0
- package/lib/archive.js +12 -0
- package/lib/backup/index.js +245 -0
- package/lib/guard-archive.js +140 -0
- package/lib/guard-filename.js +205 -0
- package/lib/safe-archive.js +275 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/archive.js
CHANGED
|
@@ -538,9 +538,21 @@ function zip() {
|
|
|
538
538
|
};
|
|
539
539
|
}
|
|
540
540
|
|
|
541
|
+
// Read primitive — random-access ZIP reader composes the same wire-
|
|
542
|
+
// format constants as the write side. Lives in a sibling file to keep
|
|
543
|
+
// this module under the line-budget the @primitive validator + the
|
|
544
|
+
// codebase-patterns "single-concern file" pattern prefer.
|
|
545
|
+
var archiveRead = require("./archive-read");
|
|
546
|
+
|
|
541
547
|
module.exports = {
|
|
542
548
|
zip: zip,
|
|
543
549
|
ArchiveError: ArchiveError,
|
|
550
|
+
read: {
|
|
551
|
+
zip: archiveRead.zip,
|
|
552
|
+
ArchiveReadError: archiveRead.ArchiveReadError,
|
|
553
|
+
DEFAULT_BOMB_POLICY: archiveRead.DEFAULT_BOMB_POLICY,
|
|
554
|
+
DEFAULT_ENTRY_TYPE_POLICY: archiveRead.DEFAULT_ENTRY_TYPE_POLICY,
|
|
555
|
+
},
|
|
544
556
|
// Test-only export — operators don't call this; it's here for unit-testing
|
|
545
557
|
// the CRC implementation against known vectors.
|
|
546
558
|
_crc32ForTest: _crc32,
|
package/lib/backup/index.js
CHANGED
|
@@ -992,6 +992,7 @@ function runInWorker(opts) {
|
|
|
992
992
|
module.exports = {
|
|
993
993
|
create: create,
|
|
994
994
|
diskStorage: diskStorage,
|
|
995
|
+
bundleAdapterStorage: bundleAdapterStorage,
|
|
995
996
|
recommendedFiles: recommendedFiles,
|
|
996
997
|
runInWorker: runInWorker,
|
|
997
998
|
verifyManifestSignature: verifyManifestSignature,
|
|
@@ -999,3 +1000,247 @@ module.exports = {
|
|
|
999
1000
|
BackupError: BackupError,
|
|
1000
1001
|
BUNDLE_ID_RE: BUNDLE_ID_RE,
|
|
1001
1002
|
};
|
|
1003
|
+
|
|
1004
|
+
// ---- v0.12.7: bundleAdapterStorage ---------------------------------------
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* @primitive b.backup.bundleAdapterStorage
|
|
1008
|
+
* @signature b.backup.bundleAdapterStorage(opts)
|
|
1009
|
+
* @since 0.12.7
|
|
1010
|
+
* @status stable
|
|
1011
|
+
* @compliance hipaa, pci-dss, gdpr, soc2
|
|
1012
|
+
* @related b.backup.diskStorage, b.backup.create
|
|
1013
|
+
*
|
|
1014
|
+
* Adapter-driven storage backend. Wraps the bundle directory's file
|
|
1015
|
+
* tree into per-file key-value pairs routed through an operator-
|
|
1016
|
+
* supplied byte-store adapter so backup bundles can land anywhere
|
|
1017
|
+
* that exposes the contract (local fs is the v0.12.7 default; tar
|
|
1018
|
+
* folding lands v0.12.8, tar.gz v0.12.9, S3/MinIO/Azure/GCS
|
|
1019
|
+
* objectStore v0.12.11).
|
|
1020
|
+
*
|
|
1021
|
+
* The adapter contract (small surface; an `fs` implementation is the
|
|
1022
|
+
* default + ships in `lib/backup/_adapter-fs.js`):
|
|
1023
|
+
*
|
|
1024
|
+
* adapter.writeFile(key, bytes): Promise<void>
|
|
1025
|
+
* adapter.readFile(key): Promise<Buffer>
|
|
1026
|
+
* adapter.listKeys(prefix): Promise<string[]>
|
|
1027
|
+
* adapter.deleteKey(key): Promise<void>
|
|
1028
|
+
* adapter.hasKey(key): Promise<boolean>
|
|
1029
|
+
*
|
|
1030
|
+
* Keys are `<bundleId>/<relative/path/within/bundle>`. Operators
|
|
1031
|
+
* pointing at an objectStore implementation pass an adapter that
|
|
1032
|
+
* routes keys to S3 paths; pointing at an HTTP-backed store, ditto.
|
|
1033
|
+
*
|
|
1034
|
+
* @opts
|
|
1035
|
+
* adapter: { writeFile, readFile, listKeys, deleteKey, hasKey },
|
|
1036
|
+
*
|
|
1037
|
+
* @example
|
|
1038
|
+
* var storage = b.backup.bundleAdapterStorage({
|
|
1039
|
+
* adapter: b.backup.bundleAdapterStorage.fsAdapter({ root: "/var/backups" }),
|
|
1040
|
+
* });
|
|
1041
|
+
* storage.name; // → "adapter"
|
|
1042
|
+
* typeof storage.writeBundle; // → "function"
|
|
1043
|
+
*/
|
|
1044
|
+
function bundleAdapterStorage(opts) {
|
|
1045
|
+
opts = opts || {};
|
|
1046
|
+
if (!opts.adapter || typeof opts.adapter !== "object") {
|
|
1047
|
+
throw new BackupError("backup/bad-adapter",
|
|
1048
|
+
"bundleAdapterStorage: opts.adapter is required (an object with writeFile/readFile/listKeys/deleteKey/hasKey)");
|
|
1049
|
+
}
|
|
1050
|
+
var adapter = opts.adapter;
|
|
1051
|
+
var required = ["writeFile", "readFile", "listKeys", "deleteKey", "hasKey"];
|
|
1052
|
+
for (var i = 0; i < required.length; i += 1) {
|
|
1053
|
+
if (typeof adapter[required[i]] !== "function") {
|
|
1054
|
+
throw new BackupError("backup/bad-adapter",
|
|
1055
|
+
"bundleAdapterStorage: adapter missing method '" + required[i] + "'");
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function _ensureBundleId(bundleId) {
|
|
1060
|
+
if (!_isValidBundleId(bundleId)) {
|
|
1061
|
+
throw new BackupError("backup/bad-bundle-id",
|
|
1062
|
+
"bundleId must match the framework's timestamp+suffix format");
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function _walkDirSync(rootDir, out, rel) {
|
|
1067
|
+
rel = rel || "";
|
|
1068
|
+
var entries = nodeFs.readdirSync(nodePath.join(rootDir, rel), { withFileTypes: true });
|
|
1069
|
+
for (var i = 0; i < entries.length; i += 1) {
|
|
1070
|
+
var name = entries[i].name;
|
|
1071
|
+
var relPath = rel ? (rel + "/" + name) : name;
|
|
1072
|
+
if (entries[i].isDirectory()) {
|
|
1073
|
+
_walkDirSync(rootDir, out, relPath);
|
|
1074
|
+
} else if (entries[i].isFile()) {
|
|
1075
|
+
out.push(relPath);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return out;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
return {
|
|
1082
|
+
name: "adapter",
|
|
1083
|
+
async writeBundle(bundleId, sourceDir) {
|
|
1084
|
+
_ensureBundleId(bundleId);
|
|
1085
|
+
if (!nodeFs.existsSync(sourceDir)) {
|
|
1086
|
+
throw new BackupError("backup/no-source",
|
|
1087
|
+
"writeBundle: sourceDir does not exist: " + sourceDir);
|
|
1088
|
+
}
|
|
1089
|
+
var alreadyHas = await adapter.hasKey(bundleId + "/manifest.json");
|
|
1090
|
+
if (alreadyHas) {
|
|
1091
|
+
throw new BackupError("backup/bundle-exists",
|
|
1092
|
+
"writeBundle: bundle '" + bundleId + "' already exists in storage");
|
|
1093
|
+
}
|
|
1094
|
+
var relPaths = _walkDirSync(sourceDir, []);
|
|
1095
|
+
for (var i = 0; i < relPaths.length; i += 1) {
|
|
1096
|
+
var rel = relPaths[i];
|
|
1097
|
+
var bytes = nodeFs.readFileSync(nodePath.join(sourceDir, rel));
|
|
1098
|
+
await adapter.writeFile(bundleId + "/" + rel, bytes);
|
|
1099
|
+
}
|
|
1100
|
+
},
|
|
1101
|
+
async readBundle(bundleId, destDir) {
|
|
1102
|
+
_ensureBundleId(bundleId);
|
|
1103
|
+
if (nodeFs.existsSync(destDir)) {
|
|
1104
|
+
throw new BackupError("backup/dest-exists",
|
|
1105
|
+
"readBundle: destDir already exists: " + destDir);
|
|
1106
|
+
}
|
|
1107
|
+
var keys = await adapter.listKeys(bundleId + "/");
|
|
1108
|
+
if (keys.length === 0) {
|
|
1109
|
+
throw new BackupError("backup/bundle-not-found",
|
|
1110
|
+
"readBundle: '" + bundleId + "' not in storage");
|
|
1111
|
+
}
|
|
1112
|
+
atomicFile.ensureDir(destDir);
|
|
1113
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
1114
|
+
var key = keys[i];
|
|
1115
|
+
var prefix = bundleId + "/";
|
|
1116
|
+
if (key.indexOf(prefix) !== 0) continue;
|
|
1117
|
+
var rel = key.slice(prefix.length);
|
|
1118
|
+
// Path-safety: rel must not escape destDir. Reuse the
|
|
1119
|
+
// verifyExtractionPath dual-check primitive when the dest
|
|
1120
|
+
// already exists (writeable directory just created).
|
|
1121
|
+
var destPath = nodePath.join(destDir, rel);
|
|
1122
|
+
var resolvedDest = nodePath.resolve(destPath);
|
|
1123
|
+
var resolvedRoot = nodePath.resolve(destDir);
|
|
1124
|
+
if (resolvedDest !== resolvedRoot &&
|
|
1125
|
+
resolvedDest.indexOf(resolvedRoot + nodePath.sep) !== 0) {
|
|
1126
|
+
throw new BackupError("backup/bad-key",
|
|
1127
|
+
"readBundle: storage key " + JSON.stringify(rel) +
|
|
1128
|
+
" escapes destDir " + JSON.stringify(destDir));
|
|
1129
|
+
}
|
|
1130
|
+
atomicFile.ensureDir(nodePath.dirname(destPath));
|
|
1131
|
+
var bytes = await adapter.readFile(key);
|
|
1132
|
+
nodeFs.writeFileSync(destPath, bytes);
|
|
1133
|
+
}
|
|
1134
|
+
},
|
|
1135
|
+
async listBundles() {
|
|
1136
|
+
// Get every key, partition by bundleId prefix, return sorted.
|
|
1137
|
+
var allKeys = await adapter.listKeys("");
|
|
1138
|
+
var byBundle = new Map();
|
|
1139
|
+
for (var i = 0; i < allKeys.length; i += 1) {
|
|
1140
|
+
var key = allKeys[i];
|
|
1141
|
+
var slash = key.indexOf("/");
|
|
1142
|
+
if (slash <= 0) continue;
|
|
1143
|
+
var bid = key.slice(0, slash);
|
|
1144
|
+
if (!_isValidBundleId(bid)) continue;
|
|
1145
|
+
var stats = byBundle.get(bid);
|
|
1146
|
+
if (!stats) {
|
|
1147
|
+
stats = { count: 0, size: 0 };
|
|
1148
|
+
byBundle.set(bid, stats);
|
|
1149
|
+
}
|
|
1150
|
+
stats.count += 1;
|
|
1151
|
+
// Note: size is approximate — we'd need a stat-per-key here,
|
|
1152
|
+
// and many adapter implementations don't expose a fast stat
|
|
1153
|
+
// primitive. listBundles is best-effort; operators wanting
|
|
1154
|
+
// exact size do their own walk.
|
|
1155
|
+
}
|
|
1156
|
+
var out = [];
|
|
1157
|
+
var entries = Array.from(byBundle.entries());
|
|
1158
|
+
for (var j = 0; j < entries.length; j += 1) {
|
|
1159
|
+
var bidJ = entries[j][0];
|
|
1160
|
+
out.push({
|
|
1161
|
+
bundleId: bidJ,
|
|
1162
|
+
createdAt: null, // adapter may not expose mtime
|
|
1163
|
+
size: null, // best-effort
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
out.sort(function (a, b) { return a.bundleId < b.bundleId ? 1 : -1; });
|
|
1167
|
+
return out;
|
|
1168
|
+
},
|
|
1169
|
+
async deleteBundle(bundleId) {
|
|
1170
|
+
_ensureBundleId(bundleId);
|
|
1171
|
+
var keys = await adapter.listKeys(bundleId + "/");
|
|
1172
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
1173
|
+
await adapter.deleteKey(keys[i]);
|
|
1174
|
+
}
|
|
1175
|
+
},
|
|
1176
|
+
async hasBundle(bundleId) {
|
|
1177
|
+
_ensureBundleId(bundleId);
|
|
1178
|
+
return adapter.hasKey(bundleId + "/manifest.json");
|
|
1179
|
+
},
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// fsAdapter — default adapter for bundleAdapterStorage backed by the
|
|
1184
|
+
// local filesystem. Provides the same on-disk layout as diskStorage
|
|
1185
|
+
// (bundle directories under a root path) but via the adapter contract
|
|
1186
|
+
// so v0.12.8+ can swap the implementation transparently.
|
|
1187
|
+
bundleAdapterStorage.fsAdapter = function (fsOpts) {
|
|
1188
|
+
fsOpts = fsOpts || {};
|
|
1189
|
+
validateOpts.requireNonEmptyString(fsOpts.root,
|
|
1190
|
+
"bundleAdapterStorage.fsAdapter: opts.root", BackupError, "backup/no-storage-root");
|
|
1191
|
+
var root = fsOpts.root;
|
|
1192
|
+
atomicFile.ensureDir(root);
|
|
1193
|
+
|
|
1194
|
+
function _keyPath(key) {
|
|
1195
|
+
// Refuse keys with traversal segments — defense in depth even
|
|
1196
|
+
// though the storage layer also checks.
|
|
1197
|
+
if (key.indexOf("..") !== -1 || key.indexOf("\0") !== -1) {
|
|
1198
|
+
throw new BackupError("backup/bad-key",
|
|
1199
|
+
"fsAdapter: key contains invalid characters: " + JSON.stringify(key));
|
|
1200
|
+
}
|
|
1201
|
+
return nodePath.join(root, key);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
return {
|
|
1205
|
+
async writeFile(key, bytes) {
|
|
1206
|
+
var path = _keyPath(key);
|
|
1207
|
+
atomicFile.ensureDir(nodePath.dirname(path));
|
|
1208
|
+
nodeFs.writeFileSync(path, bytes);
|
|
1209
|
+
},
|
|
1210
|
+
async readFile(key) {
|
|
1211
|
+
var path = _keyPath(key);
|
|
1212
|
+
if (!nodeFs.existsSync(path)) {
|
|
1213
|
+
throw new BackupError("backup/no-key", "fsAdapter: key not found: " + JSON.stringify(key));
|
|
1214
|
+
}
|
|
1215
|
+
return nodeFs.readFileSync(path);
|
|
1216
|
+
},
|
|
1217
|
+
async listKeys(prefix) {
|
|
1218
|
+
var out = [];
|
|
1219
|
+
if (!nodeFs.existsSync(root)) return out;
|
|
1220
|
+
function _walk(rel) {
|
|
1221
|
+
var entries = nodeFs.readdirSync(nodePath.join(root, rel || "."), { withFileTypes: true });
|
|
1222
|
+
for (var i = 0; i < entries.length; i += 1) {
|
|
1223
|
+
var name = entries[i].name;
|
|
1224
|
+
var nextRel = rel ? (rel + "/" + name) : name;
|
|
1225
|
+
if (entries[i].isDirectory()) {
|
|
1226
|
+
_walk(nextRel);
|
|
1227
|
+
} else if (entries[i].isFile()) {
|
|
1228
|
+
if (!prefix || nextRel.indexOf(prefix) === 0) {
|
|
1229
|
+
out.push(nextRel);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
_walk("");
|
|
1235
|
+
return out;
|
|
1236
|
+
},
|
|
1237
|
+
async deleteKey(key) {
|
|
1238
|
+
var path = _keyPath(key);
|
|
1239
|
+
try { nodeFs.rmSync(path); } catch (_e) { /* drop-silent — key already gone */ }
|
|
1240
|
+
},
|
|
1241
|
+
async hasKey(key) {
|
|
1242
|
+
try { return nodeFs.existsSync(_keyPath(key)); }
|
|
1243
|
+
catch (_e) { return false; }
|
|
1244
|
+
},
|
|
1245
|
+
};
|
|
1246
|
+
};
|
package/lib/guard-archive.js
CHANGED
|
@@ -71,6 +71,7 @@ var gateContract = require("./gate-contract");
|
|
|
71
71
|
var C = require("./constants");
|
|
72
72
|
var numericBounds = require("./numeric-bounds");
|
|
73
73
|
var guardFilename = require("./guard-filename");
|
|
74
|
+
var archiveRead = lazyRequire(function () { return require("./archive-read"); });
|
|
74
75
|
var { GuardArchiveError } = require("./framework-error");
|
|
75
76
|
|
|
76
77
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
@@ -898,4 +899,143 @@ module.exports = {
|
|
|
898
899
|
ARCHIVE_EXTENSIONS: ARCHIVE_EXTENSIONS,
|
|
899
900
|
MAGIC_SIGNATURES: MAGIC_SIGNATURES,
|
|
900
901
|
GuardArchiveError: GuardArchiveError,
|
|
902
|
+
inspect: inspect,
|
|
903
|
+
zipBombPolicy: zipBombPolicy,
|
|
904
|
+
entryTypePolicy: entryTypePolicy,
|
|
901
905
|
};
|
|
906
|
+
|
|
907
|
+
// ---- v0.12.7 extensions ---------------------------------------------------
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* @primitive b.guardArchive.inspect
|
|
911
|
+
* @signature b.guardArchive.inspect(adapter, opts?)
|
|
912
|
+
* @since 0.12.7
|
|
913
|
+
* @status stable
|
|
914
|
+
* @related b.archive.read.zip, b.guardArchive.validateEntries
|
|
915
|
+
*
|
|
916
|
+
* Bridge primitive: runs `b.archive.read.zip(adapter).inspect()` to
|
|
917
|
+
* enumerate the entry list (no decompression), then hands the list to
|
|
918
|
+
* `validateEntries` for the full posture-aware gate. Returns
|
|
919
|
+
* `{ entries, issues, decisions }` so the caller decides whether to
|
|
920
|
+
* proceed.
|
|
921
|
+
*
|
|
922
|
+
* Operators using the lower-level read primitive directly call this
|
|
923
|
+
* to combine the metadata pass with the guard pass; `b.safeArchive.
|
|
924
|
+
* extract` does the same composition inline under the hood.
|
|
925
|
+
*
|
|
926
|
+
* @opts
|
|
927
|
+
* profile: "strict" | "balanced" | "permissive" | "hipaa" | ...,
|
|
928
|
+
* format: "zip" (v0.12.7 — tar v0.12.8, gz v0.12.9),
|
|
929
|
+
* audit: b.audit,
|
|
930
|
+
*
|
|
931
|
+
* @example
|
|
932
|
+
* var adapter = b.archive.adapters.fs("/var/uploads/payload.zip");
|
|
933
|
+
* var summary = await b.guardArchive.inspect(adapter, { profile: "strict" });
|
|
934
|
+
* if (summary.issues.length > 0) refuse(summary.issues);
|
|
935
|
+
*/
|
|
936
|
+
async function inspect(adapter, opts) {
|
|
937
|
+
opts = opts || {};
|
|
938
|
+
var format = opts.format || "zip";
|
|
939
|
+
if (format !== "zip") {
|
|
940
|
+
throw new GuardArchiveError("archive/format-unsupported",
|
|
941
|
+
"guardArchive.inspect: format=" + JSON.stringify(format) +
|
|
942
|
+
" — v0.12.7 ships ZIP only (tar v0.12.8, gz v0.12.9)");
|
|
943
|
+
}
|
|
944
|
+
var reader = archiveRead().zip(adapter, { audit: opts.audit });
|
|
945
|
+
var rawEntries = await reader.inspect();
|
|
946
|
+
// Project the read-primitive's entry shape into validateEntries'
|
|
947
|
+
// expected `{ name, size, compressedSize, isSymlink, ... }` shape.
|
|
948
|
+
var guardEntries = rawEntries.map(function (e) {
|
|
949
|
+
return {
|
|
950
|
+
name: e.name,
|
|
951
|
+
size: e.size,
|
|
952
|
+
compressedSize: e.compressedSize,
|
|
953
|
+
isSymlink: e.entryType === "symlink",
|
|
954
|
+
isHardlink: false,
|
|
955
|
+
linkTarget: null,
|
|
956
|
+
isDirectory: e.entryType === "directory",
|
|
957
|
+
isEncrypted: e.isEncrypted,
|
|
958
|
+
attrs: { externalAttrs: e.externalAttrs },
|
|
959
|
+
};
|
|
960
|
+
});
|
|
961
|
+
var profile = opts.profile || "balanced";
|
|
962
|
+
var result = validateEntries(guardEntries, { profile: profile });
|
|
963
|
+
return {
|
|
964
|
+
entries: rawEntries,
|
|
965
|
+
issues: (result && result.issues) || [],
|
|
966
|
+
decisions: (result && result.decisions) || {},
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* @primitive b.guardArchive.zipBombPolicy
|
|
972
|
+
* @signature b.guardArchive.zipBombPolicy(opts)
|
|
973
|
+
* @since 0.12.7
|
|
974
|
+
* @status stable
|
|
975
|
+
* @related b.archive.read.zip, b.safeArchive.extract
|
|
976
|
+
*
|
|
977
|
+
* Policy-object builder for decompression-bomb caps. Operators
|
|
978
|
+
* declare the cap set once + reuse it across `b.archive.read.zip` /
|
|
979
|
+
* `b.safeArchive.extract` call sites. Defaults match the cap shape
|
|
980
|
+
* in `lib/archive-read.js` `DEFAULT_BOMB_POLICY`.
|
|
981
|
+
*
|
|
982
|
+
* @opts
|
|
983
|
+
* maxEntries: 65535,
|
|
984
|
+
* maxEntryDecompressedBytes: 128 * MiB,
|
|
985
|
+
* maxTotalDecompressedBytes: 4 * GiB,
|
|
986
|
+
* maxExpansionRatio: 100,
|
|
987
|
+
*
|
|
988
|
+
* @example
|
|
989
|
+
* var policy = b.guardArchive.zipBombPolicy({
|
|
990
|
+
* maxTotalDecompressedBytes: 256 * 1024 * 1024,
|
|
991
|
+
* maxExpansionRatio: 50,
|
|
992
|
+
* });
|
|
993
|
+
* await b.safeArchive.extract({ source, destination, bombPolicy: policy });
|
|
994
|
+
*/
|
|
995
|
+
function zipBombPolicy(opts) {
|
|
996
|
+
opts = opts || {};
|
|
997
|
+
return Object.freeze({
|
|
998
|
+
maxEntries: opts.maxEntries || 65535,
|
|
999
|
+
maxEntryDecompressedBytes: opts.maxEntryDecompressedBytes || C.BYTES.mib(128),
|
|
1000
|
+
maxTotalDecompressedBytes: opts.maxTotalDecompressedBytes || C.BYTES.gib(4),
|
|
1001
|
+
maxExpansionRatio: opts.maxExpansionRatio || 100,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* @primitive b.guardArchive.entryTypePolicy
|
|
1007
|
+
* @signature b.guardArchive.entryTypePolicy(opts)
|
|
1008
|
+
* @since 0.12.7
|
|
1009
|
+
* @status stable
|
|
1010
|
+
* @related b.archive.read.zip, b.safeArchive.extract
|
|
1011
|
+
*
|
|
1012
|
+
* Policy-object builder for entry-type allowlist. Defaults refuse
|
|
1013
|
+
* every "interesting" entry type (symlink / hardlink / device / fifo
|
|
1014
|
+
* / socket); operators opt in per-type and route through the
|
|
1015
|
+
* additional realpath-on-target check in `b.guardFilename.
|
|
1016
|
+
* verifyExtractionPath`.
|
|
1017
|
+
*
|
|
1018
|
+
* Symlinks + hardlinks under default settings are refused
|
|
1019
|
+
* unconditionally — CVE-2025-11001 / 11002 / 26960 class.
|
|
1020
|
+
*
|
|
1021
|
+
* @opts
|
|
1022
|
+
* symlinks: false,
|
|
1023
|
+
* hardlinks: false,
|
|
1024
|
+
* devices: false,
|
|
1025
|
+
* fifos: false,
|
|
1026
|
+
* sockets: false,
|
|
1027
|
+
*
|
|
1028
|
+
* @example
|
|
1029
|
+
* var policy = b.guardArchive.entryTypePolicy({ symlinks: true });
|
|
1030
|
+
* await b.safeArchive.extract({ source, destination, entryTypePolicy: policy });
|
|
1031
|
+
*/
|
|
1032
|
+
function entryTypePolicy(opts) {
|
|
1033
|
+
opts = opts || {};
|
|
1034
|
+
return Object.freeze({
|
|
1035
|
+
symlinks: opts.symlinks === true,
|
|
1036
|
+
hardlinks: opts.hardlinks === true,
|
|
1037
|
+
devices: opts.devices === true,
|
|
1038
|
+
fifos: opts.fifos === true,
|
|
1039
|
+
sockets: opts.sockets === true,
|
|
1040
|
+
});
|
|
1041
|
+
}
|
package/lib/guard-filename.js
CHANGED
|
@@ -923,6 +923,210 @@ var _filenameRulePacks = gateContract.makeRulePackLoader(GuardFilenameError, "fi
|
|
|
923
923
|
*/
|
|
924
924
|
var loadRulePack = _filenameRulePacks.load;
|
|
925
925
|
|
|
926
|
+
// ---- verifyExtractionPath -------------------------------------------------
|
|
927
|
+
|
|
928
|
+
var nodePath = require("node:path");
|
|
929
|
+
var nodeFs = require("node:fs");
|
|
930
|
+
|
|
931
|
+
// CVE-2025-4517 PATH_MAX threshold — Python's tarfile filter relied on
|
|
932
|
+
// os.path.realpath which silently stops resolving symlinks once the
|
|
933
|
+
// resolved path exceeds PATH_MAX (4096 on Linux). The kernel keeps
|
|
934
|
+
// resolving past that, so the filter's safety check + the kernel's
|
|
935
|
+
// extraction diverge. We refuse paths whose pre-resolve length already
|
|
936
|
+
// exceeds PATH_MAX so the operator's realpath behavior is never the
|
|
937
|
+
// gating factor.
|
|
938
|
+
var PATH_MAX_BYTES = 4096;
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* @primitive b.guardFilename.verifyExtractionPath
|
|
942
|
+
* @signature b.guardFilename.verifyExtractionPath(entryName, extractionRoot, opts?)
|
|
943
|
+
* @since 0.12.7
|
|
944
|
+
* @status stable
|
|
945
|
+
* @compliance hipaa, pci-dss, gdpr, soc2
|
|
946
|
+
* @related b.guardArchive.checkExtractionPath, b.guardArchive.validateEntries, b.archive.read.zip
|
|
947
|
+
*
|
|
948
|
+
* Dual-check extraction path safety: string-check (refuses `..`, leading
|
|
949
|
+
* `/` / `\\`, drive-letter prefix, null byte, PATH_MAX overflow) followed
|
|
950
|
+
* by `fs.realpath` agreement check (the resolved path on disk must
|
|
951
|
+
* land inside the realpath of the extraction root). Returns the
|
|
952
|
+
* resolved absolute path on success; throws `GuardFilenameError` on
|
|
953
|
+
* any refusal.
|
|
954
|
+
*
|
|
955
|
+
* Companion to `b.guardArchive.checkExtractionPath` (the string-only
|
|
956
|
+
* portable gate the guard-archive primitive keeps fs-free for use as
|
|
957
|
+
* a posture cascade member). `verifyExtractionPath` deliberately
|
|
958
|
+
* couples to `node:fs` — the deeper realpath check defends the
|
|
959
|
+
* CVE-2025-4517 PATH_MAX TOCTOU class where the operator's path
|
|
960
|
+
* resolution and the kernel's diverge silently past PATH_MAX.
|
|
961
|
+
*
|
|
962
|
+
* `b.archive.read.zip.extract` composes this on every entry; operators
|
|
963
|
+
* extracting via the safeArchive orchestrator never call it directly.
|
|
964
|
+
* Operators rolling their own extract loop call it per entry.
|
|
965
|
+
*
|
|
966
|
+
* @opts
|
|
967
|
+
* followSymlinks: boolean, // default false — symlink in the
|
|
968
|
+
* // resolved path refuses unless set
|
|
969
|
+
*
|
|
970
|
+
* @example
|
|
971
|
+
* var resolved = b.guardFilename.verifyExtractionPath(
|
|
972
|
+
* "docs/readme.txt",
|
|
973
|
+
* "/var/quarantine"
|
|
974
|
+
* );
|
|
975
|
+
* // → "/var/quarantine/docs/readme.txt"
|
|
976
|
+
*
|
|
977
|
+
* // ../ refuses
|
|
978
|
+
* b.guardFilename.verifyExtractionPath("../etc/passwd", "/var/quarantine");
|
|
979
|
+
* // throws GuardFilenameError("filename.extraction-traversal")
|
|
980
|
+
*
|
|
981
|
+
* // PATH_MAX-overflow refuses BEFORE realpath truncation hits
|
|
982
|
+
* b.guardFilename.verifyExtractionPath(longName, "/var/quarantine");
|
|
983
|
+
* // throws GuardFilenameError("filename.extraction-path-max")
|
|
984
|
+
*/
|
|
985
|
+
function verifyExtractionPath(entryName, extractionRoot, opts) {
|
|
986
|
+
opts = opts || {};
|
|
987
|
+
if (typeof entryName !== "string" || entryName.length === 0) {
|
|
988
|
+
throw new GuardFilenameError("filename.extraction-empty",
|
|
989
|
+
"verifyExtractionPath: entryName must be non-empty string");
|
|
990
|
+
}
|
|
991
|
+
if (typeof extractionRoot !== "string" || extractionRoot.length === 0) {
|
|
992
|
+
throw new GuardFilenameError("filename.extraction-bad-root",
|
|
993
|
+
"verifyExtractionPath: extractionRoot must be non-empty string");
|
|
994
|
+
}
|
|
995
|
+
// PATH_MAX defense — refuse oversize names BEFORE any path operation
|
|
996
|
+
// (mkdir / realpath / open) can truncate silently.
|
|
997
|
+
if (entryName.length > PATH_MAX_BYTES) {
|
|
998
|
+
throw new GuardFilenameError("filename.extraction-path-max",
|
|
999
|
+
"verifyExtractionPath: entryName length " + entryName.length +
|
|
1000
|
+
" exceeds PATH_MAX=" + PATH_MAX_BYTES +
|
|
1001
|
+
" (CVE-2025-4517 class — operator realpath truncation defense)");
|
|
1002
|
+
}
|
|
1003
|
+
// String-check first — these checks are portable + don't touch fs.
|
|
1004
|
+
// Null byte — POSIX path APIs treat it as a string terminator.
|
|
1005
|
+
if (entryName.indexOf("\u0000") !== -1) {
|
|
1006
|
+
throw new GuardFilenameError("filename.extraction-null-byte",
|
|
1007
|
+
"verifyExtractionPath: entryName contains null byte");
|
|
1008
|
+
}
|
|
1009
|
+
// Normalize separators so the `..` walk catches Windows-style too.
|
|
1010
|
+
var normalized = entryName.replace(/\\/g, "/");
|
|
1011
|
+
// Leading-slash absolute path refuses.
|
|
1012
|
+
if (normalized.length > 0 && normalized[0] === "/") {
|
|
1013
|
+
throw new GuardFilenameError("filename.extraction-absolute",
|
|
1014
|
+
"verifyExtractionPath: entryName is an absolute path");
|
|
1015
|
+
}
|
|
1016
|
+
// Drive-letter prefix (Windows) refuses.
|
|
1017
|
+
if (/^[A-Za-z]:[/\\]/.test(entryName)) {
|
|
1018
|
+
throw new GuardFilenameError("filename.extraction-drive-prefix",
|
|
1019
|
+
"verifyExtractionPath: entryName starts with a drive-letter prefix");
|
|
1020
|
+
}
|
|
1021
|
+
// UNC path (Windows) refuses.
|
|
1022
|
+
if (entryName.indexOf("\\\\") === 0 || entryName.indexOf("//") === 0) {
|
|
1023
|
+
throw new GuardFilenameError("filename.extraction-unc",
|
|
1024
|
+
"verifyExtractionPath: entryName starts with a UNC prefix");
|
|
1025
|
+
}
|
|
1026
|
+
// `..` segment refuses — walk path components.
|
|
1027
|
+
var segs = normalized.split("/");
|
|
1028
|
+
for (var si = 0; si < segs.length; si += 1) {
|
|
1029
|
+
if (segs[si] === ".." || segs[si] === "..\\" || segs[si] === "..%2f" || segs[si] === "..%5c") {
|
|
1030
|
+
throw new GuardFilenameError("filename.extraction-traversal",
|
|
1031
|
+
"verifyExtractionPath: entryName contains .. segment");
|
|
1032
|
+
}
|
|
1033
|
+
// URL-encoded variants — explicit refusal so operators don't
|
|
1034
|
+
// need to percent-decode before passing the entry name in.
|
|
1035
|
+
if (/%2e%2e/i.test(segs[si]) || /%c0%ae/i.test(segs[si])) {
|
|
1036
|
+
throw new GuardFilenameError("filename.extraction-traversal-encoded",
|
|
1037
|
+
"verifyExtractionPath: entryName contains encoded .. segment");
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
// Resolve the destination path against the root via path.resolve
|
|
1041
|
+
// (string-level computation; no fs hits).
|
|
1042
|
+
var stringResolved = nodePath.resolve(extractionRoot, normalized);
|
|
1043
|
+
var rootResolved = nodePath.resolve(extractionRoot);
|
|
1044
|
+
// String-level containment check — the resolved path must start
|
|
1045
|
+
// with the root + separator (or equal the root for the directory
|
|
1046
|
+
// entry itself). path.resolve normalizes separators platform-aware.
|
|
1047
|
+
var sep = nodePath.sep;
|
|
1048
|
+
if (stringResolved !== rootResolved &&
|
|
1049
|
+
stringResolved.indexOf(rootResolved + sep) !== 0) {
|
|
1050
|
+
throw new GuardFilenameError("filename.extraction-escape",
|
|
1051
|
+
"verifyExtractionPath: resolved path " + JSON.stringify(stringResolved) +
|
|
1052
|
+
" escapes extraction root " + JSON.stringify(rootResolved));
|
|
1053
|
+
}
|
|
1054
|
+
// Realpath-agreement check (fs-coupled). The CVE-2025-4517 class
|
|
1055
|
+
// exploits a divergence between the operator's path.resolve view
|
|
1056
|
+
// and the kernel's symlink-resolution. We resolve the longest
|
|
1057
|
+
// existing ancestor + verify the realpath agrees with our string
|
|
1058
|
+
// view.
|
|
1059
|
+
if (nodeFs.existsSync(rootResolved)) {
|
|
1060
|
+
var realRoot;
|
|
1061
|
+
try {
|
|
1062
|
+
realRoot = nodeFs.realpathSync(rootResolved);
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
throw new GuardFilenameError("filename.extraction-root-realpath",
|
|
1065
|
+
"verifyExtractionPath: cannot realpath extractionRoot " +
|
|
1066
|
+
JSON.stringify(rootResolved) + ": " + (e && e.message));
|
|
1067
|
+
}
|
|
1068
|
+
// Walk up from the target until we find an existing parent —
|
|
1069
|
+
// every ancestor that EXISTS must realpath inside realRoot. Once
|
|
1070
|
+
// we hit a non-existent path, the create-and-extract step will
|
|
1071
|
+
// populate it; the operator-supplied target name doesn't pre-
|
|
1072
|
+
// exist, so the deepest existing ancestor is the boundary check.
|
|
1073
|
+
var probe = nodePath.dirname(stringResolved);
|
|
1074
|
+
var safetyCounter = 0;
|
|
1075
|
+
var SAFETY_LIMIT = 4096; // guards against probe walking past root forever
|
|
1076
|
+
while (probe.length >= rootResolved.length && safetyCounter < SAFETY_LIMIT) {
|
|
1077
|
+
safetyCounter += 1;
|
|
1078
|
+
if (nodeFs.existsSync(probe)) {
|
|
1079
|
+
var realProbe;
|
|
1080
|
+
try { realProbe = nodeFs.realpathSync(probe); }
|
|
1081
|
+
catch (e2) {
|
|
1082
|
+
throw new GuardFilenameError("filename.extraction-realpath",
|
|
1083
|
+
"verifyExtractionPath: cannot realpath probe " +
|
|
1084
|
+
JSON.stringify(probe) + ": " + (e2 && e2.message));
|
|
1085
|
+
}
|
|
1086
|
+
// Two cases for the realpath comparison:
|
|
1087
|
+
// a) The probe's realpath stays inside realRoot — the symlink
|
|
1088
|
+
// (if any) is OS-level filesystem layout (macOS /var →
|
|
1089
|
+
// /private/var, Linux /tmp -> tmpfs mount) and the
|
|
1090
|
+
// ancestor was already canonicalized when we hashed
|
|
1091
|
+
// realRoot at the top. Accept.
|
|
1092
|
+
// b) The probe's realpath escapes realRoot — the symlink
|
|
1093
|
+
// resolves outside the trust boundary. Refuse (this is
|
|
1094
|
+
// the actual CVE-2025-4517 PATH_MAX TOCTOU class
|
|
1095
|
+
// defense).
|
|
1096
|
+
// Also normalize probe through path.resolve(realRoot, relative
|
|
1097
|
+
// -- to -- realRoot) so we compare against the SAME canonicalized
|
|
1098
|
+
// root, not the operator-supplied form. Computing `probeRealRel`
|
|
1099
|
+
// via the realRoot prefix avoids treating OS-level /var -> /private
|
|
1100
|
+
// /var as an escape just because realProbe doesn't textually share
|
|
1101
|
+
// the rootResolved prefix.
|
|
1102
|
+
var probeInsideRoot = (realProbe === realRoot) ||
|
|
1103
|
+
(realProbe.indexOf(realRoot + sep) === 0);
|
|
1104
|
+
if (!probeInsideRoot) {
|
|
1105
|
+
throw new GuardFilenameError("filename.extraction-realpath-escape",
|
|
1106
|
+
"verifyExtractionPath: realpath of " + JSON.stringify(probe) +
|
|
1107
|
+
" (" + JSON.stringify(realProbe) + ") escapes realpath of root " +
|
|
1108
|
+
JSON.stringify(realRoot) +
|
|
1109
|
+
" — CVE-2025-4517 PATH_MAX TOCTOU class");
|
|
1110
|
+
}
|
|
1111
|
+
// Symlink-anywhere-in-chain refusal was removed: macOS /
|
|
1112
|
+
// *BSD filesystems carry OS-level symlinks in standard paths
|
|
1113
|
+
// (/var → /private/var, /tmp → /private/tmp) that legitimate
|
|
1114
|
+
// operator usage routinely crosses. The realpath-agreement
|
|
1115
|
+
// check above is the load-bearing defense; if the resolved
|
|
1116
|
+
// chain STAYS inside realRoot, the symlinks resolved within
|
|
1117
|
+
// the trust boundary and the extraction is safe. Hostile
|
|
1118
|
+
// symlinks that escape are caught by the escape branch.
|
|
1119
|
+
void opts.followSymlinks;
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
var parent = nodePath.dirname(probe);
|
|
1123
|
+
if (parent === probe) break; // hit fs root
|
|
1124
|
+
probe = parent;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return stringResolved;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
926
1130
|
module.exports = {
|
|
927
1131
|
// ---- guard-* family identity ----
|
|
928
1132
|
// Filename is a different axis from content-bytes (operators
|
|
@@ -953,4 +1157,5 @@ module.exports = {
|
|
|
953
1157
|
WIN_RESERVED_NAMES: WIN_RESERVED_NAMES,
|
|
954
1158
|
SHELL_EXEC_EXTS: SHELL_EXEC_EXTS,
|
|
955
1159
|
GuardFilenameError: GuardFilenameError,
|
|
1160
|
+
verifyExtractionPath: verifyExtractionPath,
|
|
956
1161
|
};
|