-
Notifications
You must be signed in to change notification settings - Fork 0
fix: isolate module bundles per folder and decouple share module activation #123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,12 @@ | ||
| const fs = require('fs/promises'); | ||
| const os = require('os'); | ||
| const path = require('path'); | ||
| const { randomUUID } = require('crypto'); | ||
| const { execFile } = require('child_process'); | ||
| const { promisify } = require('util'); | ||
| const AdmZip = require('adm-zip'); | ||
| const { ApiError } = require('../middlewares/errorHandler'); | ||
|
|
||
| const execFileAsync = promisify(execFile); | ||
| const MAX_MANIFEST_BYTES = 250_000; | ||
| const MAX_ZIP_ENTRY_BYTES = 5_000_000; | ||
| const MAX_ZIP_ENTRY_COUNT = 500; | ||
|
|
||
| function getModulesBaseDir() { | ||
| if (process.env.MODULES_DIR) { | ||
|
|
@@ -21,45 +20,78 @@ async function ensureModulesBaseDir() { | |
| return getModulesBaseDir(); | ||
| } | ||
|
|
||
| function sanitizeModuleFileName(moduleKey) { | ||
| function sanitizeModuleFolderName(moduleKey) { | ||
| return String(moduleKey || '') | ||
| .trim() | ||
| .replace(/[^a-zA-Z0-9._@-]/g, '_') | ||
| .slice(0, 120) || `module-${randomUUID()}`; | ||
| } | ||
|
|
||
| function normalizeZipEntryName(entryName) { | ||
| return String(entryName || '') | ||
| .replace(/\\/g, '/') | ||
| .replace(/^\/+/, ''); | ||
| } | ||
|
|
||
| function assertSafeZipEntryPath(entryName) { | ||
| const normalized = normalizeZipEntryName(entryName); | ||
| if (!normalized || normalized.endsWith('/')) return null; | ||
| if (normalized.includes('..')) { | ||
| throw new ApiError(400, `zip contains unsafe path: ${entryName}`); | ||
| } | ||
| if (path.isAbsolute(normalized)) { | ||
| throw new ApiError(400, `zip contains absolute path: ${entryName}`); | ||
| } | ||
| return normalized; | ||
| } | ||
|
|
||
| function findManifestEntry(entries) { | ||
| const candidates = entries.filter((entry) => { | ||
| const normalized = normalizeZipEntryName(entry.entryName).toLowerCase(); | ||
| return ( | ||
| normalized.endsWith('/manifest.json') || | ||
| normalized.endsWith('/module.json') || | ||
| normalized === 'manifest.json' || | ||
| normalized === 'module.json' | ||
| ); | ||
| }); | ||
|
|
||
| if (candidates.length === 0) { | ||
| throw new ApiError(400, 'zip must include manifest.json or module.json'); | ||
| } | ||
|
|
||
| return candidates.sort((a, b) => a.entryName.length - b.entryName.length)[0]; | ||
| } | ||
|
|
||
| function getZipEntries(zipBuffer) { | ||
| const zip = new AdmZip(zipBuffer); | ||
| const entries = zip.getEntries().filter((entry) => !entry.isDirectory); | ||
| if (entries.length === 0) { | ||
| throw new ApiError(400, 'zip is empty'); | ||
| } | ||
| if (entries.length > MAX_ZIP_ENTRY_COUNT) { | ||
| throw new ApiError(400, `zip contains too many files (max ${MAX_ZIP_ENTRY_COUNT})`); | ||
| } | ||
| return entries; | ||
| } | ||
|
|
||
| async function extractManifestFromZipBuffer(zipBuffer) { | ||
| if (!Buffer.isBuffer(zipBuffer) || zipBuffer.length === 0) { | ||
| throw new ApiError(400, 'zip file is required'); | ||
| } | ||
|
|
||
| const tempFile = path.join(os.tmpdir(), `syspoints-module-${randomUUID()}.zip`); | ||
| await fs.writeFile(tempFile, zipBuffer); | ||
|
|
||
| try { | ||
| const { stdout: listStdout } = await execFileAsync('unzip', ['-Z1', tempFile], { maxBuffer: 2 * 1024 * 1024 }); | ||
| const entries = String(listStdout || '') | ||
| .split('\n') | ||
| .map((line) => line.trim()) | ||
| .filter(Boolean); | ||
|
|
||
| const manifestCandidates = entries.filter((entry) => { | ||
| const normalized = entry.replace(/\\/g, '/').toLowerCase(); | ||
| if (normalized.endsWith('/')) return false; | ||
| return normalized.endsWith('/manifest.json') || normalized.endsWith('/module.json') || normalized === 'manifest.json' || normalized === 'module.json'; | ||
| }); | ||
|
|
||
| if (manifestCandidates.length === 0) { | ||
| throw new ApiError(400, 'zip must include manifest.json or module.json'); | ||
| const entries = getZipEntries(zipBuffer); | ||
| const manifestEntry = findManifestEntry(entries); | ||
| const manifestBuffer = manifestEntry.getData(); | ||
| if (!manifestBuffer || manifestBuffer.length === 0) { | ||
| throw new ApiError(400, 'manifest file is empty'); | ||
| } | ||
| if (manifestBuffer.length > MAX_MANIFEST_BYTES) { | ||
| throw new ApiError(400, `manifest must be <= ${MAX_MANIFEST_BYTES} bytes`); | ||
| } | ||
|
|
||
| const manifestEntry = manifestCandidates.sort((a, b) => a.length - b.length)[0]; | ||
| const { stdout: manifestStdout } = await execFileAsync('unzip', ['-p', tempFile, manifestEntry], { | ||
| maxBuffer: MAX_MANIFEST_BYTES, | ||
| encoding: 'utf8', | ||
| }); | ||
|
|
||
| const manifestText = String(manifestStdout || '').trim(); | ||
| const manifestText = manifestBuffer.toString('utf8').trim(); | ||
| if (!manifestText) { | ||
| throw new ApiError(400, 'manifest file is empty'); | ||
| } | ||
|
|
@@ -71,31 +103,59 @@ async function extractManifestFromZipBuffer(zipBuffer) { | |
| } | ||
| } catch (error) { | ||
| if (error instanceof ApiError) throw error; | ||
| if (/maxBuffer/i.test(String(error?.message || ''))) { | ||
| throw new ApiError(400, `manifest must be <= ${MAX_MANIFEST_BYTES} bytes`); | ||
| } | ||
| throw new ApiError(400, 'failed to read zip manifest. Ensure unzip is available and archive is valid'); | ||
| } finally { | ||
| await fs.unlink(tempFile).catch(() => {}); | ||
| throw new ApiError(400, `failed to read zip manifest: ${String(error?.message || 'invalid archive')}`); | ||
| } | ||
| } | ||
|
|
||
| async function saveModuleArchive({ moduleKey, zipBuffer }) { | ||
| async function saveModuleBundle({ moduleKey, zipBuffer }) { | ||
| if (!Buffer.isBuffer(zipBuffer) || zipBuffer.length === 0) { | ||
| throw new ApiError(400, 'zip file is required'); | ||
| } | ||
|
|
||
| const baseDir = await ensureModulesBaseDir(); | ||
| const safeFileName = `${sanitizeModuleFileName(moduleKey)}.zip`; | ||
| const archivePath = path.join(baseDir, safeFileName); | ||
| const moduleFolderName = sanitizeModuleFolderName(moduleKey); | ||
| const moduleDir = path.join(baseDir, moduleFolderName); | ||
|
|
||
| await fs.mkdir(moduleDir, { recursive: false }); | ||
|
|
||
| try { | ||
| const archivePath = path.join(moduleDir, 'module.zip'); | ||
| await fs.writeFile(archivePath, zipBuffer, { flag: 'wx' }); | ||
|
|
||
| const entries = getZipEntries(zipBuffer); | ||
| for (const entry of entries) { | ||
| const safeRelativePath = assertSafeZipEntryPath(entry.entryName); | ||
| if (!safeRelativePath) continue; | ||
|
|
||
| const entryBuffer = entry.getData(); | ||
| if (entryBuffer.length > MAX_ZIP_ENTRY_BYTES) { | ||
| throw new ApiError(400, `zip entry too large: ${safeRelativePath}`); | ||
|
Comment on lines
+130
to
+132
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| const targetPath = path.join(moduleDir, safeRelativePath); | ||
| const normalizedTarget = path.normalize(targetPath); | ||
| const normalizedModuleDir = path.normalize(moduleDir + path.sep); | ||
| if (!normalizedTarget.startsWith(normalizedModuleDir)) { | ||
| throw new ApiError(400, `zip contains unsafe extraction path: ${safeRelativePath}`); | ||
| } | ||
|
|
||
| await fs.mkdir(path.dirname(targetPath), { recursive: true }); | ||
| await fs.writeFile(targetPath, entryBuffer, { flag: 'wx' }); | ||
| } | ||
|
|
||
| await fs.writeFile(archivePath, zipBuffer, { flag: 'wx' }); | ||
| return archivePath; | ||
| return { | ||
| module_dir: moduleDir, | ||
| archive_path: archivePath, | ||
| }; | ||
| } catch (err) { | ||
| await fs.rm(moduleDir, { recursive: true, force: true }).catch(() => {}); | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| module.exports = { | ||
| getModulesBaseDir, | ||
| ensureModulesBaseDir, | ||
| extractManifestFromZipBuffer, | ||
| saveModuleArchive, | ||
| saveModuleBundle, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
extractManifestFromZipBufferinflates the manifest withmanifestEntry.getData()and only appliesMAX_MANIFEST_BYTESafterwards, so a tiny compressed archive can still force a very large in-memory allocation before the guard runs. In module upload paths this enables zip-bomb style input to trigger high memory usage or process instability even though the endpoint advertises strict manifest limits; check uncompressed size metadata (or stream with a hard cap) before materializing the buffer.Useful? React with 👍 / 👎.