CVE-2023-48230: Out-of-bounds Write (CWE-787)
value: Cap'n Proto is a data interchange format and capability-based RPC system. In versions 1.0 and 1.0.1, when using the KJ HTTP library with WebSocket compression enabled, a buffer underrun can be caused by a remote peer. The underrun always writes a constant value that is not attacker-controlled, likely resulting in a crash, enabling a remote denial-of-service attack. Most Cap'n Proto and KJ users are unlikely to have this functionality enabled and so unlikely to be affected. Maintainers suspect only the Cloudflare Workers Runtime is affected.

If KJ HTTP is used with WebSocket compression enabled, a malicious peer may be able to cause a buffer underrun on a heap-allocated buffer. KJ HTTP is an optional library bundled with Cap'n Proto, but is not directly used by Cap'n Proto. WebSocket compression is disabled by default. It must be enabled via a setting passed to the KJ HTTP library via `HttpClientSettings` or `HttpServerSettings`. The bytes written out-of-bounds are always a specific constant 4-byte string `{ 0x00, 0x00, 0xFF, 0xFF }`. Because this string is not controlled by the attacker, maintainers believe it is unlikely that remote code execution is possible. However, it cannot be ruled out. This functionality first appeared in Cap'n Proto 1.0. Previous versions are not affected.

This issue is fixed in Cap'n Proto 1.0.1.1.
CWE description: The product writes data past the end, or before the beginning, of the intended buffer.
CVSS v3 base score: 9.8
Published: 2023-11-21T21:15Z
Repository: https://github.com/capnproto/capnproto
Language: Other
Files changed: c++/src/kj/compat/http.c++
Commit date: 2023-11-16 12:16:20 -0600
Commit message: Fix inconsistent decision about whether a WebSocket message is compressed.

When processing the header, we decide whether the mesasge is compressed based on the presence of the appropriate flag in the header. However, after receiving the whole message, we were expecting it to be compressed based only on whether the session had negotiated compression upfront. With this patch we only rely on the bits in the header (and throw if the header says it is compressed, but we didn't negotiate compression.)

Diff: diff --git a/c++/src/kj/compat/http.c++ b/c++/src/kj/compat/http.c++
index bf4d8ddc0..d71b5e5cd 100644
--- a/c++/src/kj/compat/http.c++
+++ b/c++/src/kj/compat/http.c++
@@ -2759,20 +2759,22 @@ public:
         });
       }
     }
 
     bool isFin = recvHeader.isFin();
+    bool isCompressed = false;
 
     kj::Array<byte> message;           // space to allocate
     byte* payloadTarget;               // location into which to read payload (size is payloadLen)
     kj::Maybe<size_t> originalMaxSize; // maxSize from first `receive()` call
     if (isFin) {
       size_t amountToAllocate;
       if (recvHeader.isCompressed() || fragmentCompressed) {
         // Add 4 since we append 0x00 0x00 0xFF 0xFF to the tail of the payload.
         // See: https://datatracker.ietf.org/doc/html/rfc7692#section-7.2.2
         amountToAllocate = payloadLen + 4;
+        isCompressed = true;
       } else {
         // Add space for NUL terminator when allocating text message.
         amountToAllocate = payloadLen + (opcode == OPCODE_TEXT && isFin);
       }
 
@@ -2817,11 +2819,12 @@ public:
     }
 
     Mask mask = recvHeader.getMask();
 
     auto handleMessage =
-        [this,opcode,payloadTarget,payloadLen,mask,isFin,maxSize,originalMaxSize,message=kj::mv(message)]() mutable
+        [this,opcode,payloadTarget,payloadLen,mask,isFin,maxSize,originalMaxSize,
+         isCompressed,message=kj::mv(message)]() mutable
         -> kj::Promise<Message> {
       if (!mask.isZero()) {
         mask.apply(kj::arrayPtr(payloadTarget, payloadLen));
       }
 
@@ -2836,12 +2839,14 @@ public:
         case OPCODE_CONTINUATION:
           // Shouldn't get here; handled above.
           KJ_UNREACHABLE;
         case OPCODE_TEXT:
 #if KJ_HAS_ZLIB
-          KJ_IF_SOME(config, compressionConfig) {
+          if (isCompressed) {
+            auto& config = KJ_ASSERT_NONNULL(compressionConfig);
             auto& decompressor = KJ_ASSERT_NONNULL(decompressionContext);
+            KJ_ASSERT(message.size() >= 4);
             auto tail = message.slice(message.size() - 4, message.size());
             // Note that we added an additional 4 bytes to `message`s capacity to account for these
             // extra bytes. See `amountToAllocate` in the if(recvHeader.isCompressed()) block above.
             const byte tailBytes[] = {0x00, 0x00, 0xFF, 0xFF};
             memcpy(tail.begin(), tailBytes, sizeof(tailBytes));
@@ -2860,12 +2865,14 @@ public:
 #endif // KJ_HAS_ZLIB
           message.back() = '\0';
           return Message(kj::String(message.releaseAsChars()));
         case OPCODE_BINARY:
 #if KJ_HAS_ZLIB
-          KJ_IF_SOME(config, compressionConfig) {
+          if (isCompressed) {
+            auto& config = KJ_ASSERT_NONNULL(compressionConfig);
             auto& decompressor = KJ_ASSERT_NONNULL(decompressionContext);
+            KJ_ASSERT(message.size() >= 4);
             auto tail = message.slice(message.size() - 4, message.size());
             // Note that we added an additional 4 bytes to `message`s capacity to account for these
             // extra bytes. See `amountToAllocate` in the if(recvHeader.isCompressed()) block above.
             const byte tailBytes[] = {0x00, 0x00, 0xFF, 0xFF};
             memcpy(tail.begin(), tailBytes, sizeof(tailBytes));

Vulnerable code:         [this,opcode,payloadTarget,payloadLen,mask,isFin,maxSize,originalMaxSize,message=kj::mv(message)]() mutable
          KJ_IF_SOME(config, compressionConfig) {
          KJ_IF_SOME(config, compressionConfig) {
Fixed code:     bool isCompressed = false;
        isCompressed = true;
        [this,opcode,payloadTarget,payloadLen,mask,isFin,maxSize,originalMaxSize,
         isCompressed,message=kj::mv(message)]() mutable
          if (isCompressed) {
            auto& config = KJ_ASSERT_NONNULL(compressionConfig);
            KJ_ASSERT(message.size() >= 4);
          if (isCompressed) {
            auto& config = KJ_ASSERT_NONNULL(compressionConfig);
            KJ_ASSERT(message.size() >= 4);