Skip to content

Web UI: WebSocket fails through reverse proxy when Host header lacks port #1842

Closed

Description

@thaning0

Description

Accessing the daemon web UI through a reverse proxy (e.g. nginx, Caddy, tunnel services) fails because the WebSocket connection falls back to localhost:6767 instead of using the proxy URL.

Steps to Reproduce

  1. Start daemon with web UI on a custom port
  2. Expose the daemon port through a reverse proxy
  3. Access the proxy URL in a browser remotely

Expected

Web UI loads and WebSocket connects through the proxy URL.

Actual

The WebSocket attempts to connect to localhost and fails because:

  1. The client-side connectionFromListen() calls normalizeHostPort() which requires host:port format — when the proxy Host header omits the standard port, parsing throws and connectionFromListen returns null
  2. The system falls back to bootstrapDefaultLocalhost() → localhost:6767, which is unreachable from a remote browser

Additionally, users hitting the hostname validation error ({"error":"Invalid Host header"}) may try setting the Host header to 0.0.0.0:PORT, which normalizeLoopbackToLocalhost then converts to localhost:PORT — still wrong for remote access.

Root Cause

In packages/server/src/server/web-ui.ts → injectConnectionHint():

const host = req.headers.host;       // proxy hostname without port
const hint = { listen: host, useTls, label };

The hint is passed to connectionFromListen() which requires host:port format. Without a port, it returns null and the default localhost fallback is used.

Proposed Fix

In injectConnectionHint, when the Host header lacks a port, default to the protocol-appropriate standard port:

const listen = host.includes(":") ? host : `${host}:${useTls ? 443 : 80}`;
const hint = { listen, useTls, label };

