Skip to content
File

Blob: src/workerd/server/container-client-test.c++

6.6 KB
1// Copyright (c) 2025 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5// Tests for container-client JSON decoding.
6//
7// These tests verify that JSON responses from the Docker API are decoded into
8// Cap'n Proto messages whose backing storage outlives the decode call. The
9// original decodeJsonResponse<T>() returned a Builder pointing into a
10// stack-local MallocMessageBuilder; the builder was then accessed after the
11// message was destroyed (use-after-free). Under ASAN this test would crash
12// with the old code.
13 
14#include "container-client.h"
15 
16#include <kj/test.h>
17 
18namespace workerd::server {
19namespace {
20 
21// Regression test for VULN-127728: ContainerInspectResponse decode must not
22// use-after-free. With the old buggy code every field access after decode
23// dereferences freed heap memory (detectable by ASAN).
24KJ_TEST("decodeJsonResponse ContainerInspectResponse - no use-after-free") {
25 // Minimal JSON matching what Docker returns for /containers/<id>/json.
26 auto json = R"({
27 "Id": "abc123",
28 "Created": "2025-01-01T00:00:00Z",
29 "Path": "/bin/sh",
30 "Args": ["--http-egress-port", "9000"],
31 "State": {
32 "Status": "running",
33 "Running": true,
34 "Paused": false,
35 "Restarting": false,
36 "OOMKilled": false,
37 "Dead": false,
38 "Pid": 42,
39 "ExitCode": 0,
40 "Error": "",
41 "StartedAt": "2025-01-01T00:00:01Z",
42 "FinishedAt": "0001-01-01T00:00:00Z"
43 },
44 "NetworkSettings": {
45 "Bridge": "",
46 "SandboxID": "",
47 "HairpinMode": false,
48 "LinkLocalIPv6Address": "",
49 "LinkLocalIPv6PrefixLen": 0,
50 "SandboxKey": "",
51 "EndpointID": "",
52 "Gateway": "172.17.0.1",
53 "GlobalIPv6Address": "",
54 "GlobalIPv6PrefixLen": 0,
55 "IPAddress": "172.17.0.2",
56 "IPPrefixLen": 16,
57 "IPv6Gateway": "",
58 "MacAddress": "02:42:ac:11:00:02",
59 "Networks": {},
60 "Ports": {
61 "8080/tcp": [
62 {"HostIp": "0.0.0.0", "HostPort": "55000"}
63 ]
64 }
65 }
66 })"_kj;
67 
68 auto message = decodeJsonResponse<docker_api::Docker::ContainerInspectResponse>(json);
69 auto root = message->getRoot<docker_api::Docker::ContainerInspectResponse>();
70 
71 // Every access below would be a heap-use-after-free with the old code.
72 KJ_EXPECT(root.getId() == "abc123");
73 KJ_EXPECT(root.hasState());
74 
75 auto state = root.getState();
76 KJ_EXPECT(state.getStatus() == "running");
77 KJ_EXPECT(state.getRunning() == true);
78 
79 KJ_EXPECT(root.hasArgs());
80 auto args = root.getArgs();
81 KJ_EXPECT(args.size() == 2);
82 KJ_EXPECT(args[0] == "--http-egress-port");
83 KJ_EXPECT(args[1] == "9000");
84 
85 auto ports = root.getNetworkSettings().getPorts().getObject();
86 KJ_EXPECT(ports.size() == 1);
87 KJ_EXPECT(ports[0].getName() == "8080/tcp");
88 auto array = ports[0].getValue().getArray();
89 KJ_EXPECT(array.size() == 1);
90 auto obj = array[0].getObject();
91 KJ_EXPECT(obj.size() == 2);
92 // HostPort is obj[1] in the order Docker returns
93 auto mappedPort = obj[1].getValue().getString();
94 KJ_EXPECT(mappedPort == "55000");
95}
96 
97KJ_TEST("decodeJsonResponse NetworkInspectResponse - no use-after-free") {
98 auto json = R"({
99 "Name": "bridge",
100 "Id": "net123",
101 "IPAM": {
102 "Driver": "default",
103 "Config": [
104 {"Subnet": "172.17.0.0/16", "Gateway": "172.17.0.1"}
105 ]
106 }
107 })"_kj;
108 
109 auto message = decodeJsonResponse<docker_api::Docker::NetworkInspectResponse>(json);
110 auto root = message->getRoot<docker_api::Docker::NetworkInspectResponse>();
111 
112 KJ_EXPECT(root.getName() == "bridge");
113 auto ipamConfig = root.getIpam().getConfig();
114 KJ_EXPECT(ipamConfig.size() == 1);
115 KJ_EXPECT(ipamConfig[0].getSubnet() == "172.17.0.0/16");
116 KJ_EXPECT(ipamConfig[0].getGateway() == "172.17.0.1");
117}
118 
119KJ_TEST("decodeJsonResponse ContainerMonitorResponse - no use-after-free") {
120 auto json = R"({"StatusCode": 0})"_kj;
121 
122 auto message = decodeJsonResponse<docker_api::Docker::ContainerMonitorResponse>(json);
123 auto root = message->getRoot<docker_api::Docker::ContainerMonitorResponse>();
124 
125 KJ_EXPECT(root.getStatusCode() == 0);
126}
127 
128KJ_TEST("decodeJsonResponse ContainerMonitorResponse - non-zero exit") {
129 auto json = R"({"StatusCode": 137})"_kj;
130 
131 auto message = decodeJsonResponse<docker_api::Docker::ContainerMonitorResponse>(json);
132 auto root = message->getRoot<docker_api::Docker::ContainerMonitorResponse>();
133 
134 KJ_EXPECT(root.getStatusCode() == 137);
135}
136 
137KJ_TEST("ContainerCreateRequest encodes structured mounts with NoCopy") {
138 capnp::JsonCodec codec;
139 codec.handleByAnnotation<docker_api::Docker::ContainerCreateRequest>();
140 
141 capnp::MallocMessageBuilder message;
142 auto root = message.initRoot<docker_api::Docker::ContainerCreateRequest>();
143 root.setImage("test-image");
144 
145 auto mounts = root.initHostConfig().initMounts(1);
146 auto mount = mounts[0];
147 mount.setType("volume");
148 mount.setSource("snapshot-clone-volume");
149 mount.setTarget("/app/data");
150 mount.initVolumeOptions().setNoCopy(true);
151 
152 auto json = codec.encode(root);
153 auto jsonText = json.asPtr();
154 
155 KJ_EXPECT(jsonText.contains("\"Mounts\""));
156 KJ_EXPECT(jsonText.contains("\"VolumeOptions\""));
157 KJ_EXPECT(jsonText.contains("\"NoCopy\":true"));
158 
159 auto decoded = decodeJsonResponse<docker_api::Docker::ContainerCreateRequest>(jsonText);
160 auto decodedRoot = decoded->getRoot<docker_api::Docker::ContainerCreateRequest>();
161 auto decodedMounts = decodedRoot.getHostConfig().getMounts();
162 
163 KJ_REQUIRE(decodedMounts.size() == 1);
164 KJ_EXPECT(decodedMounts[0].getType() == "volume");
165 KJ_EXPECT(decodedMounts[0].getSource() == "snapshot-clone-volume");
166 KJ_EXPECT(decodedMounts[0].getTarget() == "/app/data");
167 KJ_EXPECT(decodedMounts[0].getVolumeOptions().getNoCopy());
168}
169 
170KJ_TEST("ContainerCreateRequest encodes HostConfig Dns") {
171 capnp::JsonCodec codec;
172 codec.handleByAnnotation<docker_api::Docker::ContainerCreateRequest>();
173 
174 capnp::MallocMessageBuilder message;
175 auto root = message.initRoot<docker_api::Docker::ContainerCreateRequest>();
176 root.setImage("test-image");
177 
178 auto dns = root.initHostConfig().initDns(2);
179 dns.set(0, "1.1.1.1");
180 dns.set(1, "8.8.8.8");
181 
182 auto json = codec.encode(root);
183 auto jsonText = json.asPtr();
184 
185 KJ_EXPECT(jsonText.contains("\"Dns\""));
186 KJ_EXPECT(jsonText.contains("1.1.1.1"));
187 KJ_EXPECT(jsonText.contains("8.8.8.8"));
188 
189 auto decoded = decodeJsonResponse<docker_api::Docker::ContainerCreateRequest>(jsonText);
190 auto decodedRoot = decoded->getRoot<docker_api::Docker::ContainerCreateRequest>();
191 auto decodedDns = decodedRoot.getHostConfig().getDns();
192 
193 KJ_REQUIRE(decodedDns.size() == 2);
194 KJ_EXPECT(decodedDns[0] == "1.1.1.1");
195 KJ_EXPECT(decodedDns[1] == "8.8.8.8");
196}
197 
198} // namespace
199} // namespace workerd::server