Skip to content

Security hardening: public/self-hosted Gateway threat model and recommended controls #4

Description

@roeseth

Security hardening: public/self-hosted Gateway threat model and recommended controls

Summary

I reviewed the Gateway and bridge code with the deployment model where a LinkShell Gateway is exposed to the public internet so a CLI host and mobile client can pair/connect across networks. The current implementation is useful for trusted/local use, but the public self-hosted posture appears weaker than the docs imply.

The highest-risk finding is that the self-hosted/default mode has no general-purpose Gateway authentication, host WebSocket connections are not authenticated by a device/session secret, and the Gateway exposes pairing plus localhost tunnel functionality over public HTTP/WebSocket endpoints. A leaked, guessed, or intercepted pairing/device token can become full interactive control of the host user's PTY and can also proxy arbitrary 127.0.0.1:<port> services from the paired host.

This issue is intended as a threat model and hardening request rather than a single crash bug.

Environment reviewed

  • Repository commit reviewed: be22e1eafa823d65c21c887fe095b57f9e2f964a
  • Gateway package: @linkshell/gateway 0.2.29
  • Reviewed files:
  • packages/gateway/src/index.ts
  • packages/gateway/src/embedded.ts
  • packages/gateway/src/pairings.ts
  • packages/gateway/src/tokens.ts
  • packages/gateway/src/tunnel.ts
  • packages/gateway/src/relay.ts
  • packages/cli/src/runtime/bridge-session.ts
  • apps/mobile/src/hooks/useSessionManager.ts
  • apps/mobile/src/components/BrowserView.tsx
  • docs/deploy.md
  • docker-compose.yml

Assets and security goals