This makes the hint valid for connectionFromListen, and the client correctly builds the WebSocket URL through the proxy (e.g. wss://proxy.example.com:443/ws).

Additional Notes

  • The hostnames config key for allowing external hostnames is under-documented — users hitting the "Invalid Host header" error are unaware of PASEO_HOSTNAMES / config.json daemon.hostnames.
  • For reverse proxy scenarios with dynamic hostnames (e.g. tunnel services), a wildcard or pattern-match hostname allowlist would be helpful.

Activity

  1. added
    bugReproducible unintended behavior
    p2Valid useful work with limited impact or a workaround
    triagedReviewed by maintainer triage
    on Jul 31, 2026
  2. paseo-bot commented on Jul 31, 2026

    @paseo-bot
    Collaborator

    Keeping this open as a current web bug.

    The failure is still present on main: the daemon injects the request Host header verbatim, while the client rejects a TCP listen address without an explicit port. That makes a standard-port reverse proxy whose Host header is only a hostname fall through to the local bootstrap path.

    PR #1843 addressed this report but was closed without merging. The newer PR #2618 reproduces the same failure through the daemon web UI and proposes a tested fix, but it is still open and unmerged. Until a fix lands, preserving an explicit port in the forwarded Host header or adding the correct host manually is the workaround.

    This is limited to the web UI behind affected reverse-proxy configurations, so it is tracked as bug / p2 / platform:web.

  3. paseo-bot commented on Aug 1, 2026

    @paseo-bot
    Collaborator

    Imported from Discord.


    shasderias, 2026-07-22 14:55:17 UTC

    If Paseo's web UI (--web-ui) is being served on a standard HTTP/HTTPS (80/443) public (i.e. the port that the user sees) port (e.g. behind a HTTPS proxy):

    • the injected connection hint that is derived from the Host header (e.g. example.com)
    • isn't parsed properly because it's missing the port component (Paseo expects example.com:443)
    • causing the FE to fall back to connecting to localhost:6767

    When this happens, the UI loads, but I need to do (A) to get a working Paseo instance.

    (A) If we establish a direct connection and specify an explicit port, that works.
    (B) If Paseo is served on a non-standard public port (e.g. 7123), the Host header contains the port component and things also work as expected.

    I've tested (A) and (B) by hand.

    Below is LLM generated analysis (that I've roughly verified by hand) that might help shortcut debugging.


    The server copies the raw Host header into the connection hint:

    • packages/server/src/server/web-ui.ts:253-264

    The client passes hint.listen to connectionFromListen(). If parsing returns
    null, it abandons the hint without logging an error:

    • packages/app/src/runtime/host-runtime.ts:1542-1548

    connectionFromListen() catches endpoint parsing errors and returns null:

    • packages/app/src/types/host-connection.ts:213-253

    The underlying parser requires host:port:

    • packages/protocol/src/daemon-endpoints.ts:52-82

    In particular:

    const match = trimmed.match(/^(.+):(\d{1,5})$/);
    if (!match) {
      throw new Error("Invalid host:port (expected localhost:6767)");
    }

    shasderias, 2026-07-29 17:38:45 UTC

    I've attempted a PR here, hope it's useful. #2618

  4. mogul commented on Sep 30, 2026

    @mogul

    Posting here rather than opening a new issue: this is the same root cause you
    already identified — connectionFromListen → normalizeLoopbackToLocalhost
    discarding the daemon's own injected hint, then the client dialling localhost
    — reached from a different direction. Say the word and I'll split it out.

    Same normalization, a different way to lose

    Your report is the reverse-proxy case: the Host header lacks a port, connectionFromListen returns null, and the client falls back to bootstrapDefaultLocalhost() → localhost:6767.

    The variant I hit is the container / VM published-port case, and the hint is not rejected — it is accepted and then rewritten into a name. Running the daemon inside a microsandbox VM with the guest port published to host loopback, the served page correctly advertises the IPv4 literal:

    <script>window.__PASEO_INITIAL_DAEMON_CONNECTION__={"listen":"127.0.0.1:6767","useTls":false,"label":"…"}</script>

    That is right — it is the address the page was loaded from. Following v0.10.2:

    • packages/server/src/server/web-ui.ts:253 — injectConnectionHint sets listen: req.headers.host → 127.0.0.1:6767
    • packages/app/src/runtime/host-runtime.ts:1685 — bootstrapInitialConnectionHint → connectionFromListen(hint.listen)
    • packages/app/src/types/host-connection.ts:346 — normalizeLoopbackToLocalhost(normalizeHostPort(...))
    • packages/protocol/src/daemon-endpoints.ts:196 — maps 127.0.0.1 → localhost

    Stored connection becomes { id: "direct:localhost:6767", type: "directTcp", endpoint: "localhost:6767" }, and buildDaemonWebSocketUrl dials ws://localhost:6767/ws.

    And the address bar cannot escape it. Every loopback form collapses to the same key — I ran the v0.10.2 function over each:

    Host: localhost:6767   -> endpoint localhost:6767 -> id direct:localhost:6767
    Host: 127.0.0.1:6767   -> endpoint localhost:6767 -> id direct:localhost:6767
    Host: 0.0.0.0:6767     -> endpoint localhost:6767 -> id direct:localhost:6767
    Host: [::1]:6767       -> endpoint localhost:6767 -> id direct:localhost:6767
    

    normalizeStoredConnection (host-connection.ts:436) re-applies it on every registry load, so entering 127.0.0.1:6767 by hand in the UI does not survive a reload either. There is no way, through the UI or the address bar, to hold an IPv4-literal direct connection.

    Why localhost is not a safe synonym

    ::1 resolving first against an IPv4-only listener does not make the connection hang. Measured against the real IPv4-only listener:

    Client / condition Result
    connect ::1 literal ECONNREFUSED in 9ms — fails fast
    curl -6 localhost (no fallback) ECONNREFUSED in 0ms — fails fast
    curl localhost (fallback allowed) connected, remote_ip=127.0.0.1
    browser-like client (Happy Eyeballs) connected, remote_ip=127.0.0.1 in 9ms

    An unbound loopback port answers with an immediate RST. Browsers implement Happy Eyeballs (RFC 8305), so they race the families and reach IPv4 anyway. ::1-first, on its own, is not a failure.

    The real problem is that a name selects between listeners, and the other family can be occupied. microsandbox publishes IPv4-only by default (default_host_bind() → Ipv4Addr::LOCALHOST, and TcpSocket::new_v4() for an IPv4 bind, with no dual-stack companion). An IPv4 bind on *:6767 and an unrelated [::1]:6767 bind do not conflict — both listen at once. Demonstrated:

    $ lsof -nP -iTCP:6767 -sTCP:LISTEN
    Paseo      708 … 32u IPv4 … TCP *:6767 (LISTEN)
    node      1012 … 21u IPv6 … TCP [::1]:6767 (LISTEN)     # bound fine, no conflict
    
    $ curl -w '%{remote_ip}' http://localhost:6767/api/health   -> ::1         # the other process
    $ curl -w '%{remote_ip}' http://127.0.0.1:6767/api/health   -> 127.0.0.1   # the real daemon

    So pinning the stored endpoint to a name means the daemon you connect to depends on resolver order and on what else happens to be bound — not on the address the daemon advertised. In a container/VM setup where ports get republished and forwarders can be left behind, "something else on the other family" is a realistic state, not a contrived one.

    This also matters for a class of bug I hit downstream: I spent hours on a session that connected to a stale daemon on the same port. Identity was the only signal that differed, and the normalization had removed the one addressing detail that could have pinned it.

    Two suggestions, smallest first

    1. Do not normalize away a loopback literal that was explicitly advertised. The injected hint is derived from a request that just succeeded — it is the one endpoint known reachable. Collapsing it to a name reintroduces a selection step the hint had already resolved:

    // bootstrapInitialConnectionHint: hint.listen is the address the page loaded from,
    // so it is known-reachable. Normalizing it to a name re-opens family selection.
    const connection = connectionFromListen(hint.listen, { preserveLoopbackLiteral: true });

    If dedupe against the built-in localhost key is why the normalization exists, the dedupe could key on the post-hello serverId instead — which is already how identity is established.

    2. Surface the address actually connected to. Whatever the endpoint, showing the resolved peer (or naming it on failure) makes ::1 vs 127.0.0.1 visible rather than inferred. That alone would have shortened my investigation considerably.

    Environment

    • Paseo 0.10.2 (source-verified; originally hit on 0.9.1 and the relevant paths are unchanged between the two tags — web-ui.ts is byte-identical, and normalizeLoopbackToLocalhost is byte-identical)
    • Chrome on macOS 15 arm64; daemon in a microsandbox VM, PASEO_LISTEN=0.0.0.0:6767, published to host 127.0.0.1:6767
    • Relay disabled; the daemon self-advertises over same-origin — exactly the path above

    GSA-TTS engineer. Investigation AI-assisted (OpenCode); source claims human-verified against v0.10.2, and the corrected claim above was measured rather than reasoned.

  5. paseo-bot commented on Oct 1, 2026

    @paseo-bot
    Contributor

    @mogul I've filed your case as its own issue, #5807, since the symptom differs from this one: the UI connects to a different process instead of failing to connect. Nothing needed from you; updates go there.

  6. paseo-bot commented on Oct 11, 2026

    @paseo-bot
    Contributor

    I reproduced the standard-port proxy failure in a fresh browser: the web UI showed the welcome screen and attempted localhost:6767. With #6638, the same browser path connects through the proxy URL. The regression test fails without the fix and passes with it.

    The browser check used HTTP on port 80. HTTPS forwarding and port 443, IPv6, and explicit ports passed the real-daemon tests. Actual TLS browser termination was not exercised. The separate loopback-address case remains tracked in #5807.

    #6638 carries forward the contributor's #3832 with credit and a small branching-style correction. It is open with CI running; updates go on that pull request.

  7. added
    diagnosedThe diagnose bot investigated this; on a PR, the bot authored it
    and removed
    diagnosingThe diagnose bot is working on this issue
    on Oct 11, 2026
  8. paseo-bot commented on Oct 11, 2026

    @paseo-bot
    Contributor

    The standard-port proxy fix in #6638 now has green CI. In the verified fresh-browser flow, the page that previously attempted localhost:6767 connects through the proxy URL, and the regression test fails without the fix.

    The fix is still unmerged. Updates remain on #6638.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugReproducible unintended behaviordiagnosedThe diagnose bot investigated this; on a PR, the bot authored itp2Valid useful work with limited impact or a workaroundplatform:webWeb / browser specifictriagedReviewed by maintainer triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions