Skip to content

v13.0.3: Hiddify app (/auto/ sing-box JSON) fails through Cloudflare CDN — ws/httpupgrade wrong ALPN + missing Host, DNS-only ECH, xhttp downloadSettings, echConfigList array #5591

Closed

Description

@rezasabourinejad

Environment

  • HiddifyManager 13.0.3 (panel 13.0.3), Ubuntu 24.04, non-docker. Upgraded from 12.x.
  • Clients: Hiddify app (hiddify-core v4.0.4, hiddify-sing-box 1.13.1) and v2rayNG.
  • Domain cdn.example in CDN mode behind Cloudflare (proxied), panel tls_ech_enable=1, and the domain's ECH flag on.

Symptom

With the Hiddify app, which imports the /auto/ link (sing-box JSON), every config on the CDN domain fails.
They are all red, or the app stays on "connecting". The same proxies work when the app imports the /sub/ URI links,
which the app converts itself with ray2sing. On networks where only CDN + ECH gets through (e.g. Iran's MCI / Hamrah-e Aval),
the user gets no connection at all.

How it was isolated (reproducible on the server)

We ran each outbound with the app's own core, hiddify-core srun -c <cfg>, where <cfg> = socks inbound + that single outbound,
and requested https://www.gstatic.com/generate_204 through it. We compared:

  • the outbound from /auto/ (panel JSON), and
  • the outbound produced by hiddify-core parse from the /sub/ links (what the app does with links).
Outbound (CDN domain) from /sub/ links from /auto/ JSON
ws trojan 204 EOF
xhttp vless TLS H2 closed pipe closed pipe

Then we changed one field at a time on the /auto/ outbound until it returned 204, and repeated the test with server set
to the origin IP (bypassing Cloudflare) to tell CDN-path problems from template problems.

Bugs

1. ws / httpupgrade (sing-box JSON): wrong ALPN + missing Host → EOF through the CDN

hiddify-core/client/tls/tls_http emits "alpn": ["h2","http/1.1"] for ws/httpupgrade, and
hiddify-core/client/streams/ws / httpupgrade send only default_headers with no Host.
Results of the variant tests:

change result
alpn http/1.1 only EOF
+ Host only EOF
drop malformed sec-ch-ua-platform EOF
alpn http/1.1 + Host 204

2. ECH in sing-box JSON is DNS-only

tls_http emits:

"ech": {"enabled": true, "config": [], "config_path": "", "query_server_name": "<sni>"}

so the client must fetch the ECH config via a DNS HTTPS lookup at connect time. Where that lookup is blocked (MCI), every ECH
outbound fails. The URI links carry the static key (ech=<base64>, which ray2sing wraps as PEM), and those work.
Emitting the same key in JSON fixed it.

3. xhttp through the CDN: split downloadSettings → download handshake: read payload: io: read/write on closed pipe

Fails in every mode (auto, packet-up, stream-up) whenever downloadSettings is present. The same outbound without
downloadSettings works through Cloudflare (mode: auto), and direct to origin works with it.
(Related to #5570 but a different cause from hiddify/hiddifypanel#45.)

4. echConfigList emitted as an array (Xray)

xray/client/security/tls: "echConfigList": ["{{ domain.echinfo }}"]. In Xray echConfigList is a string
(infra/conf/transport_security.go: ECHConfigList string json:"echConfigList"). This breaks xray JSON / xhttp
extra.downloadSettings for Xray-based clients. Xray also accepts the dynamic form cloudflare-ech.com+udp://1.1.1.1
(transport/internet/tls/ech.go).

5. Smaller issues found on the way

  • ECH formats differ per client. ray2sing (common.go) wraps any ech= value longer than 5 chars in -----BEGIN ECH CONFIGS-----,
    so a dynamic domain+dns value in the URI breaks the Hiddify app, while Xray needs base64 or domain+dns. The URI template should
    pick the form per client (we used ctx.platform).
  • ECH changes (global flag + per-domain flag) only reach subscriptions after apply configs, not after a panel restart.
  • The TLS ECH Value setting (cloudflare-ech.com+https://…) is shown in the UI but is not referenced anywhere in 13.0.3.
  • custom_proxy.sort_order did not change the order of links in /sub/, even after apply.
  • /sub/?domain= is ignored; only the clash routes pass filter_domain.

Workaround we deployed (template overrides, builtin_override=1)

### hiddify-core/client/tls/tls_http
-    "alpn": {{ tls_layer.alpns() | tojson }},
+    "alpn": {% if ctx.proxy.transport in ["ws", "httpupgrade"] %}["http/1.1"]{% else %}{{ tls_layer.alpns() | tojson }}{% endif %},
     "ech": {
       "enabled": true,
+      {% if domain.echinfo %}
+      "config": ["-----BEGIN ECH CONFIGS-----\n{{ domain.echinfo }}\n-----END ECH CONFIGS-----"]
+      {% else %}
       "config": [],
       "config_path": "",
       "query_server_name": "{{ domain.sni }}"
+      {% endif %}
     },
### hiddify-core/client/streams/ws  and  hiddify-core/client/streams/httpupgrade
   "headers": {
+    "Host": "{{ proxy.domain.host }}",
     {% include 'hiddify-core/client/default_headers' %}
### hiddify-core/client/streams/xhttp
-  {% if proxy.is_download_upload_different()  %}
+  {% if proxy.is_download_upload_different() and domain.mode != 'cdn' %}
### sublink/uri/security/tls_http
-            {% set qp.ech = domain.echinfo %}
+            {% if ctx.platform.is_hiddify or 'sing' in (ctx.platform.useragent or '')|lower %}{% set qp.ech = domain.echinfo %}{% else %}{% set qp.ech = 'cloudflare-ech.com+udp://1.1.1.1' %}{% endif %}
### xray/client/security/tls
-    "echConfigList": ["{{ domain.echinfo }}"],
+    "echConfigList": "cloudflare-ech.com+udp://1.1.1.1",

Result

  • /auto/ CDN outbounds tested with hiddify-core srun: ws, httpupgrade, and xhttp all return 204 through Cloudflare (60 of 76;
    the rest are gRPC, below).
  • Hiddify app on MCI connects in Auto mode with the full subscription (direct + CDN + worker). Verified in xray logs.
  • gRPC through Cloudflare returned 403 only in our server-side test, because its UA is grpc-go/* and Cloudflare blocks that.
    The real app's gRPC connected fine. Probably not a Hiddify bug.

Also relevant (reported upstream elsewhere)

rpxy-l4 0.2.4, which Hiddify ships on :443, has a hard-coded 100 ms TCP probe timeout (junkurihara/rust-rpxy-l4#203).
Slow/mobile clients get dropped (Timeout (100ms) while probing, ~1,900/hour on our server; some MCI IPs lost 100%).
A build with 3000 ms brought that to 0. It may be worth shipping a patched build or making it configurable.

Thanks for Hiddify! Happy to test patches.

Activity

  1. Survivor-viper commented on Oct 17, 2026

    @Survivor-viper

    A likely root cause for #3 (xhttp downloadSettings → download handshake: read payload: io: read/write on closed pipe), found while reading 14.0.0b15 (hiddifypanel 0f8b15f). We saw the same closed pipe on 13.0.3 for xhttp profiles whose download leg goes to a separate domain.

    The download leg loses TLS in the hiddify-core / sing-box template when download_tls_layer is empty

    proxy_v3/proxy_templates/hiddify-core/client/tls/tls_http.j2, line 2:

    {% set tls_layer = (tls_layer if tls_layer is defined else ctx.proxy.tls_layer) or "http" %}

    It is included from hiddify-core/client/streams/xhttp.j2:20:

    {% with domain=domain.download, tls_layer=proxy.download_tls_layer %}

    download_tls_layer = None means "same as upload" (context_vars/proxy.py, models/custom_proxy.py), and the editor leaves it empty for h1. But {% with tls_layer=None %} makes the variable defined. So is defined is true, None or "http" gives "http", and the download side gets no tls block. The client then opens a plain-HTTP download stream to port 443, and the stream breaks immediately.

    The xray templates handle the same case with a truthiness test and work correctly:

    {# xray/client/security/tls.j2, sublink/uri/security/tls_http.j2 #}
    {% set tls_layer = (tls_layer if tls_layer else ctx.proxy.tls_layer) or 'http' %}

    Rendering the two variants with jinja2 (stub context) confirms it:

    upload=tls, download_tls_layer=None     -> hiddify-core: NO TLS   xray: TLS
    upload=tls, download_tls_layer='tls_h2' -> hiddify-core: TLS      xray: TLS
    

    Fix:

    --- a/hiddifypanel/proxy_v3/proxy_templates/hiddify-core/client/tls/tls_http.j2
    -{% set tls_layer = (tls_layer if tls_layer is defined else ctx.proxy.tls_layer) or "http" %}
    +{% set tls_layer = (tls_layer if tls_layer is defined and tls_layer else ctx.proxy.tls_layer) or "http" %}

    A sturdier option is to pass tls_layer=proxy.effective_download_tls_layer() in all three xhttp stream templates.

    Related: the sublink downloadSettings uses the upload Host and ignores the download domain's port

    proxy_templates/sublink/xhttp_download_settings.j2:

    "port": {{ proxy.download_port }},
    "host": "{{ proxy.domain.host }}",

    The xray and hiddify-core stream templates use domain.download.host and domain.download.port or proxy.download_port. So a vless:// link sends the download leg to the download domain's address and SNI, but with the upload domain's Host. Its port also ignores the download domain's own TLS port, because ProxyVar.download_port always returns 443/80 for L7.

    Conversely, in non-L7 modes domain.download.port is not filled and defaults to 443, so domain.download.port or … in the xray/core templates always gives 443.

    --- a/hiddifypanel/proxy_v3/proxy_templates/sublink/xhttp_download_settings.j2
    -    "host": "{{ proxy.domain.host }}",
    +    "host": "{{ proxy.domain.download.host if proxy.domain.download else proxy.domain.host }}",

    For the port, a ClientProxyDomainVar.download_port property that returns domain.download.port for domains_l7_gateway and the proxy's own port otherwise, used by all three templates, would make them consistent.

  2. added
    usefulTriaged: real bug or improvement worth keeping
    on Oct 25, 2026
  3. hiddify-com commented on Oct 26, 2026

    @hiddify-com
    Collaborator

    This should be fixed in v14. If it still happens on the latest v14, please reopen this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    usefulTriaged: real bug or improvement worth keeping

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions