#1566·ttyd

`--check-origin` can never pass over HTTP/2 (`check_host_origin` reads `WSI_TOKEN_HOST`, which h2 does not have)

Author: johanneshauerCreated Aug 11, 2026Updated Aug 11, 2026

With --ssl and --check-origin, ttyd advertises ALPN h2 and sets SETTINGS_ENABLE_CONNECT_PROTOCOL, so Chrome and Edge open the terminal WebSocket as an RFC 8441 extended CONNECT on the existing HTTP/2 session. check_host_origin() compares Origin against the Host header — but HTTP/2 has no Host header; RFC 9113 §8.3.1 carries the authority in the :authority pseudo-header, and libwebsockets keeps the two in separate token slots. The check therefore cannot pass over h2, no matter what Origin says, and every handshake is refused with:

refuse to serve WS client from different origin due to the --check-origin option.

It self-heals, which is why it is easy to miss: ttyd's own secs_since_valid_ping = 5 (src/server.c:44-50, wired at :544) hangs up the idle h2 connection five seconds later, the browser reconnects with no h2 session to reuse, upgrades over HTTP/1.1, and succeeds. So the visible symptom is only that every browser connection takes ~5 s and logs one refusal — until a reconnect happens on an already-open page, where the recovery is less reliable and the page can strand on the reconnect overlay.

Environment: ttyd 1.7.7 (Ubuntu 1.7.7-4build1), libwebsockets 4.3.5 with H1 H2 WS. Code quoted below is from main today, not the packaged build.

Reproduce

Any TLS-enabled ttyd, reached with a browser that speaks h2:

bash
ttyd --ssl --ssl-cert cert.pem --ssl-key key.pem --check-origin --credential u:p /bin/cat

Open https://127.0.0.1:7681/ in Chrome. The log shows one refuse to serve WS client from different origin per page load, then a successful WS /ws about five seconds later. openssl s_client -alpn h2,http/1.1 -connect 127.0.0.1:7681 confirms ALPN protocol: h2 is what the browser gets offered.

Isolating it to the transport — same server, same matching Origin, both attempts made with a hand-rolled client:

Transport Origin Result
HTTP/1.1 upgrade https://127.0.0.1:7681 101 Switching Protocols
HTTP/2 extended CONNECT, :authority = 127.0.0.1:7681 https://127.0.0.1:7681 refused

Cause

src/protocol.c, current main:

c
  char host_buf[256];
  memset(host_buf, 0, sizeof(host_buf));
  len = lws_hdr_copy(wsi, host_buf, (int)sizeof(host_buf), WSI_TOKEN_HOST);

  return len > 0 && strcasecmp(buf, host_buf) == 0;

Over h2 lws populates WSI_TOKEN_HTTP_COLON_AUTHORITY and never WSI_TOKEN_HOST (lib/roles/h2/hpack.c maps HPACK static index 1 to the former; nothing aliases it onto the latter), so lws_hdr_copy returns 0 and the function returns false before the comparison is even reached.

Suggested fix

diff
   char host_buf[256];
   memset(host_buf, 0, sizeof(host_buf));
-  len = lws_hdr_copy(wsi, host_buf, (int)sizeof(host_buf), WSI_TOKEN_HOST);
+  len = 0;
+#if defined(LWS_ROLE_H2)
+  /* HTTP/2 has no Host header; RFC 9113 8.3.1 puts the authority in :authority.
+   * Prefer the pseudo-header: RFC 9113 permits a request to carry both, and lws
+   * does not enforce that they agree, so reading Host first would let a forged
+   * duplicate satisfy the check. Over h1 :authority is never populated and the
+   * fallback below always fires. */
+  len = lws_hdr_copy(wsi, host_buf, (int)sizeof(host_buf),
+                     WSI_TOKEN_HTTP_COLON_AUTHORITY);
+#endif
+  if (len <= 0)
+    len = lws_hdr_copy(wsi, host_buf, (int)sizeof(host_buf), WSI_TOKEN_HOST);
 
   return len > 0 && strcasecmp(buf, host_buf) == 0;

