Coverage Report

Created: 2026-05-30 06:25

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/curl_fuzzer/proto_fuzzer/websocket_mock_server.cc
Line
Count
Source
1
/*
2
 * Copyright (C) Max Dymond, <cmeister2@gmail.com>, et al.
3
 *
4
 * SPDX-License-Identifier: curl
5
 */
6
7
/// @file
8
/// @brief Implementation of WebSocketMockServer.
9
10
#include "proto_fuzzer/websocket_mock_server.h"
11
12
#include <curl/websockets.h>
13
14
#include <algorithm>
15
#include <cstddef>
16
#include <string>
17
#include <utility>
18
19
#include "proto_fuzzer/ws_accept_key.h"
20
#include "proto_fuzzer/ws_frame.h"
21
22
namespace proto_fuzzer {
23
24
namespace {
25
26
// Cap per-scenario frame chunks so a mutator that creates thousands of tiny
27
// entries can't dominate runtime.
28
constexpr std::size_t kMaxResponseChunks = 16;
29
30
// Iteration caps for the post-handshake manual WS drive. Kept small because
31
// every iteration makes a curl_ws_recv / curl_ws_send call.
32
constexpr std::size_t kMaxWsRecvIterations = 128;
33
constexpr std::size_t kMaxWsSendIterations = 32;
34
35
/// Ordered list of flag combinations fed to curl_ws_send / curl_ws_start_frame
36
/// during the manual-drive tail. Sequencing matters: the TEXT|CONT entry puts
37
/// the encoder into `contfragment=true`, so subsequent CONT / TEXT|CONT /
38
/// BINARY|CONT entries then take the contfragment-aware branches in
39
/// ws_frame_flags2firstbyte that would otherwise be unreachable from the
40
/// fuzzer. A lone CURLWS_CONT runs first (contfragment=false → "No ongoing
41
/// fragmented message" failf), and a bare `0` directly after CURLWS_CONT
42
/// drives the "no flags given; interpreting as continuation" compatibility
43
/// path. (TEXT|BINARY), (CLOSE|CONT), (PING|CONT), (PONG|CONT) cover the
44
/// invalid-combination failf() branches; the trailing lone `0` reaches the
45
/// ordinary "no flags given" rejection with contfragment=false.
46
constexpr unsigned int kWsSendFlagMatrix[] = {
47
    CURLWS_CONT,
48
    CURLWS_TEXT,
49
    CURLWS_BINARY,
50
    CURLWS_TEXT | CURLWS_OFFSET,
51
    CURLWS_BINARY | CURLWS_OFFSET,
52
    CURLWS_TEXT | CURLWS_CONT,
53
    CURLWS_CONT,
54
    0,
55
    CURLWS_TEXT,
56
    CURLWS_BINARY | CURLWS_CONT,
57
    CURLWS_BINARY,
58
    CURLWS_PING,
59
    CURLWS_PONG,
60
    CURLWS_CLOSE,
61
    0,
62
    CURLWS_CLOSE | CURLWS_CONT,
63
    CURLWS_PING | CURLWS_CONT,
64
    CURLWS_PONG | CURLWS_CONT,
65
    CURLWS_TEXT | CURLWS_BINARY,
66
};
67
68
/// Find "Sec-WebSocket-Key:" in the request and return the trimmed value.
69
/// @param request The raw HTTP request bytes buffered from the client.
70
/// @return The header value, or an empty string if not found.
71
1.34k
std::string ExtractWebSocketKey(const std::string& request) {
72
1.34k
  static const char kHeader[] = "Sec-WebSocket-Key:";
73
1.34k
  std::size_t pos = request.find(kHeader);
74
1.34k
  if (pos == std::string::npos) {
75
2
    return {};
76
2
  }
77
1.34k
  pos += sizeof(kHeader) - 1;
78
2.64k
  while (pos < request.size() && (request[pos] == ' ' || request[pos] == '\t')) {
79
1.29k
    ++pos;
80
1.29k
  }
81
1.34k
  std::size_t end = request.find("\r\n", pos);
82
1.34k
  if (end == std::string::npos) {
83
4
    return {};
84
4
  }
85
1.45k
  while (end > pos && (request[end - 1] == ' ' || request[end - 1] == '\t')) {
86
107
    --end;
87
107
  }
88
1.34k
  return request.substr(pos, end - pos);
89
1.34k
}
90
91
/// SHA1(key + WS magic guid), base64-encoded — RFC 6455 §4.2.2.
92
/// Delegates to the standalone implementation in ws_accept_key.h so we don't
93
/// need OpenSSL.
94
1.34k
std::string ComputeWebSocketAccept(const std::string& key) { return proto_fuzzer::ComputeWebSocketAcceptKey(key); }
95
96
/// Build the ordered list of chunks to deliver once the 101 handshake has
97
/// completed. Mixes raw `on_readable` bytes (fuzzer-controlled) with serialised
98
/// `server_frames` (structured RFC 6455 frames from the proto) under a shared
99
/// kMaxResponseChunks budget.
100
1.98k
std::vector<std::string> BuildFrameChunks(const curl::fuzzer::proto::Connection& conn) {
101
1.98k
  std::vector<std::string> chunks;
102
1.98k
  chunks.reserve(kMaxResponseChunks);
103
1.98k
  const std::size_t raw_budget = std::min<std::size_t>(kMaxResponseChunks, conn.on_readable_size());
104
4.14k
  for (std::size_t i = 0; i < raw_budget; ++i) {
105
2.15k
    chunks.emplace_back(conn.on_readable(i));
106
2.15k
  }
107
1.98k
  const std::size_t frame_budget = kMaxResponseChunks - chunks.size();
108
1.98k
  const std::size_t frame_count = std::min<std::size_t>(frame_budget, conn.server_frames_size());
109
4.55k
  for (std::size_t i = 0; i < frame_count; ++i) {
110
2.56k
    chunks.emplace_back(SerializeWebSocketFrame(conn.server_frames(static_cast<int>(i))));
111
2.56k
  }
112
1.98k
  return chunks;
113
1.98k
}
114
115
/// @return true if any scenario option sets CURLOPT_CONNECT_ONLY to 2, which
116
///         is curl's "WebSocket connect-only, drive recv/send manually" mode.
117
1.98k
bool ScenarioRequestsManualWsDrive(const curl::fuzzer::proto::Scenario& scenario) {
118
35.4k
  for (const auto& opt : scenario.options()) {
119
35.4k
    if (opt.option_id() != curl::fuzzer::proto::CURLOPT_CONNECT_ONLY) {
120
34.1k
      continue;
121
34.1k
    }
122
1.38k
    if (opt.value_case() == curl::fuzzer::proto::SetOption::ValueCase::kUintValue && opt.uint_value() == 2) {
123
412
      return true;
124
412
    }
125
1.38k
  }
126
1.57k
  return false;
127
1.98k
}
128
129
/// WRITEFUNCTION / HEADERFUNCTION installed on the easy handle by
130
/// WebSocketMockServer::Install. Pokes curl_ws_meta on every invocation (so
131
/// the Curl_is_in_callback-guarded branch stays covered), and fires a
132
/// one-shot curl_ws_send probe to reach ws_send_raw_blocking — that target
133
/// is unreachable unless CURLWS_RAW_MODE is set AND the caller is inside a
134
/// callback. The probe is sized larger than typical backpressure recv
135
/// buffers so the partial-write / SOCKET_WRITABLE loop engages under a
136
/// tightened SO_RCVBUF. The one-shot gate keeps the per-scenario cost
137
/// bounded when SOCKET_WRITABLE times out. WRITEDATA is the owning
138
/// WebSocketMockServer so callback state lives on the server, not globals.
139
10.8k
size_t WebSocketWriteCallback(void* /*contents*/, size_t size, size_t nmemb, void* userdata) {
140
10.8k
  auto* server = static_cast<WebSocketMockServer*>(userdata);
141
10.8k
  if (server != nullptr) {
142
4.15k
    CURL* easy = server->easy_handle();
143
4.15k
    if (easy != nullptr) {
144
4.15k
      (void)curl_ws_meta(easy);
145
4.15k
      if (!server->ws_probe_fired()) {
146
349
        server->MarkWsProbeFired();
147
349
        static unsigned char kProbe[16384];
148
349
        std::fill(kProbe, kProbe + sizeof(kProbe), 'P');
149
349
        std::size_t sent = 0;
150
349
        (void)curl_ws_send(easy, kProbe, sizeof(kProbe), &sent, 0, 0);
151
349
      }
152
4.15k
    }
153
4.15k
  }
154
10.8k
  return size * nmemb;
155
10.8k
}
156
157
/// Drain curl_ws_recv in a tight loop until it returns CURLE_AGAIN / nothing
158
/// pending. Bounded; not expected to do anything on well-formed scenarios.
159
2.51k
void DrainWsRecv(CURL* easy) {
160
4.99k
  for (std::size_t i = 0; i < kMaxWsRecvIterations; ++i) {
161
4.99k
    unsigned char buffer[4096];
162
4.99k
    std::size_t nread = 0;
163
4.99k
    const struct curl_ws_frame* meta = nullptr;
164
4.99k
    CURLcode rr = curl_ws_recv(easy, buffer, sizeof(buffer), &nread, &meta);
165
4.99k
    if (rr == CURLE_AGAIN) {
166
1.61k
      break;
167
1.61k
    }
168
3.37k
    if (rr != CURLE_OK && rr != CURLE_GOT_NOTHING) {
169
893
      break;
170
893
    }
171
2.48k
    if (nread == 0 && meta == nullptr) {
172
0
      break;
173
0
    }
174
2.48k
  }
175
2.51k
}
176
177
}  // namespace
178
179
/// Construct an idle WebSocketMockServer with no queued frames. Install()
180
/// on the base class and DriveScenario() configure it from a Scenario proto.
181
WebSocketMockServer::WebSocketMockServer()
182
1.98k
    : next_chunk_(0), manual_delivery_(false), handshake_sent_(false), ws_probe_fired_(false), easy_handle_(nullptr) {}
183
184
/// Default destructor; the owned MockConnection (if any) cleans up its socketpair.
185
1.98k
WebSocketMockServer::~WebSocketMockServer() = default;
186
187
/// Install the common socket callbacks via the base, then overwrite
188
/// WRITEFUNCTION / HEADERFUNCTION with a ws-aware variant and wire
189
/// WRITEDATA to this server instance so the callback can consult per-
190
/// scenario state (easy handle, one-shot probe flag).
191
1.98k
void WebSocketMockServer::Install(CURL* easy) {
192
1.98k
  MockServerBase::Install(easy);
193
1.98k
  easy_handle_ = easy;
194
1.98k
  ws_probe_fired_ = false;
195
1.98k
  curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, &WebSocketWriteCallback);
196
1.98k
  curl_easy_setopt(easy, CURLOPT_WRITEDATA, this);
197
1.98k
  curl_easy_setopt(easy, CURLOPT_HEADERFUNCTION, &WebSocketWriteCallback);
198
1.98k
}
199
200
/// @return true once the one-shot WS probe has fired for this scenario.
201
4.15k
bool WebSocketMockServer::ws_probe_fired() const { return ws_probe_fired_; }
202
203
/// Flip the one-shot gate closed. Called from the write callback before it
204
/// invokes curl_ws_send, so subsequent callback entries skip the probe.
205
349
void WebSocketMockServer::MarkWsProbeFired() { ws_probe_fired_ = true; }
206
207
/// @return the curl easy handle cached by Install() for the write callback.
208
4.15k
CURL* WebSocketMockServer::easy_handle() const { return easy_handle_; }
209
210
/// Queue RFC 6455 wire-byte chunks to emit once the handshake has completed.
211
/// Resets the next-chunk cursor.
212
/// @param frames Ordered list of chunk byte strings.
213
1.98k
void WebSocketMockServer::SetFrames(std::vector<std::string> frames) {
214
1.98k
  frames_ = std::move(frames);
215
1.98k
  next_chunk_ = 0;
216
1.98k
}
217
218
/// Toggle streaming (false, default) vs manual (true) chunk delivery.
219
/// @param manual Whether to suppress automatic chunk pushing by the
220
///        drive loop.
221
1.98k
void WebSocketMockServer::SetManualDelivery(bool manual) { manual_delivery_ = manual; }
222
223
/// @return true if chunks are caller-driven rather than drive-loop-driven.
224
93.4k
bool WebSocketMockServer::manual_delivery() const { return manual_delivery_; }
225
226
/// @return true once a 101 Switching Protocols response has been written.
227
183k
bool WebSocketMockServer::handshake_sent() const { return handshake_sent_; }
228
229
/// @return true if at least one frame chunk has not yet been sent.
230
85.2k
bool WebSocketMockServer::has_more_chunks() const { return next_chunk_ < frames_.size(); }
231
232
/// @return the number of queued chunks not yet consumed.
233
2.51k
std::size_t WebSocketMockServer::remaining_chunks() const {
234
2.51k
  return next_chunk_ >= frames_.size() ? 0 : frames_.size() - next_chunk_;
235
2.51k
}
236
237
/// Access a pending chunk without consuming it.
238
/// @param index Offset from the next-pending cursor.
239
/// @return reference to the chunk byte string.
240
2.11k
const std::string& WebSocketMockServer::PeekChunk(std::size_t index) const { return frames_[next_chunk_ + index]; }
241
242
/// Advance the pending-chunk cursor by one. No-op when no chunks remain.
243
2.11k
void WebSocketMockServer::ConsumeChunk() {
244
2.11k
  if (next_chunk_ < frames_.size()) {
245
2.11k
    ++next_chunk_;
246
2.11k
  }
247
2.11k
}
248
249
/// Called by the OPENSOCKETFUNCTION trampoline in the base class. Creates the
250
/// MockConnection but does NOT write anything — the handshake is driven later
251
/// by TryAdvanceHandshake().
252
/// @return the client-side fd to hand to libcurl, or CURL_SOCKET_BAD on
253
///         failure.
254
1.58k
curl_socket_t WebSocketMockServer::HandleOpenSocket() {
255
1.58k
  if (connection_) {
256
0
    return CURL_SOCKET_BAD;
257
0
  }
258
1.58k
  connection_ = std::make_unique<MockConnection>();
259
1.58k
  if (!connection_->ok()) {
260
0
    connection_.reset();
261
0
    return CURL_SOCKET_BAD;
262
0
  }
263
1.58k
  ApplyPendingBackpressure();
264
  // Wait for curl's Upgrade request before we write anything — the drive
265
  // loop calls TryAdvanceHandshake() to drive that exchange.
266
1.58k
  return connection_->take_client_fd();
267
1.58k
}
268
269
/// Push raw bytes onto the server fd. Used by the manual-drive path to feed
270
/// frame bytes directly into curl without any mock-side framing.
271
/// @param data Buffer to send.
272
/// @param size Number of bytes in 'data'.
273
/// @return false on short or failed write.
274
2.00k
bool WebSocketMockServer::PushRawBytes(const unsigned char* data, std::size_t size) {
275
2.00k
  if (!connection_) {
276
0
    return false;
277
0
  }
278
2.00k
  connection_->DrainIncoming();
279
2.00k
  return connection_->WriteAll(data, size);
280
2.00k
}
281
282
/// Push the next queued frame when curl is ready. Used in streaming mode;
283
/// the drive loop calls this after the handshake has been sent. Shuts
284
/// the write side once the last chunk is delivered.
285
2.00k
void WebSocketMockServer::DeliverNextChunk() {
286
2.00k
  if (!connection_ || next_chunk_ >= frames_.size()) {
287
0
    return;
288
0
  }
289
2.00k
  connection_->DrainIncoming();
290
2.00k
  const std::string& chunk = frames_[next_chunk_++];
291
2.00k
  if (!chunk.empty()) {
292
1.83k
    connection_->WriteAll(reinterpret_cast<const unsigned char*>(chunk.data()), chunk.size());
293
1.83k
  }
294
2.00k
  if (next_chunk_ >= frames_.size()) {
295
453
    connection_->ShutdownWrite();
296
453
  }
297
2.00k
}
298
299
/// Drive the WebSocket opening handshake: read whatever curl has written so
300
/// far, and once we've seen the end of the request headers, reply with a
301
/// valid 101 Switching Protocols.
302
/// @return true once the 101 response has been written (idempotent afterwards).
303
6.15k
bool WebSocketMockServer::TryAdvanceHandshake() {
304
6.15k
  if (handshake_sent_ || !connection_) {
305
409
    return handshake_sent_;
306
409
  }
307
5.74k
  connection_->ReadAvailable(&ws_request_buffer_);
308
5.74k
  if (ws_request_buffer_.find("\r\n\r\n") == std::string::npos) {
309
4.39k
    return false;
310
4.39k
  }
311
1.34k
  std::string key = ExtractWebSocketKey(ws_request_buffer_);
312
  // Even if parsing failed, reply with *something* so curl doesn't wedge.
313
  // A bad Accept exercises curl's handshake-error path.
314
1.34k
  std::string accept = key.empty() ? std::string("AAAAAAAAAAAAAAAAAAAAAAAAAAA=") : ComputeWebSocketAccept(key);
315
1.34k
  std::string response =
316
1.34k
      "HTTP/1.1 101 Switching Protocols\r\n"
317
1.34k
      "Upgrade: websocket\r\n"
318
1.34k
      "Connection: Upgrade\r\n"
319
1.34k
      "Sec-WebSocket-Accept: " +
320
1.34k
      accept + "\r\n\r\n";
321
1.34k
  connection_->WriteAll(reinterpret_cast<const unsigned char*>(response.data()), response.size());
322
1.34k
  handshake_sent_ = true;
323
1.34k
  return true;
324
5.74k
}
325
326
/// Seed the mock from the scenario, then run the perform loop. Drives the 101
327
/// handshake on every iteration; in streaming mode also pushes queued frame
328
/// chunks as curl becomes readable. In manual mode (CURLOPT_CONNECT_ONLY=2)
329
/// chunks are left alone during the loop — once the loop returns, this method
330
/// pushes them as raw bytes and exercises curl_ws_recv / curl_ws_send against
331
/// a small flag matrix.
332
/// @param multi    caller-owned multi; 'easy' is already added.
333
/// @param easy     the curl easy handle attached to this mock.
334
/// @param scenario source of the frame chunks and CONNECT_ONLY setting.
335
1.98k
void WebSocketMockServer::RunLoop(CURLM* multi, CURL* easy, const curl::fuzzer::proto::Scenario& scenario) {
336
1.98k
  SetManualDelivery(ScenarioRequestsManualWsDrive(scenario));
337
  // initial_response is unused in WS mode; we synthesise the 101 dynamically
338
  // from curl's Upgrade request.
339
1.98k
  SetFrames(BuildFrameChunks(scenario.connection()));
340
341
1.98k
  int still_running = 1;
342
1.98k
  int idle_iterations = 0;
343
1.98k
  CURLMcode rc = CURLM_OK;
344
345
93.4k
  while (still_running && idle_iterations < kMaxIdleIterations) {
346
93.4k
    rc = curl_multi_perform(multi, &still_running);
347
93.4k
    if (rc != CURLM_OK) {
348
0
      break;
349
0
    }
350
    // Drive the 101 handshake on every iteration; no-op once sent.
351
93.4k
    if (!handshake_sent()) {
352
6.15k
      if (TryAdvanceHandshake()) {
353
1.34k
        idle_iterations = 0;
354
1.34k
      }
355
6.15k
    }
356
93.4k
    if (!still_running) {
357
1.98k
      break;
358
1.98k
    }
359
360
91.4k
    int ready = WaitOnMultiFdset(multi, &rc);
361
91.4k
    if (rc != CURLM_OK) {
362
0
      break;
363
0
    }
364
365
    // Only push frame chunks in streaming mode — in manual mode the caller
366
    // will push them below after the handshake.
367
91.4k
    if (!manual_delivery() && handshake_sent() && has_more_chunks()) {
368
2.00k
      DeliverNextChunk();
369
2.00k
      idle_iterations = 0;
370
89.4k
    } else if (ready == 0) {
371
88.5k
      ++idle_iterations;
372
88.5k
    } else {
373
864
      idle_iterations = 0;
374
864
    }
375
91.4k
  }
376
377
1.98k
  if (!manual_delivery() || !handshake_sent()) {
378
1.59k
    return;
379
1.59k
  }
380
381
  // Manual-drive tail: feed every remaining scripted chunk straight onto the
382
  // server fd as raw frame bytes, draining curl_ws_recv between each push.
383
2.51k
  while (remaining_chunks() > 0) {
384
2.11k
    const std::string& chunk = PeekChunk(0);
385
2.11k
    if (!chunk.empty()) {
386
2.00k
      PushRawBytes(reinterpret_cast<const unsigned char*>(chunk.data()), chunk.size());
387
2.00k
    }
388
2.11k
    ConsumeChunk();
389
2.11k
    DrainWsRecv(easy);
390
2.11k
  }
391
  // Final drain in case frame parsing produced more work after the last push.
392
398
  DrainWsRecv(easy);
393
394
398
  const auto& probes = scenario.connection().manual_probes();
395
398
  static const unsigned char kPayload[] = "hello-from-proto-fuzzer";
396
398
  const std::size_t payload_len = sizeof(kPayload) - 1;
397
398
398
  if (probes.flag_matrix()) {
399
    // Exercise curl_ws_send with a fixed matrix of flags. We don't care whether
400
    // the send actually lands on the wire — the point is to reach the encode
401
    // paths in ws_enc_add_frame / ws_enc_write_head.
402
127
    std::size_t iteration = 0;
403
2.41k
    for (unsigned int flags : kWsSendFlagMatrix) {
404
2.41k
      if (iteration++ >= kMaxWsSendIterations) {
405
0
        break;
406
0
      }
407
      // Announce the frame via the public curl_ws_start_frame entrypoint before
408
      // the actual send. In non-raw mode this writes the frame head into the
409
      // sendbuf; the follow-up curl_ws_send with the same flags finishes the
410
      // exchange or fails cleanly on invalid flag combos.
411
2.41k
      (void)curl_ws_start_frame(easy, flags, static_cast<curl_off_t>(payload_len));
412
2.41k
      std::size_t sent = 0;
413
2.41k
      curl_off_t fragsize = (flags & CURLWS_OFFSET) ? static_cast<curl_off_t>(payload_len) : 0;
414
2.41k
      (void)curl_ws_send(easy, kPayload, payload_len, &sent, fragsize, flags);
415
2.41k
      if (connection() != nullptr) {
416
2.41k
        connection()->DrainIncoming();
417
2.41k
      }
418
2.41k
    }
419
127
  }
420
421
398
  if (probes.unaligned_send()) {
422
    // Multi-call mis-sized send probe: declare a fragsize=200 frame, send 23
423
    // bytes, then call curl_ws_send again with a buflen much bigger than the
424
    // remaining payload. Hits the "unaligned frame size" failf in ws_enc_send.
425
78
    constexpr curl_off_t kBigFrag = 200;
426
78
    (void)curl_ws_start_frame(easy, CURLWS_TEXT | CURLWS_OFFSET, kBigFrag);
427
78
    std::size_t sent = 0;
428
78
    (void)curl_ws_send(easy, kPayload, payload_len, &sent, kBigFrag, CURLWS_TEXT | CURLWS_OFFSET);
429
    // Now enc.payload_remain ≈ kBigFrag - payload_len. A follow-up send with
430
    // buflen > remaining trips the guard at ws_enc_send:~1050.
431
78
    std::size_t sent2 = 0;
432
78
    constexpr std::size_t kOverrun = 500;
433
78
    unsigned char overrun[kOverrun];
434
78
    std::fill(overrun, overrun + kOverrun, 'X');
435
78
    (void)curl_ws_send(easy, overrun, kOverrun, &sent2, kBigFrag, CURLWS_TEXT | CURLWS_OFFSET);
436
78
    if (connection() != nullptr) {
437
78
      connection()->DrainIncoming();
438
78
    }
439
78
  }
440
441
398
  if (probes.raw_send()) {
442
    // Raw-mode send path: reachable only when CURLOPT_WS_OPTIONS has
443
    // CURLWS_RAW_MODE set. curl_ws_send with flags=0, fragsize=0 takes the
444
    // data->set.ws_raw_mode branch → ws_send_raw. No-op for non-raw scenarios
445
    // (hits the "no flags given" failure path instead).
446
67
    std::size_t sent = 0;
447
67
    (void)curl_ws_send(easy, kPayload, payload_len, &sent, 0, 0);
448
67
    if (connection() != nullptr) {
449
67
      connection()->DrainIncoming();
450
67
    }
451
67
  }
452
398
}
453
454
}  // namespace proto_fuzzer