{"record":{"id":"d35c14df1378b431","repo":"tailscale/tailscale","slug":"hijacking-client-connection-w","errorCode":null,"errorMessage":"hijacking client connection: %w","messagePattern":"hijacking client connection: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"control/controlhttp/controlhttpserver/controlhttpserver.go","lineNumber":84,"sourceCode":"\tinit, err := base64.StdEncoding.DecodeString(initB64)\n\tif err != nil {\n\t\thttp.Error(w, \"invalid tailscale handshake header\", http.StatusBadRequest)\n\t\treturn nil, fmt.Errorf(\"decoding base64 handshake header: %v\", err)\n\t}\n\n\thijacker, ok := w.(http.Hijacker)\n\tif !ok {\n\t\thttp.Error(w, \"make request over HTTP/1\", http.StatusBadRequest)\n\t\treturn nil, errors.New(\"can't hijack client connection\")\n\t}\n\n\tw.Header().Set(\"Upgrade\", controlhttpcommon.UpgradeHeaderValue)\n\tw.Header().Set(\"Connection\", \"upgrade\")\n\tw.WriteHeader(http.StatusSwitchingProtocols)\n\n\tconn, brw, err := hijacker.Hijack()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"hijacking client connection: %w\", err)\n\t}\n\n\tdefer func() {\n\t\tif retErr != nil {\n\t\t\tconn.Close()\n\t\t}\n\t}()\n\n\tif err := brw.Flush(); err != nil {\n\t\treturn nil, fmt.Errorf(\"flushing hijacked HTTP buffer: %w\", err)\n\t}\n\tconn = netutil.NewDrainBufConn(conn, brw.Reader)\n\n\tcwc := newWriteCorkingConn(conn)\n\n\tnc, err := controlbase.Server(ctx, cwc, private, init)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"noise handshake failed: %w\", err)","sourceCodeStart":66,"sourceCodeEnd":102,"githubUrl":"https://github.com/tailscale/tailscale/blob/0fd2f14deb478d09c95f38af2b0d35b492b63520/control/controlhttp/controlhttpserver/controlhttpserver.go#L66-L102","documentation":"After writing the 101 and calling ResponseWriter.Hijack() to take over the raw connection for the Noise handshake, Hijack returned an error. The earlier branch already handles ResponseWriters that don't implement http.Hijacker at all (e.g. http2); this error means Hijack exists but failed — connection already hijacked/closed, middleware wrappers, or a conn in a bad state.","triggerScenarios":"Serving the control endpoint over h2c/HTTP/2 where hijack semantics break; middleware that already consumed or hijacked the conn; double handling of the same request; ResponseWriter wrapped by logging/tracing middleware that breaks the Hijacker assertion chain.","commonSituations":"golang.org/x/net/http2 h2c servers, custom middleware stacks, tests with fake ResponseWriters, grpc-gateway style wrappers.","solutions":["Serve the control endpoint on a plain HTTP/1.1 listener without h2c","Remove or reorder middleware that wraps or hijacks the ResponseWriter before acceptHTTP","Ensure the handler runs exactly once per connection"],"exampleFix":"// before\nsrv := &http.Server{Handler: h2cHandler(ctrlHandler), Addr: \":443\"} // http2 conn -> Hijack fails\n\n// after\nsrv := &http.Server{Handler: ctrlHandler, Addr: \":443\"} // HTTP/1.1 only","handlingStrategy":"validation","validationCode":"// Reject anything that is not HTTP/1.x before it reaches acceptHTTP\nif r.ProtoMajor != 1 {\n    http.Error(w, \"make request over HTTP/1\", http.StatusBadRequest)\n    return\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Serve the control endpoint from a plain HTTP/1.1 server; no h2c","Unwrap ResponseWriters in middleware or pass Hijacker through intact"],"tags":["server","hijack","http1","middleware","http2"],"backgroundTag":"http-hijack-failed","analyzedSha":"0fd2f14deb478d09c95f38af2b0d35b492b63520","analyzedAt":"2026-08-18T08:17:25.280Z","contentChangedAt":"2026-08-18T08:17:25.280Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}