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.
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
be22e1eafa823d65c21c887fe095b57f9e2f964a@linkshell/gateway0.2.29packages/gateway/src/index.tspackages/gateway/src/embedded.tspackages/gateway/src/pairings.tspackages/gateway/src/tokens.tspackages/gateway/src/tunnel.tspackages/gateway/src/relay.tspackages/cli/src/runtime/bridge-session.tsapps/mobile/src/hooks/useSessionManager.tsapps/mobile/src/components/BrowserView.tsxdocs/deploy.mddocker-compose.ymlAssets and security goals
Important assets protected by the Gateway:
/tunnel/:sessionId/:port/**.Expected security properties for an internet-exposed Gateway:
role=hostfor a session.Trust boundaries
Relevant boundaries:
/ws.127.0.0.1:<port>.Threat actors
Findings
1. Self-hosted default mode has no general-purpose Gateway auth
AUTH_REQUIREDdefaults tofalseindocker-compose.ymland docs describe this as suitable for self-hosted Gateway use:docker-compose.yml:AUTH_REQUIRED=${AUTH_REQUIRED:-false}docs/deploy.md:AUTH_REQUIRED=falseis documented as "anyone can connect, suitable for self-hosted Gateway"packages/gateway/src/auth-middleware.ts:AUTH_REQUIREDis only enabled byprocess.env.AUTH_REQUIRED === "true"When enabled, auth is tied to Supabase/iTool subscription state:
requireAuth()validates a Supabase JWT and checksprofiles.plan === "pro"GATEWAY_TOKEN/ shared secret / OIDC / trusted-proxy mode for private deployments.Impact:
Recommended fix:
AUTH_MODE=none|shared-secret|oidc|supabaseGATEWAY_SHARED_SECRET=<high entropy>POST /pairings,GET /pairings/:code/status,POST /pairings/claim,/sessions*,/ws, and/tunnel*.Authorization: Bearer, WebSocket subprotocol/header where possible, or query parameter only as a fallback with clear logging warnings.2.
linkshell gateway --daemonuses the embedded Gateway, which is weaker than Docker standaloneThe docs list CLI Gateway deployment as the simplest option. However, the CLI command imports
@linkshell/gateway/embedded, not the standalonepackages/gateway/src/index.tsserver:packages/cli/src/index.ts:linkshell gatewaycallsstartEmbeddedGateway()packages/gateway/src/embedded.ts: implements a separate HTTP/WS serverpackages/gateway/src/index.ts: standalone Docker/server entrypoint contains rate limit logicImportant difference:
index.tshasPAIRING_RATE_LIMIT_MAX,WS_CONNECT_RATE_LIMIT_MAX, andAUTH_REQUIREDchecks.embedded.tshas no pairing rate limit, no WebSocket connection rate limit, and noAUTH_REQUIREDguard.Impact:
Recommended fix:
linkshell gateway --daemon.linkshell gateway --daemonas 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 checkstokenManager.owns(token, sessionId)packages/gateway/src/embedded.ts: client path checkstokenManager.owns(token, sessionId)Host WebSocket connections do not require an equivalent host secret in self-hosted mode:
packages/gateway/src/index.ts:role === "host"callssessionManager.setHost(sessionId, device)packages/gateway/src/embedded.ts: same behaviorImpact:
sessionIdmay be enough for an attacker to attach as/replacement host for that session on a self-hosted Gateway.Recommended fix:
role=hostWebSocket connections.PairingRecordor a newSessionAuthRecord.4. Pairing code status endpoint is unauthenticated and appears unrate-limited
GET /pairings/:code/statusreturns whether a 6-digit code exists and returns the associatedsessionIdif it does:packages/gateway/src/index.ts: route is before/after auth dependingAUTH_REQUIRED, but in default mode it is publicpackages/gateway/src/pairings.ts:getStatus()returnsstatus,expiresAt, andsessionIdpackages/gateway/src/embedded.ts: same public status endpoint, no rate limiterImpact:
Recommended fix:
GET /pairings/:code/statusseparately.sessionIduntil a code is successfully claimed by an authenticated client.5. Rate limiting trusts
X-Forwarded-Forand can be bypassed if the Node port is directly reachablegetClientIp()uses the firstX-Forwarded-Forvalue if present:packages/gateway/src/index.ts:getClientIp()returnsforwarded.split(",")[0]isRateLimitBypassed()bypasses limits for loopback IPsImpact:
X-Forwarded-For: 127.0.0.1and bypass pairing/WebSocket rate limits.Recommended fix:
X-Forwarded-Forby default.TRUST_PROXY=trueandTRUSTED_PROXY_CIDRSbefore honoring forwarded headers.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 validationpackages/cli/src/runtime/bridge-session.ts:handleTunnelRequest()proxies tohostname: "127.0.0.1", portapps/mobile/src/components/BrowserView.tsx: UI allows manually entering any port in the same rangeImpact:
Recommended fix:
LINKSHELL_TUNNEL_ENABLED=false.LINKSHELL_TUNNEL_PORTS=3000,5173.7. Long-lived device tokens have broad authority and limited revocation model
Device tokens:
TokenManager.register()TokenManager.bind()SESSION_TTLof 7 days of inactivityImpact:
/tunnel.Recommended fix:
8. Payload and resource limits are permissive for public exposure
The Gateway allows:
Impact:
Recommended fix:
MAX_WS_MESSAGE_SIZE,MAX_TUNNEL_BODY, max concurrent tunnel requests, and max sessions.9. CORS is wide open
Gateway responses set:
Access-Control-Allow-Origin: *Impact:
Recommended fix:
CORS_ORIGINSallowlist for production.Example deployment-specific risk
A common private deployment need is:
linkshell startdoes not have Tailscale.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
linkshell gateway --daemonas LAN/trusted-only until it shares the hardened standalone path.docs/deploy.mdto say internet-exposed self-hosted Gateways should be behind HTTPS, auth, rate limits, and ideally tunnels disabled/allowlisted.AUTH_REQUIRED=falsemeans public anonymous Gateway API access.Priority 1: auth and pairing hardening
AUTH_MODE=shared-secret.role=host.sessionIdfrom pairing status before authenticated claim.Priority 2: tunnel safety
TUNNEL_ENABLED=falsesupport.TUNNEL_ALLOWED_PORTS.Priority 3: abuse resistance
Priority 4: tests
X-Forwarded-For: 127.0.0.1does not bypass limits./tunnelrejects non-allowlisted ports.linkshell gatewayand Docker Gateway share the same auth/rate-limit behavior or document the difference explicitly.Acceptance criteria