[PR #1097] [CLOSED] fix(raw_websocket): enable TLS chain verification for outbound WS (CWE-295) #89

Closed
opened 2026-07-19 11:34:54 +03:00 by mirror-bot · 0 comments

📋 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)

  • 4f21501 security: 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.py disables both hostname checking and certificate chain verification:

_ssl_ctx = ssl.create_default_context()
_ssl_ctx.check_hostname = False
_ssl_ctx.verify_mode = ssl.CERT_NONE   # <-- this line

ssl.create_default_context() returns a context with verify_mode = CERT_REQUIRED. The third line then downgrades it to CERT_NONE, so any certificate presented by the TLS peer — including a freshly-generated self-signed cert — is accepted on every outbound connection made from RawWebSocket.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_NONE line so that verify_mode retains the CERT_REQUIRED default from create_default_context(). check_hostname is intentionally left False — see the comment added in the diff — because the domain-fronting fallback deliberately sets server_hostname=sprinthost.ru while the WS Host: header targets kwsN.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):

@@ -21,9 +21,22 @@
 _st_H = struct.Struct('>H')
 _st_Q = struct.Struct('>Q')

+# Outbound WebSocket TLS context.
+#
+# ``check_hostname`` is disabled because the domain-fronting fallback
+# deliberately sets ``server_hostname`` to a different domain than the
+# HTTP ``Host`` header (e.g. SNI=``sprinthost.ru``, Host=``kwsN.web.telegram.org``),
+# so a strict hostname match is not applicable to every code path.
+#
+# ``verify_mode`` MUST remain ``CERT_REQUIRED`` so that the CA chain
+# of the presented certificate is validated on every outbound
+# connection. …
 _ssl_ctx = ssl.create_default_context()
 _ssl_ctx.check_hostname = False
-_ssl_ctx.verify_mode = ssl.CERT_NONE

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_init blob it built at proxy/tg_ws_proxy.py:295-298 (see bridge.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:

  1. Present a self-signed certificate; the TLS handshake completes successfully.
  2. Recover the relay_init key 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.
  3. Observe frame metadata: 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_id is 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_NONE state 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:

import ssl
from proxy.raw_websocket import _ssl_ctx
# on main:  _ssl_ctx.verify_mode is ssl.CERT_NONE  -> any cert accepted
# on this branch: _ssl_ctx.verify_mode is ssl.CERT_REQUIRED  -> chain validated
assert _ssl_ctx.verify_mode == ssl.CERT_REQUIRED, "CWE-295 not fixed"

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):

FAIL  verify_mode_still_required: regression: verify_mode is <VerifyMode.CERT_NONE: 0> (want CERT_REQUIRED=2) — CWE-295 bug is back
FAIL  self_signed_rejected: TLS handshake succeeded against a SELF-SIGNED cert — the CWE-295 fix is not effective.
FAIL  trusted_cert_accepted: no peer cert after successful handshake

summary: 0 passed, 3 failed

Result on this branch:

PASS  verify_mode_still_required
PASS  self_signed_rejected
PASS  trusted_cert_accepted

summary: 3 passed, 0 failed

The critical case is self_signed_rejected — on main it 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 on cryptography (already installed alongside most Python stacks).

What I considered before submitting

Before opening this, I tried to talk myself out of it:

  • "Isn't MTProto already end-to-end encrypted?" Yes for message contents — but the relay_init blob and auth_key_id are 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.
  • "Isn't check_hostname=False still 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.
  • "Doesn't fronting need to disable verify?" Fronting only needs 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 the trusted_cert_accepted test, which uses a mismatched hostname and still succeeds against a properly-chained cert.
  • "Is ui/ctk_tray_ui.py still using CERT_NONE?" Yes (line 49-50), but that path is a synchronous connectivity probe that reads only the HTTP 101 status line — no relay_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

  • Ran the differential test on both main (fails 0/3) and this branch (passes 3/3).
  • Verified _ssl_ctx.verify_mode at Python startup: ssl.CERT_REQUIRED (value 2) after fix.
  • Verified check_hostname remains False so the fronting path (sni="sprinthost.ru" in pool.py/tg_ws_proxy.py) continues to work.
  • No changes to any function signatures, no new dependencies, no behaviour change on connections to properly-configured Telegram DCs.

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.

