DEV Community

Cover image for Why Your Database Client Shouldn't Write to Your Project Directory
Hannah Schmidt
Hannah Schmidt

Posted on

Why Your Database Client Shouldn't Write to Your Project Directory

Why Your Database Client Shouldn't Write to Your Project Directory

At 2:47 AM, a junior engineer's compromised database client silently rewrote migration scripts across twelve microservice repositories. The client had been granted write access to the entire project tree for "convenience." The blast radius: three hours of rollback across staging and pre-prod before anyone noticed the corrupted schema definitions.

A database client should make connecting easier. It should not quietly turn a disposable development environment into a privileged extension of your workstation.

I am evaluating t8y2/dbx as an external integrator. Its stated scope is attractive: a roughly 25 MB, cross-platform client supporting more than 100 databases, with desktop, CLI, and Docker delivery options. For this evaluation, however, I want one boundary established before testing features: the database environment must not have write access to my project or home directory.

The configuration below supplies that environment. It runs PostgreSQL alongside a small, non-root Go connectivity sentinel. dbx runs separately on the workstation and connects through a loopback-only port.

This is a companion sandbox, not an asserted deployment recipe for dbx itself. I have not verified its current container image, ports, or runtime requirements.

The failure is usually the mount, not the container

"Containers isolate processes" is an incomplete security argument.

Consider a development container running as root with the repository mounted read-write. A compromised process can modify files in that mount without exploiting the kernel or escaping its namespace. Mount the Docker socket, and the consequences become substantially worse: control of the daemon can enable host filesystem access.

Running as root does not automatically cause a container escape. It does increase the consequences of mistakes and some vulnerabilities. Removing root, writable host mounts, and unnecessary capabilities reduces several independent attack paths.

This sandbox therefore gives PostgreSQL a named data volume, not a host directory. Neither service receives the Docker socket. The internal bridge separates service traffic from ordinary outbound networking; the published loopback port is the deliberate workstation entry point.

One reproducible configuration

Run this in a new directory with Docker Compose v2 and BuildKit available. The sentinel is deliberately small: its health means "PostgreSQL accepts TCP connections," not "SQL authentication succeeded."

mkdir -p dbx-sandbox
cd dbx-sandbox

cat > go.mod <<'EOF'
module local/dbx-sentinel

go 1.24
EOF

cat > main.go <<'EOF'
package main

import (
    "net"
    "net/http"
    "os"
    "time"
)

func main() {
    if len(os.Args) > 1 && os.Args[1] == "-check" {
        c := http.Client{Timeout: 2 * time.Second}
        r, err := c.Get("http://127.0.0.1:8080/healthz")
        if err != nil {
            os.Exit(1)
        }
        defer r.Body.Close()
        if r.StatusCode != http.StatusOK {
            os.Exit(1)
        }
        return
    }
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        c, err := net.DialTimeout("tcp", "db:5432", time.Second)
        if err != nil {
            http.Error(w, "database unavailable", http.StatusServiceUnavailable)
            return
        }
        c.Close()
        w.WriteHeader(http.StatusOK)
    })
    s := &http.Server{
        Addr: ":8080", ReadHeaderTimeout: 2 * time.Second,
        ReadTimeout: 3 * time.Second, WriteTimeout: 3 * time.Second,
        IdleTimeout: 30 * time.Second,
    }
    if err := s.ListenAndServe(); err != nil {
        os.Exit(1)
    }
}
EOF

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY main.go ./
RUN --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=cache,target=/go/pkg/mod \
    CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
    -o /out/sentinel .

FROM scratch
COPY --from=build --chown=65532:65532 /out/sentinel /sentinel
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/sentinel"]
EOF

cat > docker-compose.yml <<'EOF'
services:
  db:
    image: postgres:17-alpine
    user: postgres
    environment:
      POSTGRES_USER: dbx
      POSTGRES_DB: sandbox
      POSTGRES_PASSWORD: ${DB_PASSWORD:?Export DB_PASSWORD first}
    ports:
      - "127.0.0.1:55432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    read_only: true
    tmpfs:
      - /tmp:size=64m,mode=1777
      - /var/run/postgresql:size=16m,mode=1777
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    cpus: 1.0
    mem_limit: 512m
    pids_limit: 128
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U dbx -d sandbox"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 20s
    restart: unless-stopped
    networks: [database]

  sentinel:
    build: .
    depends_on:
      db:
        condition: service_healthy
    read_only: true
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    cpus: 0.25
    mem_limit: 32m
    pids_limit: 32
    healthcheck:
      test: ["CMD", "/sentinel", "-check"]
      interval: 10s
      timeout: 3s
      retries: 3
    restart: unless-stopped
    networks: [database]

networks:
  database:
    driver: bridge
    internal: true

volumes:
  pgdata:
EOF

export DB_PASSWORD="$(openssl rand -hex 24)"
docker compose up --build -d --wait
docker compose ps
Enter fullscreen mode Exit fullscreen mode

What this boundary buys—and what it does not

Configure dbx with host 127.0.0.1, port 55432, database sandbox, user dbx, and the generated password from your shell. That PostgreSQL bootstrap user is a superuser: appropriate for a disposable sandbox, not a shared production database.

The sentinel publishes no port. Its readiness check tests the container-side network path independently of dbx. PostgreSQL's own check tests server readiness, but neither check proves that the workstation client can authenticate. Execute SELECT current_user, current_database(); through dbx to finish that verification.

Health status also does not trigger automatic restarts. Compose restart policies handle process exits; an unhealthy but running process requires investigation or external supervision.

The Dockerfile keeps build tooling outside the runtime image. BuildKit caches dependency downloads and compiler artifacts without embedding either cache in the final image. This addresses the same invalidation pattern that makes Node builds painful: copying the entire repository before dependency installation forces expensive work after unrelated source edits. Separate dependency metadata from application source instead.

The trade-offs are intentional

An internal network restricts ordinary egress, but it is not a universal firewall policy. Loopback publishing limits remote exposure; it does not exclude other local processes. Validate both assumptions on your Docker platform.

Environment-based credentials keep this example executable, but Docker inspection can expose them. A shared deployment should use a managed secret mechanism and restricted database roles.

Likewise, version tags make the example convenient, not immutable. Pin reviewed image digests and update them deliberately before promoting this pattern beyond a workstation.

Finally, retain the exported password: PostgreSQL initialization variables do not reset credentials in an existing volume. docker compose down preserves data; docker compose down -v destroys it.

The useful result is a narrow contract: dbx gets a database connection, while the database gets no access to your working tree.

The hardest question

This sandbox protects the workstation from the database environment. It does not protect the database from a compromised client.

If dbx itself were exploited, an attacker would inherit your authenticated PostgreSQL superuser session. The loopback binding and internal network become irrelevant once the client has a valid connection. Least-privilege database roles, query logging, and connection auditing become the next defensive layer.

How do you balance developer ergonomics against the risk surface of granting full schema privileges to a third-party client you have not audited? Where do you draw that line in your own infrastructure, and what monitoring convinced you it holds?

Top comments (0)