`--check-origin` can never pass over HTTP/2 (`check_host_origin` reads `WSI_TOKEN_HOST`, which h2 does not have)
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:
ttyd --ssl --ssl-cert cert.pem --ssl-key key.pem --check-origin --credential u:p /bin/catOpen 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:
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
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:
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:
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 sprintf→snprintf 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_CONNECTIONis 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, noRST_STREAMand no GOAWAY until the 5 s idle hangup takes the whole connection down. It does not appear to be a missingRST_STREAMso 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 ofuser: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>/cmdlineexposure, which is inherent to passing it as an argument.
Thanks for ttyd — it has been running my dev workspace for months.
Source: tsl0922/ttyd