## 📋 Pull Request Information **Original PR:** https://github.com/Flowseal/tg-ws-proxy/pull/1097 **Author:** [@andesyteoss](https://github.com/andesyteoss) **Created:** 7/5/2026 **Status:** ❌ Closed **Base:** `main` ← **Head:** `fix/cwe295-raw-websocket-tls-8dbf` --- ### 📝 Commits (1) - [`4f21501`](https://github.com/Flowseal/tg-ws-proxy/commit/4f21501fb8eb00e8ddf0904bd0da821b8ba214c9) security: enable TLS chain verification for outbound WS ### 📊 Changes **1 file changed** (+14 additions, -1 deletions) <details> <summary>View changed files</summary> 📝 `proxy/raw_websocket.py` (+14 -1) </details> ### 📄 Description ## Summary The outbound WebSocket TLS context in `proxy/raw_websocket.py` disables **both** hostname checking *and* certificate chain verification: ```python _ssl_ctx = ssl.create_default_context() _ssl_ctx.check_hostname = False _ssl_ctx.verify_mode = ssl.CERT_NONE # <-- this line ``` `ssl.create_default_context()` returns a context with `verify_mode = CERT_REQUIRED`. The third line then downgrades it to `CERT_NONE`, so **any** certificate presented by the TLS peer — including a freshly-generated self-signed cert — is accepted on every outbound connection made from `RawWebSocket.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_NONE` line so that `verify_mode` retains the `CERT_REQUIRED` default from `create_default_context()`. `check_hostname` is intentionally left `False` — see the comment added in the diff — because the domain-fronting fallback deliberately sets `server_hostname=sprinthost.ru` while the WS `Host:` header targets `kwsN.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): ```diff @@ -21,9 +21,22 @@ _st_H = struct.Struct('>H') _st_Q = struct.Struct('>Q') +# Outbound WebSocket TLS context. +# +# ``check_hostname`` is disabled because the domain-fronting fallback +# deliberately sets ``server_hostname`` to a different domain than the +# HTTP ``Host`` header (e.g. SNI=``sprinthost.ru``, Host=``kwsN.web.telegram.org``), +# so a strict hostname match is not applicable to every code path. +# +# ``verify_mode`` MUST remain ``CERT_REQUIRED`` so that the CA chain +# of the presented certificate is validated on every outbound +# connection. … _ssl_ctx = ssl.create_default_context() _ssl_ctx.check_hostname = False -_ssl_ctx.verify_mode = ssl.CERT_NONE ``` ## 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_init` blob it built at `proxy/tg_ws_proxy.py:295-298` (see `bridge.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: 1. Present a self-signed certificate; the TLS handshake completes successfully. 2. Recover the `relay_init` key 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. 3. Observe frame metadata: `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_id` is 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_NONE` state 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: ```python import ssl from proxy.raw_websocket import _ssl_ctx # on main: _ssl_ctx.verify_mode is ssl.CERT_NONE -> any cert accepted # on this branch: _ssl_ctx.verify_mode is ssl.CERT_REQUIRED -> chain validated assert _ssl_ctx.verify_mode == ssl.CERT_REQUIRED, "CWE-295 not fixed" ``` 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): ``` FAIL verify_mode_still_required: regression: verify_mode is <VerifyMode.CERT_NONE: 0> (want CERT_REQUIRED=2) — CWE-295 bug is back FAIL self_signed_rejected: TLS handshake succeeded against a SELF-SIGNED cert — the CWE-295 fix is not effective. FAIL trusted_cert_accepted: no peer cert after successful handshake summary: 0 passed, 3 failed ``` Result on this branch: ``` PASS verify_mode_still_required PASS self_signed_rejected PASS trusted_cert_accepted summary: 3 passed, 0 failed ``` The critical case is `self_signed_rejected` — on `main` it 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 on `cryptography` (already installed alongside most Python stacks). ## What I considered before submitting Before opening this, I tried to talk myself out of it: - **"Isn't MTProto already end-to-end encrypted?"** Yes for message contents — but the `relay_init` blob and `auth_key_id` are 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. - **"Isn't `check_hostname=False` still 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. - **"Doesn't fronting need to disable verify?"** Fronting only needs `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 the `trusted_cert_accepted` test, which uses a mismatched hostname and still succeeds against a properly-chained cert. - **"Is `ui/ctk_tray_ui.py` still using `CERT_NONE`?"** Yes (line 49-50), but that path is a synchronous connectivity probe that reads only the HTTP 101 status line — no `relay_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 - Ran the differential test on both `main` (fails 0/3) and this branch (passes 3/3). - Verified `_ssl_ctx.verify_mode` at Python startup: `ssl.CERT_REQUIRED` (value `2`) after fix. - Verified `check_hostname` remains `False` so the fronting path (`sni="sprinthost.ru"` in `pool.py`/`tg_ws_proxy.py`) continues to work. - No changes to any function signatures, no new dependencies, no behaviour change on connections to properly-configured Telegram DCs. Thanks for maintaining this project — happy to iterate on the wording of the comment or the scope if you'd like. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
mirror-bot added the pull-request label 2026-07-19 11:34:54 +03:00
mirror-bot changed title from [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) 2026-07-20 04:11:47 +03:00
mirror-bot changed title from [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) 2026-07-20 23:15:11 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Utilities/tg-ws-proxy#89