Four problems you inherit the moment your SQL client runs on a server
DEV Community

Four problems you inherit the moment your SQL client runs on a server

A desktop database client makes two demands nobody writes down: every laptop has to reach the production network, and every laptop has to hold a copy of every credential. Move the client onto a server next to the data and both demands disappear. The credential lives in one place instead of sixty. The network path becomes a deployment topology instead of a VPN grant per person. That is the trade. This post is the invoice. Because the thing you just built is no longer a client. It is a multi-tenant network service holding every database connection your team owns, and it has four problems a desktop app never had. We hit all four building LibreDB Studio, and got two of them wrong on the first attempt. 1. The trust boundary moves inside your own process On a laptop the OS user is the authorization boundary. Server-side, one process holds every connection, and something inside it has to decide who is asking. The obvious answer is middleware: run before every route, verify the session cookie, redirect the rest. We have that. The part worth saying out loud is that it is not the boundary. Look at what a Next.js matcher actually is: export const config = { matcher: ["/((?!api/storage/config|_next/static|_next/image|.\..).)"], }; That ...* exempts any path containing a dot. It is there so static assets skip the pipeline, and it is a perfectly good optimisation. It is a terrible boundary. So every route that reaches a database or a model provider verifies its own caller again through one shared guard. The edge exists to make the common case cheap, not to be trusted. That distinction paid off the first time we designed a seam where something legitimate could not present a session. Our agent runtime has a callback that asks the server to resume a long-running query session. Its caller is a durable transport, not a person, so it has no cookie by construction. Nothing mints one in production yet, because no queue produces a drive delivery so far, and the credential exists now precisely because a boundary is cheap to design with and expensive to bolt on. The tempting fix is to add its path to the public list. That fix is wrong for a structural reason: a path exemption is path-shaped, so anything that can reach the port gets in. Instead the caller presents a credential the server minted itself. Sixty seconds, names exactly one run, grants nothing else, and signed with a key derived from the JWT secret rather than with the secret itself: const DRIVE_KEY_LABEL = "libredb.agent.drive.v1"; // HMAC(JWT_SECRET, label): a key that cannot mint or verify a session. const base = await crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const derived = await crypto.subtle.sign("HMAC", base, new TextEncoder().encode(DRIVE_KEY_LABEL)); Why derive instead of reuse? Because our verifyJWT casts its payload to UserPayload without inspecting it. A drive token accepted there would present as a session whose role is undefined , which the middleware reads as "not admin", meaning an ordinary user of the entire application. Separate keys make that unreachable instead of depending on every future reader to remember to check a claim. 2. CSRF becomes real, and your reverse proxy silently breaks the fix A desktop client has no cookies and no ambient authority. A browser-based one has both. So state-changing requests get a second layer beyond the session cookie: compare the request Origin against the deployment's own host. Two decisions in that check are worth stealing, and both are about false positives rather than about attackers. The comparison is host-only and ignores the scheme. A TLS-terminating proxy that forwards plain HTTP without setting x-forwarded-proto makes the browser send Origin: https://db.example.com while the app computes http://db.example.com . Compare schemes there and you lock the operator out of their own login form. What you give up is an http:// page on the same host posting to the https:// app, which needs an active network attacker who has already broken transport. Smaller threat than the deployment class it protects. A request with neither Origin nor Referer is accepted, but only if its content type is application/json . const mediaType = value.split(";")[0]?.trim().toLowerCase(); return mediaType === "application/json"; This looks like an off switch wearing a disguise. It is not, for two independent reasons. An HTML can only submit as x-www-form-urlencoded , multipart/form-data or text/plain , because enctype has no fourth value, so the classic CSRF vector is structurally incapable of producing that shape. And a cross-site fetch() can set that content type, but application/json is not CORS-safelisted, so it forces a preflight, and a deployment that never returns an Access-Control-Allow-* header never affirmatively answers one. What is left is curl and server-to-server callers, which are not CSRF. CSRF is specifically an unwitting browser sending credentials it did not choose to send. Now the one we shipped wrong. The matcher excluded api/db/health so load-balancer probes would skip the pipeline. That path also backs a POST /api/db/health , a session-gated detailed check against a specific connection. Excluding the path excluded the method, so the route named after health was the one route whose POST reached a provider with no origin check at all. Path-shaped exemptions are method-blind. Same lesson as problem 1, different costume. One more thing this layer taught us: a lockout has to diagnose itself. A proxy that rewrites Host without setting x-forwarded-host produces a mismatch on every state-changing request, login included, and the operator sees a working page that silently refuses everything. So the 403 body names the fix: Request origin is not allowed for this deployment. If Studio sits behind a reverse proxy, set ALLOWED_ORIGINS to its public origin. An error that explains its own cause beats a shorter one. 3. Your rejection logging is a denial-of-service surface Every branch above writes a log line and an audit event. On the public internet that means an unauthenticated scanner can fill a container log volume with requests it knows will fail, and evict real events from the fixed-size ring the admin UI reads. So denials are metered. The rejection itself is never rate limited, only its record. The part that surprised us: bounding how many lines get written does not bound how large each line is. The line carries the request path and the observed host. Both are attacker controlled and both can be made arbitrarily long. A cap on count with no cap on size is the same attack at a different scale. logger.warn("Origin check rejected a request", { route: ${request.method} ${pathname}.slice(0, MAX_AUDIT_FIELD_LENGTH), observedOrigin: origin.observedOrigin.slice(0, MAX_AUDIT_FIELD_LENGTH), expectedHost: origin.expectedHost.slice(0, MAX_AUDIT_FIELD_LENGTH), }); There is a keying subtlety too. For "authenticated user hit an admin route", metering by IP is wrong: holding a token bounds how many identities reach that branch, not how many requests each identity makes, and one session can poll in a loop. That bucket is keyed on the username, so rotating X-Forwarded-For buys no extra lines. 4. State has to move too, and that is where you stay honest The browser was the store. Connections, tabs, query history all in localStorage , which is exactly right for one developer and useless the moment two people share a deployment. Server mode is one environment variable: reads still come from localStorage as a write-through cache, mutations get pushed to a per-user scoped server store, connection credentials encrypted at rest. What that encryption buys you is a stolen database file or a dump. It is not a vault. Anyone who can read the server's environment can read the key. And the browser copy of your credentials is still plaintext, because encrypting it needs a master password and a recovery flow, which changes what the product is. That admission is why XSS controls are the highest-leverage rows in our security posture rather than a checkbox. What this does not buy you Relocating the client narrows the blast radius. It does not hand you an authorization model you did not write. Studio ships two roles, and a user can still connect to any host and port and run any statement. Target allowlists and per-provider command capabilities are a coherent direction and are not implemented. Writing that down in the docs, right next to the controls that are implemented, turned out to matter more than any individual control. None of this is specific to us. Move any credential-holding client onto a shared host and the same four arrive with it: the boundary lands inside your process, ambient browser authority becomes a real vector, your own error paths become a resource to exhaust, and your state stops being one person's. LibreDB Studio is MIT licensed: https://github.com/libredb/libredb-studio If you have moved a client-side tool server-side, I would like to know which of the four bit you first. For us it was the health-check matcher, and it was embarrassing. Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.