fix: harden native http protocol boundaries
This commit is contained in:
@@ -88,6 +88,179 @@ func TestServerRejectsUnsupportedBodyAndLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerProtocolBoundaries(t *testing.T) {
|
||||
application := omni.NewWithRouter(netrouter.New())
|
||||
application.GET("/health", func(ctx *omni.Context) error { return ctx.OK("ok") })
|
||||
server, listener, done := startServer(t, application)
|
||||
defer stopServer(t, server, listener, done)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
request string
|
||||
status int
|
||||
}{
|
||||
{
|
||||
name: "missing host",
|
||||
request: "GET /health HTTP/1.1\r\n\r\n",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "duplicate host",
|
||||
request: "GET /health HTTP/1.1\r\nHost: one\r\nHost: two\r\n\r\n",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "unsupported version",
|
||||
request: "GET /health HTTP/2.0\r\nHost: test\r\n\r\n",
|
||||
status: http.StatusHTTPVersionNotSupported,
|
||||
},
|
||||
{
|
||||
name: "malformed header",
|
||||
request: "GET /health HTTP/1.1\r\nHost: test\r\nBroken\r\n\r\n",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "folded header",
|
||||
request: "GET /health HTTP/1.1\r\nHost: test\r\n X-Bad: value\r\n\r\n",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "control header value",
|
||||
request: "GET /health HTTP/1.1\r\nHost: test\r\nX-Bad: value\x01\r\n\r\n",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "chunked",
|
||||
request: "POST /health HTTP/1.1\r\nHost: test\r\nTransfer-Encoding: chunked\r\n\r\n",
|
||||
status: http.StatusNotImplemented,
|
||||
},
|
||||
{
|
||||
name: "expectation",
|
||||
request: "GET /health HTTP/1.1\r\nHost: test\r\nExpect: 100-continue\r\n\r\n",
|
||||
status: http.StatusExpectationFailed,
|
||||
},
|
||||
{
|
||||
name: "conflicting content length",
|
||||
request: "POST /health HTTP/1.1\r\nHost: test\r\nContent-Length: 1\r\nContent-Length: 2\r\n\r\na",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid content length",
|
||||
request: "POST /health HTTP/1.1\r\nHost: test\r\nContent-Length: nope\r\n\r\n",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "malformed target",
|
||||
request: "GET health HTTP/1.1\r\nHost: test\r\n\r\n",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
response := rawResponse(t, listener.Addr().String(), test.request)
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != test.status {
|
||||
t.Fatalf("expected status %d, got %d", test.status, response.StatusCode)
|
||||
}
|
||||
if !response.Close {
|
||||
t.Fatal("protocol error should close the connection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerHTTP10KeepAliveAndRequestMetadata(t *testing.T) {
|
||||
application := omni.NewWithRouter(netrouter.New())
|
||||
application.GET("/metadata", func(ctx *omni.Context) error {
|
||||
return ctx.OK(map[string]string{
|
||||
"method": ctx.Request().Method,
|
||||
"host": ctx.Request().Host,
|
||||
})
|
||||
})
|
||||
server, listener, done := startServer(t, application)
|
||||
defer stopServer(t, server, listener, done)
|
||||
|
||||
conn, err := net.Dial("tcp", listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
reader := bufio.NewReader(conn)
|
||||
_, _ = io.WriteString(conn, "GET /metadata HTTP/1.0\r\nHost: legacy\r\nConnection: keep-alive\r\n\r\n")
|
||||
response, err := http.ReadResponse(reader, &http.Request{Method: http.MethodGet})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if err != nil || response.StatusCode != http.StatusOK || response.Close || !strings.Contains(string(body), `"host":"legacy"`) {
|
||||
t.Fatalf("unexpected HTTP/1.0 response: close=%v status=%d body=%s error=%v", response.Close, response.StatusCode, body, err)
|
||||
}
|
||||
_, _ = io.WriteString(conn, "GET /metadata HTTP/1.0\r\nHost: legacy\r\n\r\n")
|
||||
response, err = http.ReadResponse(reader, &http.Request{Method: http.MethodGet})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if !response.Close {
|
||||
t.Fatal("HTTP/1.0 response without keep-alive should close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSuppressesBodiesForHeadAndNoContent(t *testing.T) {
|
||||
application := omni.NewWithRouter(netrouter.New())
|
||||
application.Handle(http.MethodHead, "/head", func(ctx *omni.Context) error { return ctx.OK("hidden") })
|
||||
application.GET("/empty", func(ctx *omni.Context) error {
|
||||
ctx.Raw().WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
})
|
||||
server, listener, done := startServer(t, application)
|
||||
defer stopServer(t, server, listener, done)
|
||||
|
||||
headResponse := rawResponseMethod(t, listener.Addr().String(), http.MethodHead, "HEAD /head HTTP/1.1\r\nHost: test\r\n\r\n")
|
||||
headBody, err := io.ReadAll(headResponse.Body)
|
||||
headResponse.Body.Close()
|
||||
if err != nil || len(headBody) != 0 || headResponse.StatusCode != http.StatusOK || headResponse.ContentLength <= 0 {
|
||||
t.Fatalf("unexpected HEAD response: status=%d contentLength=%d body=%q error=%v", headResponse.StatusCode, headResponse.ContentLength, headBody, err)
|
||||
}
|
||||
emptyResponse := rawResponse(t, listener.Addr().String(), "GET /empty HTTP/1.1\r\nHost: test\r\n\r\n")
|
||||
emptyBody, err := io.ReadAll(emptyResponse.Body)
|
||||
emptyResponse.Body.Close()
|
||||
if err != nil || len(emptyBody) != 0 || emptyResponse.StatusCode != http.StatusNoContent || emptyResponse.ContentLength != 0 {
|
||||
t.Fatalf("unexpected 204 response: status=%d contentLength=%d body=%q error=%v", emptyResponse.StatusCode, emptyResponse.ContentLength, emptyBody, err)
|
||||
}
|
||||
}
|
||||
|
||||
func rawResponse(t *testing.T, address, request string) *http.Response {
|
||||
return rawResponseMethod(t, address, http.MethodGet, request)
|
||||
}
|
||||
|
||||
func rawResponseMethod(t *testing.T, address, method, request string) *http.Response {
|
||||
t.Helper()
|
||||
conn, err := net.Dial("tcp", address)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(conn, request)
|
||||
response, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: method})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body = &closeBody{ReadCloser: response.Body, conn: conn}
|
||||
return response
|
||||
}
|
||||
|
||||
type closeBody struct {
|
||||
io.ReadCloser
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
func (b *closeBody) Close() error {
|
||||
err := b.ReadCloser.Close()
|
||||
return errors.Join(err, b.conn.Close())
|
||||
}
|
||||
|
||||
func TestServerShutdownCancelsActiveHandler(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
application := omni.NewWithRouter(netrouter.New())
|
||||
|
||||
Reference in New Issue
Block a user