mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-10-30 21:47:01 +03:00
[PR #1097] [CLOSED] fix(raw_websocket): enable TLS chain verification for outbound WS (CWE-295) #89
Reference in new issue
Block a user
📋 Pull Request Information
Original PR: https://github.com/Flowseal/tg-ws-proxy/pull/1097
Author: @andesyteoss
Created: 7/5/2026
Status: ❌ Closed
Base:
main← Head:fix/cwe295-raw-websocket-tls-8dbf📝 Commits (1)
4f21501security: enable TLS chain verification for outbound WS📊 Changes
1 file changed (+14 additions, -1 deletions)
View changed files
📝
proxy/raw_websocket.py(+14 -1)📄 Description
Summary
The outbound WebSocket TLS context in
proxy/raw_websocket.pydisables both hostname checking and certificate chain verification:ssl.create_default_context()returns a context withverify_mode = CERT_REQUIRED. The third line then downgrades it toCERT_NONE, so any certificate presented by the TLS peer — including a freshly-generated self-signed cert — is accepted on every outbound connection made fromRawWebSocket.connect()(proxy/raw_websocket.py:96-105,asyncio.open_connection(..., ssl=_ssl_ctx, ...)).This context is used for every
RawWebSocket.connect()call, which is how the proxy dials Telegram's DC IPs (149.154.167.x:443) both in the normal path and in the domain-fronting fallback (proxy/pool.py:84,proxy/tg_ws_proxy.py:352,proxy/tg_ws_proxy.py:408).Classification: CWE-295 — Improper Certificate Validation.
Fix
Remove the
verify_mode = CERT_NONEline so thatverify_moderetains theCERT_REQUIREDdefault fromcreate_default_context().check_hostnameis intentionally leftFalse— see the comment added in the diff — because the domain-fronting fallback deliberately setsserver_hostname=sprinthost.ruwhile the WSHost:header targetskwsN.web.telegram.org; forcing a strict hostname match would break fronting. Chain validation is orthogonal to hostname matching and is what the fix restores.Diff is 1 file, +14/-1 (12 of the added lines are the block comment explaining the invariant):
Security impact
The proxy dials Telegram's DCs on port 443 wrapped in TLS. Immediately after the WebSocket upgrade completes, the proxy sends the
relay_initblob it built atproxy/tg_ws_proxy.py:295-298(seebridge.py:210/bridge.py:246—await ws.send(relay_init)). That blob contains the AES-256/CTR key and IV used to obfuscate the transport (proxy/bridge.py:41-43).With
CERT_NONE, an attacker on the network path between the proxy host and the DC IP (rogue Wi-Fi AP, DPI middlebox, malicious upstream ISP) can:relay_initkey material sent right after upgrade — those AES keys are what the proxy uses to un-obfuscate MTProto frames, so the attacker sees the framed MTProto stream.auth_key_id(a stable per-account identifier), message sizes, and timing on every message.The inner MTProto encryption still protects message contents between client and DC — the attacker does not read plaintext messages. But
auth_key_idis enough to fingerprint and track individual accounts, and this proxy is deployed primarily by users in networks that are actively adversarial to MTProto (README positions it as a Telegram-access tool in restricted networks). Bypassing censorship-network TLS should not also make the traffic trivially attributable to that network.Note: the fix does not close the entire MitM threat — an attacker who can obtain a publicly-trusted certificate (e.g. by issuing a Let's Encrypt cert for a domain they control) can still MitM if they can also cause the connection to reach their server. But the current
CERT_NONEstate accepts a self-signed cert generated in one command, which is the actual capability that off-the-shelf DPI boxes have. The fix raises the bar from "any cert" to "a cert from a public CA for a name the attacker controls", which materially changes the attacker class.Proof of concept
Minimal reproducer — four lines, no test framework needed. Run from the repo root:
Full differential test (spins up a local TLS server with a self-signed cert, wraps a socket with the production
_ssl_ctx, asserts the handshake fails; then generates a throw-away CA + leaf, loads the CA into the context, asserts the handshake succeeds — proving fronting still works):Result on
main(pre-fix):Result on this branch:
The critical case is
self_signed_rejected— onmainit silently accepts the attacker's cert, which is the vulnerability in one line. Happy to attach the full ~200-line test script as a follow-up comment if you'd like to run it locally; it depends only oncryptography(already installed alongside most Python stacks).What I considered before submitting
Before opening this, I tried to talk myself out of it:
relay_initblob andauth_key_idare visible at the WS/obfuscation layer, and the proxy's whole premise is that its network is untrusted. Leaking a stable per-account identifier to that network defeats a large part of the point.check_hostname=Falsestill off, so the fix is cosmetic?" No — an attacker without a certificate signed by a public CA still fails the chain check. Self-signed / rogue-CA MitM (the common DPI/corporate case) is blocked. That's a real reduction in attack surface even with hostname checking off.check_hostname=False(so SNI≠Host is allowed). The CA chain of the presented cert is unrelated to the Host header and is still checked. I verified this with thetrusted_cert_acceptedtest, which uses a mismatched hostname and still succeeds against a properly-chained cert.ui/ctk_tray_ui.pystill usingCERT_NONE?" Yes (line 49-50), but that path is a synchronous connectivity probe that reads only the HTTP 101 status line — norelay_init, no MTProto flows through it. Intentionally out of scope for this PR to keep the diff minimal. Happy to follow up with a separate PR if you'd like it hardened too.Testing
main(fails 0/3) and this branch (passes 3/3)._ssl_ctx.verify_modeat Python startup:ssl.CERT_REQUIRED(value2) after fix.check_hostnameremainsFalseso the fronting path (sni="sprinthost.ru"inpool.py/tg_ws_proxy.py) continues to work.Thanks for maintaining this project — happy to iterate on the wording of the comment or the scope if you'd like.
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.
[PR #1097] [CLOSED] fix(raw_websocket): enable TLS chain verification for outbound WS (CWE-295)to [PR #1097] fix(raw_websocket): enable TLS chain verification for outbound WS (CWE-295)[PR #1097] fix(raw_websocket): enable TLS chain verification for outbound WS (CWE-295)to [PR #1097] [CLOSED] fix(raw_websocket): enable TLS chain verification for outbound WS (CWE-295)