Harden local transcript explorer security - #17
Conversation
| ports: | ||
| - "127.0.0.1:9877:9876" |
There was a problem hiding this comment.
🟡 Live updates stop working in the dev container because the real-time connection is rejected
The real-time WebSocket connection is refused (getAllowedWebSocketOrigins at src/chats-mobile.js:1516-1521) whenever the page is opened on a host port that differs from the container's internal port, which is exactly the case for the dev setup published on 127.0.0.1:9877:9876.
Impact: In the dev container, new conversations and messages never appear live; the real-time panel silently stops updating.
Origin allowlist keyed on internal port vs. published host port
The WebSocket verifyClient (src/chats-mobile.js:1551-1562) calls authorizeWebSocketUpgrade, which requires info.origin to be an exact member of getAllowedWebSocketOrigins(). That set is built from this.port, the port the app listens on inside the container (9876). The dev proxy publishes 127.0.0.1:9877:9876 (docker-compose.dev.yml:62-63) and forwards the browser's Origin header verbatim (src/loopback-proxy.js:28). A browser loading http://localhost:9877 sends Origin: http://localhost:9877, which is not in {http://localhost:9876, http://127.0.0.1:9876, http://[::1]:9876}, so isOriginAllowed returns false and the upgrade is rejected with 403. The same breakage occurs for any user who follows the README "Change Port" instructions to remap the published port. Production works only because there the published port equals the internal port.
Prompt for agents
The WebSocket origin allowlist in src/chats-mobile.js (getAllowedWebSocketOrigins) is derived from this.port, which is the app's internal listening port (9876 inside the container). However, browsers connect through the loopback proxy on the published host port, which can differ from the internal port (e.g. 9877 in docker-compose.dev.yml, or any custom mapping per the README 'Change Port' section). Because src/loopback-proxy.js forwards the browser Origin header verbatim, the Origin (http://localhost:9877) never matches the allowlist built for port 9876, so authorizeWebSocketUpgrade returns a 403 and real-time updates break. Consider making the set of allowed WebSocket origins configurable (e.g. via an environment variable listing the externally-published origins/ports) so the dev container and custom port mappings can advertise their real browser-facing origin, rather than assuming the browser reaches the app on the same port the app listens on.
Was this helpful? React with 👍 or 👎 to provide feedback.
| echo "✅ Container is already running!" | ||
| echo "📊 Access the web interface at: http://localhost:9876" | ||
| docker compose up -d | ||
| echo "📊 Authenticated access URL:" | ||
| docker compose logs --tail=30 chat-explorer | grep "Local access:" || true |
There was a problem hiding this comment.
🔍 Already-running container branch may print an empty access URL
In quick-start.sh:25-29, when the container is already running the script does docker compose up -d (a no-op for an unchanged running container) and then greps docker compose logs --tail=30 chat-explorer for Local access:. That log line is only emitted once at process startup, so for a container that has been running for a while it has long scrolled past the last 30 lines and the grep prints nothing. Because the token defaults to a fresh random value per container start, a user who did not export a stable CHAT_EXPLORER_AUTH_TOKEN then has no easy way to obtain the authenticated URL and cannot reach the dashboard. The newly-created-container branch (quick-start.sh:37-44) is fine because the line is within the tail window right after startup.
Was this helpful? React with 👍 or 👎 to provide feedback.
| networks: | ||
| chat-explorer-internal: | ||
| internal: true |
There was a problem hiding this comment.
🔍 App container is on an internal-only network with no outbound route, which disables URL-based session import
docker-compose.yml:99-101 places the transcript-reading app on chat-explorer-internal with internal: true, so the container has no outbound route. The session-sharing import path (src/session-sharing.js downloadSession) performs an outbound fetch to allowlisted hosts (x0.at, transfer.sh, etc.). With no egress, remote session cloning will always fail from inside the default container. The PR description states outbound access is intentionally blocked, so this appears deliberate, but it does silently break the import-from-URL feature when running under the default compose file — worth confirming this trade-off is intended and documented.
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
pfctllauncher with Docker-managed isolation and validate cloned conversation IDs against path traversalWhy
The application indexes sensitive local Claude transcripts. Previously it trusted transcript HTML, exposed unauthenticated HTTP and WebSocket endpoints on all interfaces, and used
pfctl -fin a way that could replace the host ruleset. Session import also accepted conversation IDs that could escape the intended destination path.Impact
Startup now prints a one-time authenticated URL. It sets an HttpOnly, SameSite=Strict cookie and redirects to a clean URL. Monitoring scripts require
CHAT_EXPLORER_AUTH_TOKENso protected metrics remain accessible intentionally.The main container remains read-only, mounts Claude data read-only, publishes no host port, and has no outbound route. Only the no-mount proxy publishes
127.0.0.1:9876.Validation
mise exec -- npm ci --no-audit --no-fundmise exec -- npm test -- --maxWorkers=1— 351 tests passedmise exec -- npm audit --json— 0 vulnerabilitiesmise exec -- npm audit signatures— 292 verified registry signatures and 37 verified attestationsdocker compose build chat-explorer