Repository: moby/moby (language: go)

Issue:
docker commit REST API error: {"message":"invalid JSON: got EOF while reading request body"}
### Description

Issue: 
After Latest version of docker (23.0.6, build ef23cbc) fails to commit a container when using HTTP REST API.

CentOS 7 and fully patched
Docker version 20.10.24, build 297e128

Using sample documentation provided by Docker to commit a container via HTTP REST API:
https://docs.docker.com/engine/api/sdk/examples/#commit-a-container

curl --unix-socket /var/run/docker.sock -X POST "http://localhost/commit?container=2211ef44b&repo=helloworld:1"
{"Id":"sha256:66d20a926be6267867b0060ddefb2dc50cc4fc6bf3fce53354867239095b80e6"}

Upgrade to latest docker-ce engine: Docker version 23.0.6, build ef23cbc

Repeat test:
curl --unix-socket /var/run/docker.sock -X POST "http://localhost/commit?container=2211ef44b&repo=helloworld:2"
{"message":"invalid JSON: got EOF while reading request body"}

Downgrade docker-ce: yum downgrade 3:docker-ce-20.10.24-3.el7.x86_64

Interestingly version still shows previous:
[root@docker-centos ~]# docker -v
Docker version 23.0.6, build ef23cbc

Repeat test again:
curl --unix-socket /var/run/docker.sock -X POST "http://localhost/commit?container=2211ef44b&repo=helloworld:2"
{"Id":"sha256:d945f4cff496ba4c5851a153050e196c470cc8524ae7c52e0370ae0d0557bde3"}

This is also happening in my production environment using RHEL8 and Rocky Linux 8.

Not sure what has changed. If I need to make changes on my end, could someone point me to the proper documentation?

Thanks,
valenbb



### Reproduce

1. Install docker-ce 20.10.24 per documentation
2. Perform docker commit using REST API using documentation: https://docs.docker.com/engine/api/sdk/examples/#commit-a-container
3. Upgrade to latest docker-ce release
4. Repeat step #2 

### Expected behavior

curl --unix-socket /var/run/docker.sock -X POST "http://localhost/commit?container=2211ef44b&repo=helloworld:2" should produce an output as below:
{"Id":"sha256:d945f4cff496ba4c5851a153050e196c470cc8524ae7c52e0370ae0d0557bde3"}

### docker version

```bash
Client: Docker Engine - Community
 Version:           23.0.6
 API version:       1.41 (downgraded from 1.42)
 Go version:        go1.19.9
 Git commit:        ef23cbc
 Built:             Fri May  5 21:21:29 2023
 OS/Arch:           linux/amd64
 Context:           default

Server: Docker Engine - Community
 Engine:
  Version:          20.10.24
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.19.7
  Git commit:       5d6db84
  Built:            Tue Apr  4 18:21:02 2023
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.6.21
  GitCommit:        3dce8eb055cbb6872793272b4f20ed16117344f8
 runc:
  Version:          1.1.7
  GitCommit:        v1.1.7-0-g860f061
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0
```


### docker info

```bash
Client:
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.10.4
    Path:     /usr/libexec/docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v2.17.3
    Path:     /usr/libexec/docker/cli-plugins/docker-compose

Server:
 Containers: 1
  Running: 0
  Paused: 0
  Stopped: 1
 Images: 3
 Server Version: 20.10.24
 Storage Driver: overlay2
  Backing Filesystem: xfs
  Supports d_type: true
  Native Overlay Diff: true
  userxattr: false
 Logging Driver: json-file
 Cgroup Driver: cgroupfs
 Cgroup Version: 1
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local logentries splunk syslog
 Swarm: inactive
 Runtimes: io.containerd.runc.v2 io.containerd.runtime.v1.linux runc
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: 3dce8eb055cbb6872793272b4f20ed16117344f8
 runc version: v1.1.7-0-g860f061
 init version: de40ad0
 Security Options:
  seccomp
   Profile: default
 Kernel Version: 3.10.0-1160.90.1.el7.x86_64
 Operating System: CentOS Linux 7 (Core)
 OSType: linux
 Architecture: x86_64
 CPUs: 2
 Total Memory: 3.7GiB
 Name: docker-centos
 ID: BWAW:2MDW:W3FA:JYOA:IAGR:WPBT:G7JI:QXIM:GKLH:7KXA:BODK:CJ4G
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Registry: https://index.docker.io/v1/
 Experimental: false
 Insecure Registries:
  127.0.0.0/8
 Live Restore Enabled: false
```


### Additional Info

_No response_


Discussion / hints:
https://github.com/moby/moby/blob/1371aee3cc38134778e2e85f1a4122d1b31d344d/api/server/router/container/container_routes.go#L46-L49
It does look like allowing an empty request body is intended, making this a regression. The offending commit looks to be b6d58d749c6d671f0dc19a88b948b772272e145d

Fix patch:
diff --git a/api/server/router/container/container_routes.go b/api/server/router/container/container_routes.go
index 21bc20a3a32a9..b4aa0864fb4e3 100644
--- a/api/server/router/container/container_routes.go
+++ b/api/server/router/container/container_routes.go
@@ -44,7 +44,7 @@ func (s *containerRouter) postCommit(ctx context.Context, w http.ResponseWriter,
 	}
 
 	config, _, _, err := s.decoder.DecodeConfig(r.Body)
