The patch swaps two lines. Before, AdGuard Home wrapped web.auth.middleware() around h2c.NewHandler. After, it wraps h2c.NewHandler around web.auth.middleware(). The two configurations look symmetric. They are not. One of them authenticates HTTP requests; the other authenticates the act of opening a connection that will then carry HTTP requests with no further check.
CVE-2026-32136: AdGuard Home Authenticated the Upgrade, Not the Streams
pattern
cve
proof of concept
The patch is two reorderings
The vulnerable code in internal/home/web.go builds the HTTP server's handler chain by wrapping middleware from the outside in. Pre-patch, in v0.107.72:
// Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies.
hdlr := h2c.NewHandler(
withMiddlewares(web.conf.mux, limitRequestBody),
&http2.Server{},
)
logger := web.baseLogger.With(loggerKeyServer, "plain")
logMw := httputil.NewLogMiddleware(logger, slog.LevelDebug)
hdlr = logMw.Wrap(hdlr)
web.httpServer = &http.Server{
Addr: web.conf.BindAddr.String(),
Handler: web.auth.middleware().Wrap(hdlr),
// ...
}Outside-in, the chain on the plain-HTTP listener is auth, log, h2c, mux.
The patch (commit c003e9f9c0) reorders those four wrappers:
- hdlr := h2c.NewHandler(
- withMiddlewares(web.conf.mux, limitRequestBody),
- &http2.Server{},
- )
+ hdlr := withMiddlewares(web.conf.mux, limitRequestBody)
...
hdlr = logMw.Wrap(hdlr)
+
+ hdlr = web.auth.middleware().Wrap(hdlr)
+
+ // Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies.
+ //
+ // NOTE: The auth middleware must be inside the h2c handler to ensure
+ // it applies to upgraded HTTP/2 connections as well. See AG-51779.
+ hdlr = h2c.NewHandler(hdlr, &http2.Server{})
...
web.httpServer = &http.Server{
...
- Handler: web.auth.middleware().Wrap(hdlr),
+ Handler: hdlr,
}Outside-in, the chain is now h2c, auth, log, mux. No new logic. No new check. The auth middleware crosses one layer.
h2c is not a request handler. h2c is a connection handler that pretends to be one.
golang.org/x/net/http2/h2c.NewHandler returns an http.Handler. Its ServeHTTP has two paths.
Path 1 is a regular HTTP/1.1 request with no Upgrade: h2c header. The handler delegates to its inner handler and returns. It behaves like any other middleware on a single request.
Path 2 is an HTTP/1.1 request with Upgrade: h2c and a valid HTTP2-Settings header. The handler hijacks the TCP connection via http.ResponseWriter.Hijack, sends 101 Switching Protocols on the wire, and runs (&http2.Server{}).ServeConn on the same socket. ServeConn is a blocking call. It reads HTTP/2 frames until the connection closes, and for every stream it sees, it dispatches the resulting request to the same inner handler that was passed to h2c.NewHandler.
Both paths share ServeHTTP's signature. Only one of them is a request. The other is the entire remaining lifetime of the TCP socket, served from inside a single ServeHTTP call by an HTTP/2 server the outer http.Server can no longer see.
Anything wrapped outside h2c.NewHandler runs on path 1 and on the path-2 upgrade request. Once ServeConn takes over, the streams it dispatches go straight to the inner handler. The outer middleware was never registered with http2.Server. It does not run on the streams. It cannot. They are not requests the outer http.Server ever heard about.
The PoC chooses /login.html for a reason
s.sendall(
f"GET /login.html HTTP/1.1\r\n"
f"Host: {target}\r\nConnection: Upgrade, HTTP2-Settings\r\n"
f"Upgrade: h2c\r\nHTTP2-Settings: AAMAAABkAARAAP__AAIAAAAA\r\n\r\n".encode()
)The path is a deliberate choice and the PoC's README documents the iteration. /control/login was tried first; it returns 404 to a GET because the endpoint is POST-only, and a 404 on stream 1 confuses the HTTP/2 client. /login.html returns 200 OK, the static asset arrives cleanly on stream 1, and subsequent streams can be requested without poisoning the connection.
/login.html was also picked because the auth middleware lets it through unauthenticated. The pre-patch authhttp.go defines isPublicResource:
isLogin, err := path.Match("/login.*", p)
// ...
paths := []string{
"/dns-query",
"/control/login",
"/apple/doh.mobileconfig",
"/apple/dot.mobileconfig",
"/control/install/get_addresses",
"/control/install/check_config",
"/control/install/configure",
"/install.html",
}
return isAsset || isLogin || slices.Contains(paths, p)A logged-out browser has to be able to fetch /login.html to render the form. The auth middleware exempts it. So the upgrade request, GET /login.html, lands in handlePublicAccess, which calls h.ServeHTTP(w, r) on the inner handler. The inner handler at that layer is h2c.NewHandler(...). h2c.NewHandler does the upgrade, hijacks the connection, and starts dispatching streams to withMiddlewares(mux, limitRequestBody). The PoC then opens a new HTTP/2 stream:
sid = c.get_next_available_stream_id()
c.send_headers(sid, [
(":method", "GET"), (":path", path),
(":scheme", "http"), (":authority", target),
("accept", "application/json"),
], end_stream=True)path defaults to /control/filtering/status. That endpoint is firmly inside the auth-required surface. The README enumerates several others worth pulling on a default install: /control/clients returns the DHCP and DNS client list, /control/querylog returns the DNS query log (which on home and small-office deployments often contains internal hostnames the operator would not publish on purpose), /control/dns_info returns the upstream DNS configuration, /control/status returns the listening interfaces. The auth middleware never sees any of these. The connection is on the wrong side of the hijack.
The README also notes that curl --http2-prior-knowledge does not trigger the bug. Prior-knowledge HTTP/2 sends an HTTP/2 preface as the first bytes on the socket, before any HTTP/1.1 layer runs. The outer http.Server is HTTP/1.1-only on the plain listener; the preface looks like a malformed HTTP/1.1 request, the auth middleware sees a malformed request and returns 401, the connection closes. Prior-knowledge HTTP/2 does not work because the auth middleware is correctly placed on the HTTP/1.1 layer. Cleartext upgrade works because the auth middleware is correctly placed on the HTTP/1.1 layer and that is the only place it is placed.
The middleware is request-scoped. The hijacker is connection-scoped.
Wrapping a request-scoped middleware around a connection-scoped handler is a category error. The middleware's contract is that for each call into its ServeHTTP, it can choose to continue or to short-circuit with a 401. h2c's path 2 makes exactly one call into ServeHTTP per connection, and that call returns only when the entire HTTP/2 conversation is over. Every request that arrived between the upgrade and the disconnect came from inside http2.Server.ServeConn, a goroutine the outer middleware does not own and was never given a handle to.
The fixed code makes the middleware a property of the inner handler instead of a property of the outer http.Server. Because http2.Server.ServeConn dispatches each stream to the inner handler, the middleware now sees every stream. The auth check returns to being a per-request check, which is what its name has always implied.
The patch carries its own commentary in the post-patch source:
// Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies.
//
// NOTE: The auth middleware must be inside the h2c handler to ensure
// it applies to upgraded HTTP/2 connections as well. See AG-51779.
hdlr = h2c.NewHandler(hdlr, &http2.Server{})That comment is the bug class stated as a rule. This is the middleware-outside-the-hijacker shape: a request-scoped check wrapped around a handler that can hijack the underlying TCP connection and then serve subsequent traffic from inside its own goroutine. h2c is the obvious instance. WebSocket upgrade handlers are another. http.Hijacker-using libraries (CONNECT proxies, server-sent events with custom framing, raw TCP tunnelled over HTTP) are another. Any code path where ResponseWriter.Hijack is called is a path where outer middleware silently drops out of scope.
The "for proxies" justification in the original comment is worth sitting with. h2c is enabled by default on every AdGuard Home plain-HTTP listener because at some point a deployment scenario was contemplated in which a reverse proxy would speak h2c upstream to AdGuard Home. The feature ships enabled to all users; the deployment scenario applies to a small subset of those users. The bug is reachable on the default install.
The CWE is CWE-287. The pattern is in the layering.
CVE-2026-32136 is filed as CWE-287, Improper Authentication, with the description "an unauthenticated remote attacker can bypass all authentication in AdGuardHome." That is accurate at the level of impact. It is not the structural reason.
The structural reason is that the auth middleware was attached at the wrong layer of a five-line chain. The chain was correctly written for HTTP/1.1. It would also be correctly written for HTTP/2-over-TLS, because in that case the standard library never re-enters userland with a hijacked socket; ALPN-negotiated HTTP/2 is served by net/http's HTTP/2 implementation under http.Server directly, and the outer middleware sees every stream. The h2c handler is the specific intermediary that breaks the assumption. It exists to support unencrypted HTTP/2 for reverse-proxy scenarios, and the AdGuard Home web server enables it on every plain-HTTP listener whether the deployment uses it or not.
The patch is two lines of reorder. The bug is a layer-ordering decision. The class of bug, request-scoped middleware wrapping a connection-hijacking handler, is general and routinely re-discovered. The advisory credits @mandreko, an external reporter. The CHANGELOG note for v0.107.73 reads, in full: "Authentication is now applied to requests that have been upgraded from HTTP/2 Cleartext (H2C) requests to public resources."
That sentence describes the fix. The shape of the fix is admission that the previous behavior was the inverse. Authentication was not applied to requests that had been upgraded from h2c on a public resource. The CVE description does not name the layering. The patch comment does. The advisory does not. The bug was a comment that was never written until it was.
The auth middleware ran on the request that asked to upgrade. Everything after the upgrade was inside a hijacked socket the middleware no longer saw.