The guard is load-bearing rather than defensive: WSI_TOKEN_HTTP_COLON_AUTHORITY is itself declared inside #if defined(LWS_ROLE_H2) || defined(LWS_HTTP_HEADERS_ALL) in include/libwebsockets/lws-http.h, so naming it unconditionally would fail to compile against an lws built without h2. LWS_ROLE_H2 is a #cmakedefine in the public cmake/lws_config.h.in, so it is visible to consumers.

One thing this deliberately does not change: the authority side is compared unnormalized, while the Origin side already has its default port stripped by the port == 80 || port == 443 branch above. That asymmetry is pre-existing — it applies to Host on HTTP/1.1 today — and browsers omit default ports in both Origin (RFC 6454) and :authority, so it does not bite in practice. It would bite behind an intermediary that spells the authority example.com:443, and that is where to add normalization if you want it; I have kept it out of this patch to keep the change to the actual defect.

I have not sent this as a PR because I have only tested the reasoning and the h1/h2 behaviour, not a build with the patch applied — happy to open one if the approach looks right to you.


Three smaller things in the same function

Independent of the above; all fail closed, so none is a security hole. Reported because they are two minutes' reading away from the fix.

1. port is read uninitialised for schemes lws does not know. int port; is declared uninitialised and lws_parse_uri() (lws lib/core-net/wsi.c) only assigns it for http/ws/https/wss or when an explicit :port is present:

c
	if (!strcmp(*prot, "http") || !strcmp(*prot, "ws"))
		*port = 80;
	else if (!strcmp(*prot, "https") || !strcmp(*prot, "wss"))
		*port = 443;

Browsers send Origin: null for sandboxed iframes, file:// documents and some redirect chains. That parses to prot="", ads="null", and port untouched — so if (port == 80 || port == 443) and the %d below it read an indeterminate value.

The existing if (lws_parse_uri(...)) return false; does not save it: in 4.3.5 lws_parse_uri() has exactly one return statement, return 0, so it never reports failure and that branch is unreachable. Initialising int port = 0; is enough, since a null origin then compares as null:0 and is correctly rejected.

2. The snprintf overlaps its own source buffer. lws_parse_uri() parses in place, so address points into buf; both branches then write back into buf:

c
    snprintf(buf, sizeof(buf), "%s:%d", address, port);

Copying between overlapping objects is undefined (C11 §7.21.6.5). It happens to work with glibc because the destination precedes the source, but a char tmp[256] would cost nothing. (The sprintfsnprintf sweep fixed the truncation risk here, not the overlap.)

3. IPv6-literal origins can never match. lws_parse_uri() strips the brackets — if (*p == '[') { ++(*ads); … } — so Origin: https://[::1]:7681 yields address = ::1 and the comparison string ::1:7681, while the Host/:authority value is [::1]:7681. --check-origin rejects every IPv6-literal origin.

Two observations, no action requested

  • A refused h2 stream gets no response at all — and this one looks like it belongs to libwebsockets rather than to ttyd, so I mention it only because it is what makes the bug above present as a stall rather than a rejection. Returning non-zero from LWS_CALLBACK_FILTER_PROTOCOL_CONNECTION is the documented way to refuse a handshake, and on HTTP/1.1 it closes the connection promptly. On an RFC 8441 extended CONNECT stream the client instead sees no HEADERS, no RST_STREAM and no GOAWAY until the 5 s idle hangup takes the whole connection down. It does not appear to be a missing RST_STREAM so much as an unflushed one: in my captures the reset only reached the wire once unrelated inbound traffic forced a write on the connection. Fixing the origin check does not address it — it just stops legitimate clients reaching it. I can open this separately against libwebsockets with the captures if that is useful.
  • The credential is written to the log at every start. src/server.c:142: lwsl_notice(" credential: %s\n", server->credential) — base64 of user:password, at NOTICE, so it lands in the journal by default. credential: (set) would keep the diagnostic value. This is in addition to the /proc/<pid>/cmdline exposure, which is inherent to passing it as an argument.

Thanks for ttyd — it has been running my dev workspace for months.