-	if err != nil && err != io.EOF { // Do not fail if body is empty.
+	if err != nil && !errors.Is(err, io.EOF) { // Do not fail if body is empty.
 		return err
 	}
 
@@ -486,6 +486,9 @@ func (s *containerRouter) postContainersCreate(ctx context.Context, w http.Respo
 
 	config, hostConfig, networkingConfig, err := s.decoder.DecodeConfig(r.Body)
 	if err != nil {
+		if errors.Is(err, io.EOF) {
+			return errdefs.InvalidParameter(errors.New("invalid JSON: got EOF while reading request body"))
+		}
 		return err
 	}
 	version := httputils.VersionFromContext(ctx)
diff --git a/client/request.go b/client/request.go
index c799095c12272..babe462a6d83a 100644
--- a/client/request.go
+++ b/client/request.go
@@ -10,6 +10,7 @@ import (
 	"net/http"
 	"net/url"
 	"os"
+	"reflect"
 	"strings"
 
 	"github.com/docker/docker/api/types"
@@ -54,11 +55,17 @@ func (cli *Client) put(ctx context.Context, path string, query url.Values, obj i
 	if err != nil {
 		return serverResponse{}, err
 	}
-	return cli.sendRequest(ctx, http.MethodPut, path, query, body, headers)
+	return cli.putRaw(ctx, path, query, body, headers)
 }
 
 // putRaw sends an http request to the docker API using the method PUT.
 func (cli *Client) putRaw(ctx context.Context, path string, query url.Values, body io.Reader, headers map[string][]string) (serverResponse, error) {
+	// PUT requests are expected to always have a body (apparently)
+	// so explicitly pass an empty body to sendRequest to signal that
+	// it should set the Content-Type header if not already present.
+	if body == nil {
+		body = http.NoBody
+	}
 	return cli.sendRequest(ctx, http.MethodPut, path, query, body, headers)
 }
 
@@ -73,6 +80,12 @@ func encodeBody(obj interface{}, headers headers) (io.Reader, headers, error) {
 	if obj == nil {
 		return nil, headers, nil
 	}
+	// encoding/json encodes a nil pointer as the JSON document `null`,
+	// irrespective of whether the type implements json.Marshaler or encoding.TextMarshaler.
+	// That is almost certainly not what the caller intended as the request body.
+	if reflect.TypeOf(obj).Kind() == reflect.Ptr && reflect.ValueOf(obj).IsNil() {
+		return nil, headers, nil
+	}
 
 	body, err := encodeData(obj)
 	if err != nil {
@@ -86,11 +99,6 @@ func encodeBody(obj interface{}, headers headers) (io.Reader, headers, error) {
 }
 
 func (cli *Client) buildRequest(method, path string, body io.Reader, headers headers) (*http.Request, error) {
-	expectedPayload := (method == http.MethodPost || method == http.MethodPut)
-	if expectedPayload && body == nil {
-		body = bytes.NewReader([]byte{})
-	}
-
 	req, err := http.NewRequest(method, path, body)
 	if err != nil {
 		return nil, err
@@ -106,7 +114,7 @@ func (cli *Client) buildRequest(method, path string, body io.Reader, headers hea
 	req.URL.Host = cli.addr
 	req.URL.Scheme = cli.scheme
 
-	if expectedPayload && req.Header.Get("Content-Type") == "" {
+	if body != nil && req.Header.Get("Content-Type") == "" {
 		req.Header.Set("Content-Type", "text/plain")
 	}
 	return req, nil
diff --git a/runconfig/config.go b/runconfig/config.go
index b25d1a8aa3480..3ba1609e9101f 100644
--- a/runconfig/config.go
+++ b/runconfig/config.go
@@ -77,10 +77,7 @@ func decodeContainerConfig(src io.Reader, si *sysinfo.SysInfo) (*container.Confi
 func loadJSON(src io.Reader, out interface{}) error {
 	dec := json.NewDecoder(src)
 	if err := dec.Decode(&out); err != nil {
-		if err == io.EOF {
-			return validationError("invalid JSON: got EOF while reading request body")
-		}
-		return validationError("invalid JSON: " + err.Error())
+		return invalidJSONError{Err: err}
 	}
 	if dec.More() {
 		return validationError("unexpected content after JSON")
diff --git a/runconfig/errors.go b/runconfig/errors.go
index 038fe396609ed..9522a2e0f7a3a 100644
--- a/runconfig/errors.go
+++ b/runconfig/errors.go
@@ -40,3 +40,17 @@ func (e validationError) Error() string {
 }
 
 func (e validationError) InvalidParameter() {}
+
+type invalidJSONError struct {
+	Err error
+}
+
+func (e invalidJSONError) Error() string {
+	return "invalid JSON: " + e.Err.Error()
+}
+
+func (e invalidJSONError) Unwrap() error {
+	return e.Err
+}
+
+func (e invalidJSONError) InvalidParameter() {}


Test patch: