goauthentik / goauthentik/authentik

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

Open
#25,409 2 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.