Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d71edf3
feat: add experimental http2 support
mafredri Sep 29, 2025
6d09ea6
fix: address review feedback
mafredri Dec 16, 2025
fea5b67
refactor: rename Protocol to HTTPProtocol
mafredri Dec 16, 2025
53a900c
refactor(internal/examples/http2): remove self-signed cert generation
mafredri Dec 16, 2025
fd5e7f7
fix(dial): verify HTTP/1.x protocol in verifyServerResponseH1 (#550)
mafredri Oct 29, 2026
3866540
test(dial): set ProtoMajor in hand-built responses
mafredri Oct 29, 2026
887c8b3
test(dial): cover the HTTP/1.x check in verifyServerResponseH1
mafredri Oct 29, 2026
3186028
refactor(internal/examples/http2): use DialTLSContext
mafredri Oct 29, 2026
7cad4a2
chore(internal/examples/http2): apply gofmt and prettier
mafredri Oct 29, 2026
3f3a021
test(internal/thirdparty/http2): use DialTLSContext
mafredri Oct 29, 2026
d499f15
fix(dial): accept an unset ProtoMajor as HTTP/1.x
mafredri Oct 29, 2026
425ae71
test(dial): keep hand-built responses without ProtoMajor
mafredri Oct 29, 2026
d2b7a8d
build(internal): bump golang.org/x/net and golang.org/x/text
mafredri Oct 29, 2026
9d5a131
refactor(internal): serve HTTP/2 with net/http in example and tests
mafredri Oct 30, 2026
f8d55ce
chore(internal): ignore SA1019 for the x/net HTTP/2 client
mafredri Oct 30, 2026
5477e50
docs: use the HTTPProtocol option name
mafredri Oct 30, 2026
4ba01a2
docs: require -tags http2legacy for HTTP/2 Dial on Go 1.27
mafredri Oct 30, 2026
d2f16ad
fix(dial): check Sec-WebSocket-Accept over HTTP/2 only when present
mafredri Oct 30, 2026
818ed69
fix(accept): stop requiring Sec-WebSocket-Key over HTTP/2
mafredri Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ go get github.com/coder/websocket
- [RFC 7692](https://tools.ietf.org/html/rfc7692) permessage-deflate compression
- [CloseRead](https://pkg.go.dev/github.com/coder/websocket#Conn.CloseRead) helper for write only connections
- Compile to [Wasm](https://pkg.go.dev/github.com/coder/websocket#hdr-Wasm)
- Experimental support for HTTP/2 extended CONNECT (RFC 8441)
- Requires opt-in via the [AcceptOptions](https://pkg.go.dev/github.com/coder/websocket#AcceptOptions) and [DialOptions](https://pkg.go.dev/github.com/coder/websocket#DialOptions) HTTPProtocol option
- Clients must provide a `http2.Transport`, and on Go 1.27 be built with `-tags http2legacy`
- Servers must be started with `GODEBUG=http2xconnect=1`, see https://github.com/golang/go/issues/53208
- See the [http2 example](./internal/examples/http2)

## Roadmap

Expand All @@ -43,7 +48,7 @@ See GitHub issues for minor issues but the major future enhancements are:
- [ ] Graceful shutdown helpers [#209](https://github.com/coder/websocket/issues/209)
- [ ] Assembly for WebSocket masking [#16](https://github.com/coder/websocket/issues/16)
- WIP at [#326](https://github.com/coder/websocket/pull/326), about 3x faster
- [ ] HTTP/2 [#4](https://github.com/coder/websocket/issues/4)
- [x] HTTP/2 extended CONNECT (RFC 8441) [#4](https://github.com/coder/websocket/issues/4)
- [ ] The holy grail [#402](https://github.com/coder/websocket/issues/402)

## Examples
Expand All @@ -53,6 +58,8 @@ For a production quality example that demonstrates the complete API, see the

For a full stack example, see the [chat example](./internal/examples/chat).

For a HTTP/2 WebSocket example, see the [http2 example](./internal/examples/http2).

### Server

```go
Expand Down
265 changes: 201 additions & 64 deletions accept.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ type AcceptOptions struct {
// reject it, close the connection when c.Subprotocol() == "".
Subprotocols []string

// InsecureSkipVerify is used to disable Accept's origin verification behaviour.
// HTTPProtocol selects which HTTP version to accept. Zero value defaults to
// HTTPProtocol1. HTTPProtocolAny allows accepting either HTTP/1.1 or
// HTTP/2.
//
// Experimental: This feature is experimental and may change in the future.
HTTPProtocol HTTPProtocol

// InsecureSkipVerify is used to disable Accept's origin verification behavior.
//
// You probably want to use OriginPatterns instead.
InsecureSkipVerify bool
Expand Down Expand Up @@ -81,12 +88,20 @@ type AcceptOptions struct {
OnPongReceived func(ctx context.Context, payload []byte)
}

func (opts *AcceptOptions) cloneWithDefaults() *AcceptOptions {
func (opts *AcceptOptions) cloneWithDefaults() (*AcceptOptions, error) {
var o AcceptOptions
if opts != nil {
o = *opts
}
return &o

// Defaults to HTTP/1.1 only to preserve existing behavior (zero value).
switch o.HTTPProtocol {
case HTTPProtocolAny, HTTPProtocol1, HTTPProtocol2:
default:
return nil, fmt.Errorf("websocket: invalid protocol for accept options: %s", o.HTTPProtocol)
}

return &o, nil
}

// Accept accepts a WebSocket handshake from a client and upgrades the
Expand All @@ -106,13 +121,20 @@ func Accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (*Conn,
func accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (_ *Conn, err error) {
defer errd.Wrap(&err, "failed to accept WebSocket connection")

errCode, err := verifyClientRequest(w, r)
opts, err = opts.cloneWithDefaults()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, err
}

// Verify client request and determine handshake proto (h1 or h2).
proto, key, errCode, err := verifyClientRequest(w, r, opts)
if err != nil {
http.Error(w, err.Error(), errCode)
return nil, err
}

opts = opts.cloneWithDefaults()
// Origin/auth checks (common for both H1 and H2).
if !opts.InsecureSkipVerify {
err = authenticateOrigin(r, opts.OriginPatterns)
if err != nil {
Expand All @@ -125,104 +147,219 @@ func accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (_ *Con
}
}

hj, ok := hijacker(w)
if !ok {
err = errors.New("http.ResponseWriter does not implement http.Hijacker")
http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
return nil, err
}
switch proto {
case HTTPProtocol2:
// Prepare response headers for H2 (no Connection/Upgrade).
if key != "" {
w.Header().Set("Sec-WebSocket-Accept", secWebSocketAccept(key))
}

subproto := selectSubprotocol(r, opts.Subprotocols)
if subproto != "" {
w.Header().Set("Sec-WebSocket-Protocol", subproto)
}

w.Header().Set("Upgrade", "websocket")
w.Header().Set("Connection", "Upgrade")
copts, ok := selectDeflate(websocketExtensions(r.Header), opts.CompressionMode)
if ok {
w.Header().Set("Sec-WebSocket-Extensions", copts.String())
}

key := r.Header.Get("Sec-WebSocket-Key")
w.Header().Set("Sec-WebSocket-Accept", secWebSocketAccept(key))
// RFC 8441 requires a 2xx response for extended CONNECT.
w.WriteHeader(http.StatusOK)
// Flush the response immediately to complete the extended CONNECT
// handshake before we start streaming on the tunnel.
rc := http.NewResponseController(w)
if err := rc.Flush(); err != nil {
return nil, err
}

stream := &h2ServerStream{ReadCloser: r.Body, Writer: w, flush: rc.Flush}
return newConn(connConfig{
subprotocol: w.Header().Get("Sec-WebSocket-Protocol"),
rwc: stream,
client: false,
copts: copts,
flateThreshold: opts.CompressionThreshold,
onPingReceived: opts.OnPingReceived,
onPongReceived: opts.OnPongReceived,
br: getBufioReader(stream),
bw: getBufioWriter(stream),
}), nil

case HTTPProtocol1:
hj, ok := hijacker(w)
if !ok {
err = errors.New("http.ResponseWriter does not implement http.Hijacker")
http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
return nil, err
}

w.Header().Set("Upgrade", "websocket")
w.Header().Set("Connection", "Upgrade")
w.Header().Set("Sec-WebSocket-Accept", secWebSocketAccept(key))

subproto := selectSubprotocol(r, opts.Subprotocols)
if subproto != "" {
w.Header().Set("Sec-WebSocket-Protocol", subproto)
}

copts, ok := selectDeflate(websocketExtensions(r.Header), opts.CompressionMode)
if ok {
w.Header().Set("Sec-WebSocket-Extensions", copts.String())
}

w.WriteHeader(http.StatusSwitchingProtocols)
// See https://github.com/coder/websocket/issues/166.
if ginWriter, ok := w.(interface {
WriteHeaderNow()
}); ok {
ginWriter.WriteHeaderNow()
}

netConn, brw, err := hj.Hijack()
if err != nil {
err = fmt.Errorf("failed to hijack connection: %w", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return nil, err
}

subproto := selectSubprotocol(r, opts.Subprotocols)
if subproto != "" {
w.Header().Set("Sec-WebSocket-Protocol", subproto)
// https://github.com/golang/go/issues/32314
b, _ := brw.Reader.Peek(brw.Reader.Buffered())
brw.Reader.Reset(io.MultiReader(bytes.NewReader(b), netConn))

return newConn(connConfig{
subprotocol: w.Header().Get("Sec-WebSocket-Protocol"),
rwc: netConn,
client: false,
copts: copts,
flateThreshold: opts.CompressionThreshold,
onPingReceived: opts.OnPingReceived,
onPongReceived: opts.OnPongReceived,

br: brw.Reader,
bw: brw.Writer,
}), nil
default:
http.Error(w, "unsupported protocol: "+r.Proto, http.StatusBadRequest)
return nil, errors.New("unsupported protocol: " + r.Proto)
}
}

func verifyClientRequest(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (proto HTTPProtocol, key string, errCode int, err error) {
if r.ProtoMajor == 2 {
switch opts.HTTPProtocol {
case HTTPProtocol1:
return HTTPProtocol2, "", http.StatusBadRequest, errors.New("HTTP/2 extended CONNECT refused: server only accepts HTTP/1.1 Upgrade")
}

copts, ok := selectDeflate(websocketExtensions(r.Header), opts.CompressionMode)
if ok {
w.Header().Set("Sec-WebSocket-Extensions", copts.String())
// HTTP/2 extended CONNECT (RFC 8441) path.
key, errCode, err = verifyClientRequestH2(w, r)
if err != nil {
return HTTPProtocol2, "", errCode, err
}
return HTTPProtocol2, key, 0, nil
}

w.WriteHeader(http.StatusSwitchingProtocols)
// See https://github.com/coder/websocket/issues/166
if ginWriter, ok := w.(interface {
WriteHeaderNow()
}); ok {
ginWriter.WriteHeaderNow()
switch opts.HTTPProtocol {
case HTTPProtocol2:
return HTTPProtocol1, "", http.StatusBadRequest, errors.New("HTTP/1.1 Upgrade refused: server requires HTTP/2 extended CONNECT")
}

netConn, brw, err := hj.Hijack()
// HTTP/1.1 GET/Upgrade handshake validation.
key, errCode, err = verifyClientRequestH1(w, r)
if err != nil {
err = fmt.Errorf("failed to hijack connection: %w", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return nil, err
return HTTPProtocol1, "", errCode, err
}

// https://github.com/golang/go/issues/32314
b, _ := brw.Reader.Peek(brw.Reader.Buffered())
brw.Reader.Reset(io.MultiReader(bytes.NewReader(b), netConn))

return newConn(connConfig{
subprotocol: w.Header().Get("Sec-WebSocket-Protocol"),
rwc: netConn,
client: false,
copts: copts,
flateThreshold: opts.CompressionThreshold,
onPingReceived: opts.OnPingReceived,
onPongReceived: opts.OnPongReceived,

br: brw.Reader,
bw: brw.Writer,
}), nil
return HTTPProtocol1, key, 0, nil
}

func verifyClientRequest(w http.ResponseWriter, r *http.Request) (errCode int, _ error) {
if !r.ProtoAtLeast(1, 1) {
return http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: handshake request must be at least HTTP/1.1: %q", r.Proto)
// verifyClientRequestH1 validates an HTTP/1.1 WebSocket GET/Upgrade request.
func verifyClientRequestH1(w http.ResponseWriter, r *http.Request) (key string, errCode int, _ error) {
if !r.ProtoAtLeast(1, 1) || r.ProtoMajor != 1 {
return "", http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: handshake request must be at least HTTP/1.1: %q", r.Proto)
}

if !headerContainsTokenIgnoreCase(r.Header, "Connection", "Upgrade") {
w.Header().Set("Connection", "Upgrade")
w.Header().Set("Upgrade", "websocket")
return http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: Connection header %q does not contain Upgrade", r.Header.Get("Connection"))
return "", http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: Connection header %q does not contain Upgrade", r.Header.Get("Connection"))
}

if !headerContainsTokenIgnoreCase(r.Header, "Upgrade", "websocket") {
w.Header().Set("Connection", "Upgrade")
w.Header().Set("Upgrade", "websocket")
return http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: Upgrade header %q does not contain websocket", r.Header.Get("Upgrade"))
return "", http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: Upgrade header %q does not contain websocket", r.Header.Get("Upgrade"))
}

if r.Method != "GET" {
return http.StatusMethodNotAllowed, fmt.Errorf("WebSocket protocol violation: handshake request method is not GET but %q", r.Method)
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
return "", http.StatusMethodNotAllowed, fmt.Errorf("WebSocket protocol violation: handshake request method is not GET but %q", r.Method)
}

key, errCode, err := validateSecWebSocketHeaders(w, r)
if err != nil {
return "", errCode, err
}

return key, 0, nil
}

func verifyClientRequestH2(w http.ResponseWriter, r *http.Request) (key string, errCode int, _ error) {
if r.ProtoMajor != 2 {
return "", http.StatusBadRequest, fmt.Errorf("WebSocket protocol violation: handshake request must be HTTP/2: %q", r.Proto)
}

if r.Header.Get(":protocol") != "websocket" {
return "", http.StatusBadRequest, fmt.Errorf("WebSocket protocol violation: :protocol header not set or does not match websocket: %q", r.Header.Get(":protocol"))
}

if r.Method != http.MethodConnect {
w.Header().Set("Allow", http.MethodConnect)
return "", http.StatusMethodNotAllowed, fmt.Errorf("WebSocket protocol violation: handshake request method is not CONNECT but %q", r.Method)
}

errCode, err := validateSecWebSocketVersion(w, r)
if err != nil {
return "", errCode, err
}

// RFC 8441 §5 replaces Sec-WebSocket-Key with :protocol, and Chrome and
// Firefox send none. A key that does arrive is returned so Accept answers
// it: proxies such as Envoy forward an HTTP/1.1 client's key without
// generating the Sec-WebSocket-Accept that client checks.
return strings.TrimSpace(r.Header.Get("Sec-WebSocket-Key")), 0, nil
}

// validateSecWebSocketVersion sets Sec-WebSocket-Version: 13 on a mismatch.
func validateSecWebSocketVersion(w http.ResponseWriter, r *http.Request) (errCode int, _ error) {
if r.Header.Get("Sec-WebSocket-Version") != "13" {
w.Header().Set("Sec-WebSocket-Version", "13")
return http.StatusBadRequest, fmt.Errorf("unsupported WebSocket protocol version (only 13 is supported): %q", r.Header.Get("Sec-WebSocket-Version"))
}
return 0, nil
}

// validateSecWebSocketHeaders validates Sec-WebSocket-Version/Sec-WebSocket-Key
// and returns the trimmed key.
func validateSecWebSocketHeaders(w http.ResponseWriter, r *http.Request) (key string, errCode int, _ error) {
errCode, err := validateSecWebSocketVersion(w, r)
if err != nil {
return "", errCode, err
}

websocketSecKeys := r.Header.Values("Sec-WebSocket-Key")
if len(websocketSecKeys) == 0 {
return http.StatusBadRequest, errors.New("WebSocket protocol violation: missing Sec-WebSocket-Key")
return "", http.StatusBadRequest, errors.New("WebSocket protocol violation: missing Sec-WebSocket-Key")
}

if len(websocketSecKeys) > 1 {
return http.StatusBadRequest, errors.New("WebSocket protocol violation: multiple Sec-WebSocket-Key headers")
return "", http.StatusBadRequest, errors.New("WebSocket protocol violation: multiple Sec-WebSocket-Key headers")
}

// The RFC states to remove any leading or trailing whitespace.
websocketSecKey := strings.TrimSpace(websocketSecKeys[0])
if v, err := base64.StdEncoding.DecodeString(websocketSecKey); err != nil || len(v) != 16 {
return http.StatusBadRequest, fmt.Errorf("WebSocket protocol violation: invalid Sec-WebSocket-Key %q, must be a 16 byte base64 encoded string", websocketSecKey)
key = strings.TrimSpace(websocketSecKeys[0])
if v, err := base64.StdEncoding.DecodeString(key); err != nil || len(v) != 16 {
return "", http.StatusBadRequest, fmt.Errorf("WebSocket protocol violation: invalid Sec-WebSocket-Key %q, must be a 16 byte base64 encoded string", key)
}

return 0, nil
return key, 0, nil
}

func authenticateOrigin(r *http.Request, originHosts []string) error {
Expand Down
2 changes: 1 addition & 1 deletion accept_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ func Test_verifyClientHandshake(t *testing.T) {
r.Header.Add(k, v)
}

_, err := verifyClientRequest(httptest.NewRecorder(), r)
_, _, _, err := verifyClientRequest(httptest.NewRecorder(), r, &AcceptOptions{HTTPProtocol: HTTPProtocol1})
if tc.success {
assert.Success(t, err)
} else {
Expand Down
2 changes: 1 addition & 1 deletion autobahn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ func checkWSTestIndex(t *testing.T, path string) {
switch result.BehaviorClose {
case "OK", "INFORMATIONAL":
default:
t.Errorf("bad close behaviour")
t.Errorf("bad close behavior")
Comment thread
mafredri marked this conversation as resolved.
Resolved
}

switch result.Behavior {
Expand Down
Loading
Loading