Important assets protected by the Gateway:

  • Host user's interactive shell/agent session.
  • Terminal output, scrollback, shell history, cwd/project metadata, hostname/platform metadata.
  • Agent permission decisions and prompts.
  • Optional screen-sharing data.
  • Device tokens that authorize reconnects.
  • Localhost services reachable from the host through /tunnel/:sessionId/:port/**.

Expected security properties for an internet-exposed Gateway:

  • Only an authorized host can attach as role=host for a session.
  • Only authorized clients can claim, list, connect to, and control sessions.
  • Pairing codes should resist online guessing and should not be enumerable.
  • Gateway-level auth should be available for self-hosted deployments without Supabase/iTool subscription coupling.
  • Localhost tunnel exposure should be explicit and scoped, not any arbitrary port by default.
  • Rate limiting should not be bypassable by spoofed request headers.
  • Public docs should distinguish LAN/trusted use from internet-exposed use.

Trust boundaries

Relevant boundaries:

  • Public internet -> Gateway HTTP API.
  • Public internet -> Gateway WebSocket /ws.
  • Gateway -> host bridge WebSocket.
  • Gateway tunnel endpoint -> host 127.0.0.1:<port>.
  • Mobile app storage -> long-lived device token.
  • Reverse proxy/Funnel/CDN -> Gateway, including forwarded headers.

Threat actors

  • Anonymous internet scanner hitting exposed Gateway endpoints.
  • Remote attacker who can guess/brute-force a pairing code.
  • Attacker who obtains a QR/deep link, device token, logs, proxy access log, browser history entry, or tunnel URL.
  • Malicious/compromised paired mobile device.
  • Same-network attacker if the Gateway is exposed over LAN without TLS.
  • Reverse proxy misconfiguration or direct access to the Node Gateway port.

Findings

1. Self-hosted default mode has no general-purpose Gateway auth

AUTH_REQUIRED defaults to false in docker-compose.yml and docs describe this as suitable for self-hosted Gateway use:

  • docker-compose.yml: AUTH_REQUIRED=${AUTH_REQUIRED:-false}
  • docs/deploy.md: AUTH_REQUIRED=false is documented as "anyone can connect, suitable for self-hosted Gateway"
  • packages/gateway/src/auth-middleware.ts: AUTH_REQUIRED is only enabled by process.env.AUTH_REQUIRED === "true"

When enabled, auth is tied to Supabase/iTool subscription state:

  • requireAuth() validates a Supabase JWT and checks profiles.plan === "pro"
  • There is no simple self-hosted GATEWAY_TOKEN / shared secret / OIDC / trusted-proxy mode for private deployments.

Impact:

  • Public self-hosted deployments are effectively unauthenticated at the Gateway layer unless the operator adopts the official subscription/Supabase path.
  • Reverse-proxy path secrets can reduce scanning but are not first-class protocol authentication.

Recommended fix:

  • Add a self-hosted auth mode independent of Supabase, for example:
  • AUTH_MODE=none|shared-secret|oidc|supabase
  • GATEWAY_SHARED_SECRET=<high entropy>
  • Require this secret for POST /pairings, GET /pairings/:code/status, POST /pairings/claim, /sessions*, /ws, and /tunnel*.
  • Support the secret via Authorization: Bearer, WebSocket subprotocol/header where possible, or query parameter only as a fallback with clear logging warnings.

2. linkshell gateway --daemon uses the embedded Gateway, which is weaker than Docker standalone

The docs list CLI Gateway deployment as the simplest option. However, the CLI command imports @linkshell/gateway/embedded, not the standalone packages/gateway/src/index.ts server:

  • packages/cli/src/index.ts: linkshell gateway calls startEmbeddedGateway()
  • packages/gateway/src/embedded.ts: implements a separate HTTP/WS server
  • packages/gateway/src/index.ts: standalone Docker/server entrypoint contains rate limit logic

Important difference:

  • Standalone index.ts has PAIRING_RATE_LIMIT_MAX, WS_CONNECT_RATE_LIMIT_MAX, and AUTH_REQUIRED checks.
  • Embedded embedded.ts has no pairing rate limit, no WebSocket connection rate limit, and no AUTH_REQUIRED guard.

Impact:

  • Operators following the "simplest" deployment path may unintentionally expose the weakest Gateway variant publicly.

Recommended fix:

  • Do not use the embedded Gateway for public linkshell gateway --daemon.
  • Refactor CLI Gateway command to launch the same hardened standalone server implementation.
  • At minimum, document linkshell gateway --daemon as LAN/trusted-network only until parity exists.

3. Host WebSocket connections are not authenticated or bound to a pairing/device token in self-hosted mode

Client WebSocket connections require a token that owns the session:

  • packages/gateway/src/index.ts: client path checks tokenManager.owns(token, sessionId)
  • packages/gateway/src/embedded.ts: client path checks tokenManager.owns(token, sessionId)

Host WebSocket connections do not require an equivalent host secret in self-hosted mode:

  • packages/gateway/src/index.ts: role === "host" calls sessionManager.setHost(sessionId, device)
  • packages/gateway/src/embedded.ts: same behavior

Impact:

  • Possession or disclosure of a sessionId may be enough for an attacker to attach as/replacement host for that session on a self-hosted Gateway.
  • A malicious host connection can confuse clients, inject terminal output, disrupt reconnect behavior, or DoS the real host.
  • In authenticated official mode this is partly mitigated by Supabase auth, but self-hosted mode has no analogous host credential.

Recommended fix:

  • Generate a separate host secret at pairing creation and require it for role=host WebSocket connections.
  • Bind the host secret to the session in PairingRecord or a new SessionAuthRecord.
  • Reject any host connection without the correct host secret.
  • On reconnect, require the same host secret and optionally same host device ID.
  • Add tests for unauthorized host takeover attempts.

4. Pairing code status endpoint is unauthenticated and appears unrate-limited

GET /pairings/:code/status returns whether a 6-digit code exists and returns the associated sessionId if it does:

  • packages/gateway/src/index.ts: route is before/after auth depending AUTH_REQUIRED, but in default mode it is public
  • packages/gateway/src/pairings.ts: getStatus() returns status, expiresAt, and sessionId
  • packages/gateway/src/embedded.ts: same public status endpoint, no rate limiter

Impact:

  • Attackers can enumerate valid pairing codes and learn session IDs.
  • Because codes are 6 digits and valid for 10 minutes by default, enumeration should be explicitly defended against.
  • This also helps target the unauthenticated host attach issue above.

Recommended fix:

  • Require Gateway auth for pairing status.
  • Rate-limit GET /pairings/:code/status separately.
  • Avoid returning sessionId until a code is successfully claimed by an authenticated client.
  • Consider replacing 6-digit public codes with higher-entropy single-use pairing tokens in QR/deep links, while keeping manual 6-digit pairing behind stricter online limits.

5. Rate limiting trusts X-Forwarded-For and can be bypassed if the Node port is directly reachable

getClientIp() uses the first X-Forwarded-For value if present:

  • packages/gateway/src/index.ts: getClientIp() returns forwarded.split(",")[0]
  • isRateLimitBypassed() bypasses limits for loopback IPs

Impact:

  • If the Node Gateway port is directly reachable, a client can spoof X-Forwarded-For: 127.0.0.1 and bypass pairing/WebSocket rate limits.
  • Even behind a proxy, trusting arbitrary forwarded headers should be conditional on the remote peer being a configured trusted proxy.

Recommended fix:

  • Ignore X-Forwarded-For by default.
  • Add TRUST_PROXY=true and TRUSTED_PROXY_CIDRS before honoring forwarded headers.
  • Never allow a request-supplied forwarded header to create loopback bypass unless the immediate peer is trusted.
  • Add tests for spoofed X-Forwarded-For.

6. Tunnel endpoint exposes arbitrary host localhost ports to any authorized session token

Tunnel path accepts arbitrary ports from 1 to 65535:

  • packages/gateway/src/tunnel.ts: parseTunnelPath() accepts (\d+) with only 1..65535 validation
  • packages/cli/src/runtime/bridge-session.ts: handleTunnelRequest() proxies to hostname: "127.0.0.1", port
  • apps/mobile/src/components/BrowserView.tsx: UI allows manually entering any port in the same range

Impact:

  • If a device token is compromised or a malicious client pairs, it can proxy arbitrary services bound to host localhost.
  • This may expose dev servers, database admin UIs, Docker APIs, cloud metadata proxies, local dashboards, or other services that assume localhost-only trust.
  • The tunnel also sets a cookie for fallback subresources. This makes browsing convenient, but broadens the exposure surface once a tunnel URL is opened externally.

Recommended fix:

  • Add a Gateway/CLI option to disable tunnels entirely, for example LINKSHELL_TUNNEL_ENABLED=false.
  • Add an explicit allowlist, for example LINKSHELL_TUNNEL_PORTS=3000,5173.
  • Require host-side confirmation before first exposing a port.
  • Consider denying common sensitive ports by default.
  • Make the mobile UI reflect allowed ports instead of allowing 1..65535.

7. Long-lived device tokens have broad authority and limited revocation model

Device tokens:

  • Are generated/accepted in TokenManager.register()
  • Bind to sessions in TokenManager.bind()
  • Live until SESSION_TTL of 7 days of inactivity
  • Can own multiple session IDs
  • Are stored by the mobile app in AsyncStorage

Impact:

  • A leaked device token is enough to reconnect to owned sessions and use /tunnel.
  • There is no obvious user-facing revocation per device or per session.
  • Token exposure is possible through query strings, logs, deep links, browser history, WebView debugging, or proxy logs, especially for tunnel URLs.

Recommended fix:

  • Prefer secure storage for mobile device tokens where available.
  • Add explicit token revocation APIs.
  • Scope tokens to one session by default, or require explicit account-level trust to reuse one token across sessions.
  • Avoid putting tokens in URLs where possible; use headers or WebSocket auth protocols.
  • Add session/device management UI for deleting trusted devices.

8. Payload and resource limits are permissive for public exposure

The Gateway allows:

  • WebSocket payloads up to 50 MB
  • Tunnel request bodies up to 10 MB
  • Output buffers per terminal but no obvious global session/client cap
  • Long-lived WebSocket connections

Impact:

  • A public Gateway is susceptible to memory, bandwidth, and connection exhaustion even if functional auth is added.

Recommended fix:

  • Add global and per-IP connection caps.
  • Add per-session client caps.
  • Add configurable MAX_WS_MESSAGE_SIZE, MAX_TUNNEL_BODY, max concurrent tunnel requests, and max sessions.
  • Apply backpressure handling for WebSocket sends.
  • Add basic abuse metrics/logging.

9. CORS is wide open

Gateway responses set:

  • Access-Control-Allow-Origin: *
  • broad methods and authorization headers

Impact:

  • This is not automatically a vulnerability because sensitive endpoints should require tokens, but it makes browser-origin abuse easier when tokens are stored or copied into browser-accessible contexts.
  • Public self-hosted deployments should have an option to restrict origins.

Recommended fix:

  • Add CORS_ORIGINS allowlist for production.
  • Default to permissive only for local/dev mode.

Example deployment-specific risk

A common private deployment need is:

  • Gateway host has Tailscale/Funnel.
  • The computer running linkshell start does not have Tailscale.
  • Both the CLI host and the mobile app connect to a public HTTPS/WSS Gateway URL.

In that model, the Gateway URL must be reachable from the public internet. The current self-hosted mode relies mostly on the short pairing code and device token, while allowing public pairing creation and unauthenticated host attachment. A reverse proxy with a high-entropy path prefix can reduce internet scanning, but it is not a replacement for first-class Gateway authentication because the secret is placed in URLs and logs.

Suggested remediation plan

Priority 0: documentation warnings

  • Mark linkshell gateway --daemon as LAN/trusted-only until it shares the hardened standalone path.
  • Update docs/deploy.md to say internet-exposed self-hosted Gateways should be behind HTTPS, auth, rate limits, and ideally tunnels disabled/allowlisted.
  • Clarify that AUTH_REQUIRED=false means public anonymous Gateway API access.

Priority 1: auth and pairing hardening

  • Add self-hosted AUTH_MODE=shared-secret.
  • Require Gateway auth on all pairing/session/tunnel/WS endpoints.
  • Add a host secret and require it for role=host.
  • Rate-limit pairing status and claim attempts robustly.
  • Stop returning sessionId from pairing status before authenticated claim.

Priority 2: tunnel safety

  • Add TUNNEL_ENABLED=false support.
  • Add TUNNEL_ALLOWED_PORTS.
  • Add host confirmation before exposing a new port.
  • Avoid storing/propagating tunnel auth in query parameters when possible.

Priority 3: abuse resistance

  • Fix trusted proxy/IP handling.
  • Add connection/session/tunnel caps.
  • Make size limits configurable and lower defaults for public deployments.
  • Add logs/metrics for rejected auth, rate limits, host reconnects, and tunnel access.

Priority 4: tests

  • Test unauthorized host takeover is rejected.
  • Test pairing status enumeration is rate-limited/authenticated.
  • Test spoofed X-Forwarded-For: 127.0.0.1 does not bypass limits.
  • Test /tunnel rejects non-allowlisted ports.
  • Test CLI linkshell gateway and Docker Gateway share the same auth/rate-limit behavior or document the difference explicitly.

Acceptance criteria

  • A self-hosted operator can expose the Gateway publicly without depending on Supabase/iTool auth.
  • A random internet client cannot create pairings, enumerate codes, claim codes, attach as host, connect as client, or access tunnels without Gateway auth.
  • A leaked pairing code alone is not sufficient for host takeover.
  • Tunnels are disabled by default for public deployments or restricted to explicit ports.
  • Docs clearly separate local/LAN convenience mode from public Gateway mode.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions