goauthentik / goauthentik/authentik
Proxy outpost sends a literal Host header on HTTP/2 upstreams, breaking https backends (2026.8.0 regression)
- Dominant language
- Python
- Stars
- 25.6k
- Forks
- 2k
- Avg merge
- 1d 1h
- Merged PRs (30d)
- 644
Description
## AI assistance disclosure
Per [AI_POLICY.md](https://github.com/goauthentik/authentik/blob/main/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:
```rust
.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:
```shell
{"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.
Contributor guide
Research direction
Start in src/outpost/proxy/upstream.rs at build_client() and in reverse_proxy.rs at create_proxied_request(). Trace how set_host(false), HTTP/2 negotiation, the rewritten URI, and the forwarded inbound Host header combine, then reproduce the h2 behavior described in the issue. Done means HTTPS upstreams that negotiate HTTP/2 no longer receive the malformed literal Host combination, while existing HTTP/1.1 proxying remains working.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, rust
- Domain
- backend, networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100