All posts
clilocalhostbridgesecurity

The duckviz CLI — from your terminal into the browser

`npx duckviz ./access.log` opens the app with your file already loaded — no upload to a server, no copy-paste. Here's what the CLI is for, what it does under the hood, and why the small loopback bridge underneath ended up being the most interesting part.

Vikas Awaghade3 min read

Engineers don't live in browser tabs. They live in shells. So when a customer issue lands at 2 AM and the relevant log is already on disk from a kubectl cp, there's a friction tax in stopping to drag the file into a web app.

The CLI is the answer:

npx duckviz ./access.log

That opens app.duckviz.com with your file already loaded. The file flows from your disk into the browser's DuckDB-WASM table directly — no server upload, no copy-paste, no detour through the file picker.

The CLI accepts files and folders as positional args. npx duckviz ./logs/ -r recurses; npx duckviz ./data.csv --fresh replaces the current session. There's no push subcommand to remember; the default action is the send.

What happens under the hood

duckviz <files> does five things in sequence:

  1. Resolves the file paths and reads enough of each to build a manifest.
  2. Spins up a tiny HTTP server on a random loopback port, behind a one-time bearer token.
  3. Opens the browser to https://app.duckviz.com/cli-receive?endpoint=…&token=….
  4. The /cli-receive page fetches the manifest from the loopback server, then pulls each file by relative path and hands it to the same ingestion pipeline an upload would use.
  5. Files stream into DuckDB-WASM. The CLI's loopback server self-shuts.

The CLI never talks to DuckViz Cloud. It never holds your account credentials. It's a bridge — your terminal on one side, your browser session on the other.

Why bridges are interesting

A CLI by itself is an old, well-understood thing — read args, print stuff, exit. The interesting bit here isn't the CLI. It's the bridge.

Bridges between local processes and browsers ride two privilege ladders at once. Shells have local filesystem access and ambient credentials. Browsers have session cookies and same-origin trust against your real app. A poorly-built bridge inherits the worst of both: a malicious page somewhere on the web could DNS-rebind onto your loopback port and read whatever the CLI is exposing; a token in the URL bar could leak via screenshots or Referer headers; a path served by name could be traversed to /etc/passwd.

So most of the work in the CLI isn't the file shipping. It's making sure none of those things can happen to a user who installed duckviz from npm and ran it on their laptop.

How the bridge stays narrow

Without going lock by lock, the shape of the security model:

  • Bearer-only auth. The token rides in Authorization: Bearer … headers, never in the URL. Constant-time comparison handles timing oracles.
  • Host header allowlist. The loopback server only accepts requests where Host is 127.0.0.1:<port> or localhost:<port>. Anything else is a flat 403, no CORS leaked. DNS rebinding doesn't work.
  • Manifest-locked file serving. /file?path=… only serves names that were registered in a manifest at startup. ../../etc/passwd returns 404 because that string isn't a key in the map.
  • Manifest-gated state wipes. --fresh clears app state only after an authenticated manifest fetch has succeeded. A crafted cli-receive link that arrives via Slack can't wipe anything.
  • URL scrub on arrival. The /cli-receive page snapshots its query parameters in memory, then immediately rewrites the URL bar. Tokens never linger in browser history, screenshots, or outbound Referer.
  • Idle shutdown. The loopback server self-closes after ten minutes of inactivity. No orphaned ports.

Each of those is unit-tested in packages/cli/src/server.test.ts and token.test.ts. The full threat-model writeup, including which specific attack each guard blocks, lives in the source — pull the package apart if you want the deep dive.

Try it

# one-shot, no install:npx duckviz ./your-file.log# or install globally:npm install -g duckvizduckviz ./your-file.log

Folders work too — duckviz ./logs/ for a single directory, or add -r to recurse:

duckviz ./logs/                    # everything in ./logs (one level)duckviz ./logs/ -r                 # recurse into subdirectoriesduckviz ./data.csv --fresh         # clean session before ingestduckviz . -r                       # current directory, recursive

The whole point is that the friction between "I have a file" and "I have answers" should be one line.

— Vikas