Skip to content

Proxy outpost sends a literal Host header on HTTP/2 upstreams, breaking https backends (2026.8.0 regression) #25409

Description

@SisyphusMD

AI assistance disclosure

Per AI_POLICY.md:

Tool: Claude (Anthropic), driven agentically via Claude Code.

Extent: AI-assisted throughout. The agent did the investigation — reading the 2026.8.0 outpost source, fingerprinting the 400 body against the upstream's own error format, bisecting the failure against logs, and running the h2 reproduction — and drafted this report. Every result quoted below is real command output rather than model-generated text: the status codes and the ProtocolError came from executing those requests against the affected controller.

Human-in-the-loop: I have read and verified this report and take responsibility for its contents.


Describe the bug

After upgrading to 2026.8.0, every request to a proxy provider whose internal_host is https returns 400, passed through from the upstream. In my case the upstream is a UniFi (UDM) controller at https://192.168.1.1:443.

Every application path 400s, while the outpost's own endpoints still work:

path result
/ 400
/manage 400
/outpost.goauthentik.io/start 302 (correct redirect, correct client_id)
/outpost.goauthentik.io/ping 204

Providers with plain-http:// internal hosts on the same outpost are unaffected — they still redirect to auth normally.

The 400 body is the upstream's error format, not authentik's — that same controller answers /api/self with {"error":{"code":401,"message":"Unauthorized"}}. So the request is reaching the backend and being rejected there.

How to reproduce

  1. Create a proxy provider whose internal_host is https://… and whose backend negotiates HTTP/2 via ALPN (any nginx-fronted appliance; mine is a UniFi UDM on :443).
  2. Assign it to an outpost in proxy mode and route an external hostname to it.
  3. Request any application path → 400, emitted by the upstream.

The rejection is reproducible against the backend directly, with no authentik in the path. Using Python's h2 with validate_outbound_headers=False so the malformed pair is actually transmitted:

h2, no host header (:authority only)      -> 200
h2, host: 192.168.1.1     (agrees)        -> 400
h2, host: unifi.example.com (mismatch)    -> 400

The same server returns 200 for all of these over HTTP/1.1.

Note this upstream rejects any literal host header on h2, even one that agrees with :authority — so merely keeping the two consistent is not sufficient here; the header should not be sent on h2 at all.

h2 refuses to send the mismatched pair without that flag, citing the relevant rule:

h2.exceptions.ProtocolError: Request header block has mismatched
:authority and Host headers: b'192.168.1.1' / b'unifi.example.com'

Expected behavior

Proxying to an https backend should keep working as it did through 2026.5.x.

On HTTP/2 upstreams the outpost should either drop the inbound Host and let :authority carry the value, or derive :authority from the same value it places in Host — not emit both inconsistently.

Additional context

build_client() in src/outpost/proxy/upstream.rs enables HTTP/2 on the upstream connector and disables hyper's Host handling:

.https_or_http()
.enable_http1()
.enable_http2()
.build();
// Forward the request's own `Host` upstream instead of deriving it from the
// (internal) upstream URI authority. The proxy sets `Host` explicitly.
Ok(Client::builder(TokioExecutor::new())
    .set_host(false)
    .build(connector))

create_proxied_request() in reverse_proxy.rs strips only hop-by-hop headers, so the client's inbound Host is forwarded verbatim. For an https upstream that negotiates h2, the request then carries both :authority (from the rewritten URI) and a literal host header holding the external hostname.

Per RFC 9113 §8.3.1 that combination is malformed when the two disagree, and servers reject it. set_host(false) looks correct for HTTP/1.1 and wrong for HTTP/2.

Workarounds tried, none sufficient:

  • Provider Host header override — still emits a literal host header, still 400s on this upstream.
  • http:// internal_host — upstream 301s to https.
  • A different upstream port — not serving the application.

There is no provider field or environment variable exposing upstream HTTP version or set_host, so this is not configurable around. I ended up routing past the outpost entirely at the ingress layer.

Deployment Method

Kubernetes

Version

2026.8.0 (regression — 2026.5.6 was unaffected)

Relevant log output

Worth flagging that authentik logs nothing anomalous — the outpost records the 400 as an ordinary request, with no error or warning emitted, which makes this hard to spot from the authentik side:

{"target":"authentik_axum::tracing","event":"/","runtime":"1","status":400,
 "host":"unifi.example.com","method":"GET","path":"/","scheme":"https"}

Timeline, for the regression window: last successful request at 2026-08-18 22:xx, pods restarted onto 2026.8.0 at 23:20, first 400 at 2026-08-19 01:06, and no successful request since.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions