Mercurial > hg > orthanc
changeset 6668:5ce108190752
merged machine-spirits -> default
| author | Alain Mazy <am@orthanc.team> |
|---|---|
| date | Fri, 03 Apr 2026 11:46:14 +0200 |
| parents | 38f8a3b3d04e (current diff) eeeca0b5fab6 (diff) |
| children | 906d50d05430 |
| files | NEWS OrthancServer/Resources/Configuration.json OrthancServer/Sources/ServerContext.cpp |
| diffstat | 20 files changed, 648 insertions(+), 147 deletions(-) [+] |
line wrap: on
line diff
--- a/NEWS Mon Mar 30 14:21:48 2026 +0200 +++ b/NEWS Fri Apr 03 11:46:14 2026 +0200 @@ -16,6 +16,8 @@ grouped per series and ordered by their InstanceNumber instead of using a random order in previous Orthanc versions. * New experimental configuration "PatientLevelEnabled". +* New configuration option "MaximumRequestBodySizeMB" to limit the body size in HTTP requests +* New configuration option "MaximumFileSizeInArchiveMB" to limit the file size in ZIP/gzip archives TODO before release: re-enable test_content_disposition @@ -72,6 +74,16 @@ * Save the jobs registry in DB only if it has changed. https://discourse.orthanc-server.org/t/frequent-idle-messages-between-postgres-and-orthanc/6406 * New CMake option: "USE_SYSTEM_MINIZIP" to use the system-wide version of minizip +* Fixes for security issues reported Machine Spirits UG: + - Fix possible out-of-bound access when calling /tools/create-dicom with a PAM file. + - Fix possible out-of-bound access when rows/columns DICOM tags exceed the maximum value for a US (65535). + - Fix possible out-of-bound access in PMSCT_RLE1 encoded images. + - Fix possible out-of-bound access in palette images. + - Fix possible out-of-bound access when reading a DICOM file with invalid group length tag. + - Fix possible memory exhaustion via very large "Content-Length" + - Fix possible memory exhaustion via forged ZIP metadata + - Fix possible memory exhaustion via forged gzip metadata + - Sanitize HTTP header "Content-Disposition" when creating ZIP archives * Upgraded dependencies for static builds: - boost 1.89.0 - dcmtk 3.7.0
--- a/OrthancFramework/Sources/Compatibility.h Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/Compatibility.h Fri Apr 03 11:46:14 2026 +0200 @@ -72,10 +72,12 @@ // The override keyword (C++11) is enabled # define ORTHANC_OVERRIDE override # define ORTHANC_FINAL final +# define ORTHANC_NOEXCEPT noexcept #else // The override keyword (C++11) is not available # define ORTHANC_OVERRIDE # define ORTHANC_FINAL +# define ORTHANC_NOEXCEPT #endif
--- a/OrthancFramework/Sources/Compression/GzipCompressor.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/Compression/GzipCompressor.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -29,8 +29,16 @@ #include <string.h> #include <zlib.h> +#include "../ChunkedBuffer.h" +#include "../Logging.h" +#include "../MultiThreading/ReaderWriterLock.h" #include "../OrthancException.h" -#include "../Logging.h" + + +static Orthanc::ReaderWriterLock maximumUncompressedFileSizeMutex_; +static bool hasMaximumUncompressedFileSize_ = false; +static size_t maximumUncompressedFileSize_ = 0; + namespace Orthanc { @@ -177,100 +185,149 @@ } + namespace + { + class GzipRaii : public boost::noncopyable + { + private: + z_stream& stream_; + + public: + GzipRaii(z_stream& stream) : + stream_(stream) + { + int error = inflateInit2(&stream_, + MAX_WBITS + 16); // this is a gzip input + + if (error != Z_OK) + { + throw OrthancException(ErrorCode_InternalError, "Cannot initialize zlib"); + } + } + + ~GzipRaii() + { + inflateEnd(&stream_); + } + }; + } + + void GzipCompressor::Uncompress(std::string& uncompressed, const void* compressed, size_t compressedSize) { - uint64_t uncompressedSize; const uint8_t* source = reinterpret_cast<const uint8_t*>(compressed); + bool hasMaximumSize = false; + size_t maximumSize = 0; + if (HasPrefixWithUncompressedSize()) { - uncompressedSize = ReadUncompressedSizePrefix(compressed, compressedSize); + hasMaximumSize = true; + maximumSize = ReadUncompressedSizePrefix(compressed, compressedSize); source += sizeof(uint64_t); compressedSize -= sizeof(uint64_t); } else { - uncompressedSize = GuessUncompressedSize(compressed, compressedSize); - } + ReaderWriterLock::ReadLock lock(maximumUncompressedFileSizeMutex_); - try - { - uncompressed.resize(static_cast<size_t>(uncompressedSize)); - } - catch (...) - { - throw OrthancException(ErrorCode_NotEnoughMemory); + if (hasMaximumUncompressedFileSize_) + { + hasMaximumSize = true; + maximumSize = maximumUncompressedFileSize_; + } } z_stream stream; memset(&stream, 0, sizeof(stream)); - char dummy = '\0'; // zlib does not like NULL output buffers (even if the uncompressed data is empty) stream.next_in = const_cast<Bytef*>(source); - stream.next_out = reinterpret_cast<Bytef*>(uncompressedSize == 0 ? &dummy : &uncompressed[0]); - stream.avail_in = static_cast<uInt>(compressedSize); - stream.avail_out = static_cast<uInt>(uncompressedSize); - // Ensure no overflow (if the buffer is too large for the current archicture) - if (static_cast<size_t>(stream.avail_in) != compressedSize || - static_cast<size_t>(stream.avail_out) != uncompressedSize) + // Ensure no overflow (if the buffer is too large for the current zlib archicture) + if (static_cast<size_t>(stream.avail_in) != compressedSize) { throw OrthancException(ErrorCode_NotEnoughMemory); } - // Initialize the compression engine - int error = inflateInit2(&stream, - MAX_WBITS + 16); // this is a gzip input + ChunkedBuffer buffer; + + { + GzipRaii raii(stream); - if (error != Z_OK) - { - // Cannot initialize zlib - uncompressed.clear(); - throw OrthancException(ErrorCode_InternalError); - } + std::string chunk; + chunk.resize(10 * 1024 * 1024); // Read by chunks of 10MB + + int ret = Z_OK; - // Uncompress the input buffer - error = inflate(&stream, Z_FINISH); + while (ret != Z_STREAM_END) + { + stream.next_out = const_cast<Bytef*>(reinterpret_cast<const Bytef*>(chunk.data())); + stream.avail_out = static_cast<uInt>(chunk.size()); + + ret = inflate(&stream, Z_NO_FLUSH); - if (error != Z_STREAM_END) - { - inflateEnd(&stream); - uncompressed.clear(); + switch (ret) + { + case Z_STREAM_END: + case Z_OK: + break; // Normal - switch (error) - { + case Z_NEED_DICT: + case Z_DATA_ERROR: + case Z_STREAM_ERROR: + throw OrthancException(ErrorCode_BadFileFormat); + case Z_MEM_ERROR: throw OrthancException(ErrorCode_NotEnoughMemory); - + case Z_BUF_ERROR: - case Z_NEED_DICT: - throw OrthancException(ErrorCode_BadFileFormat); - + // Not fatal: means no progress was possible this round. + // If avail_in is also 0 here, the input is truncated. + if (stream.avail_in == 0) + { + throw OrthancException(ErrorCode_BadFileFormat, "Truncated gzip input"); + } + break; + default: - throw OrthancException(ErrorCode_InternalError); + throw OrthancException(ErrorCode_InternalError); // Unknown error + } + + const size_t produced = chunk.size() - stream.avail_out; + + if (hasMaximumSize && + buffer.GetNumBytes() + produced > maximumSize) + { + char s[32]; + sprintf(s, "%0.1f", static_cast<float>(maximumSize) / (1024.0f * 1024.0f)); + throw OrthancException(ErrorCode_BadFileFormat, "Uncompressed size exceeds limit (" + std::string(s) + "MB)"); + } + else + { + // OK, add the chunk + buffer.AddChunk(&chunk[0], produced); + } } } - size_t size = stream.total_out; + buffer.Flatten(uncompressed); + } - if (inflateEnd(&stream) != Z_OK) - { - uncompressed.clear(); - throw OrthancException(ErrorCode_InternalError); - } - if (size != uncompressedSize) + void GzipCompressor::SetMaximumUncompressedFileSize(uint64_t size) + { + if (static_cast<uint64_t>(static_cast<size_t>(size)) != size) { - uncompressed.clear(); - - // The uncompressed size was not that properly guess, presumably - // because of a file size over 4GB. Should fallback to - // stream-based decompression. - throw OrthancException(ErrorCode_NotImplemented, - "The uncompressed size of a gzip-encoded buffer was not properly guessed"); + throw OrthancException(ErrorCode_NotEnoughMemory); + } + else + { + ReaderWriterLock::WriteLock lock(maximumUncompressedFileSizeMutex_); + hasMaximumUncompressedFileSize_ = true; + maximumUncompressedFileSize_ = size; } } }
--- a/OrthancFramework/Sources/Compression/GzipCompressor.h Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/Compression/GzipCompressor.h Fri Apr 03 11:46:14 2026 +0200 @@ -45,5 +45,7 @@ virtual void Uncompress(std::string& uncompressed, const void* compressed, size_t compressedSize) ORTHANC_OVERRIDE; + + static void SetMaximumUncompressedFileSize(uint64_t size); }; }
--- a/OrthancFramework/Sources/Compression/ZipReader.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/Compression/ZipReader.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -41,7 +41,9 @@ #endif +#include "../ChunkedBuffer.h" #include "../OrthancException.h" +#include "../MultiThreading/ReaderWriterLock.h" #if ORTHANC_SANDBOXED != 1 # include "../SystemToolbox.h" @@ -66,6 +68,11 @@ #include <string.h> +static Orthanc::ReaderWriterLock maximumUncompressedFileSizeMutex_; +static bool hasMaximumUncompressedFileSize_ = false; +static size_t maximumUncompressedFileSize_ = 0; + + namespace Orthanc { // ZPOS64_T corresponds to "uint64_t" @@ -323,6 +330,62 @@ } + static bool ReadInternal(std::string& content, + unzFile& unzip, + uint64_t size) ORTHANC_NOEXCEPT + { +#if 0 + /** + * This was the code using in Orthanc <= 1.12.10 + **/ + content.resize(static_cast<size_t>(size)); + return (unzReadCurrentFile(unzip, &content[0], static_cast<uLong>(content.size())) != 0); + +#else + /** + * Read chunk by chunk to disarm ZIP bombs + **/ + ChunkedBuffer buffer; + + std::string chunk; + chunk.resize(10 * 1024 * 1024); // Read by chunks of 10MB + + for (;;) + { + int r = unzReadCurrentFile(unzip, &chunk[0], chunk.size()); + + if (r == 0) + { + // We're done + if (buffer.GetNumBytes() == size) + { + buffer.Flatten(content); + return true; + } + else + { + return false; // Presumably a malicious ZIP file + } + } + else if (r < 0) + { + // Error (might come from a malicious ZIP file) + return false; + } + else if (static_cast<uint64_t>(buffer.GetNumBytes()) + r > size) + { + // Presumably a ZIP bomb + return false; + } + else + { + buffer.AddChunk(&chunk[0], r); + } + } +#endif + } + + bool ZipReader::ReadNextFile(std::string& filename, std::string& content) { @@ -340,6 +403,12 @@ throw OrthancException(ErrorCode_BadFileFormat); } + if (static_cast<uint64_t>(static_cast<size_t>(info.uncompressed_size)) != info.uncompressed_size) + { + // Too large file for a 32bit architecture + throw OrthancException(ErrorCode_NotEnoughMemory); + } + filename.resize(info.size_filename); if (!filename.empty() && unzGetCurrentFileInfo64(pimpl_->unzip_, &info, &filename[0], @@ -348,14 +417,28 @@ throw OrthancException(ErrorCode_BadFileFormat); } - content.resize(info.uncompressed_size); + { + // Prevent ZIP bombs + ReaderWriterLock::ReadLock lock(maximumUncompressedFileSizeMutex_); - if (!content.empty()) + if (hasMaximumUncompressedFileSize_ && + info.uncompressed_size > maximumUncompressedFileSize_) + { + char s[32]; + sprintf(s, "%0.1f", static_cast<float>(maximumUncompressedFileSize_) / (1024.0f * 1024.0f)); + throw OrthancException(ErrorCode_BadFileFormat, "Uncompressed size exceeds limit (" + std::string(s) + "MB)"); + } + } + + if (info.uncompressed_size == 0) + { + content.clear(); + } + else { if (unzOpenCurrentFile(pimpl_->unzip_) == 0) { - bool success = (unzReadCurrentFile(pimpl_->unzip_, &content[0], - static_cast<uLong>(content.size())) != 0); + bool success = ReadInternal(content, pimpl_->unzip_, info.uncompressed_size); if (unzCloseCurrentFile(pimpl_->unzip_) != 0 || !success) @@ -442,4 +525,19 @@ } } #endif + + + void ZipReader::SetMaximumUncompressedFileSize(uint64_t size) + { + if (static_cast<uint64_t>(static_cast<size_t>(size)) != size) + { + throw OrthancException(ErrorCode_NotEnoughMemory); + } + else + { + ReaderWriterLock::WriteLock lock(maximumUncompressedFileSizeMutex_); + hasMaximumUncompressedFileSize_ = true; + maximumUncompressedFileSize_ = size; + } + } }
--- a/OrthancFramework/Sources/Compression/ZipReader.h Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/Compression/ZipReader.h Fri Apr 03 11:46:14 2026 +0200 @@ -87,5 +87,7 @@ #if ORTHANC_SANDBOXED != 1 static bool IsZipFile(const boost::filesystem::path& path); #endif + + static void SetMaximumUncompressedFileSize(uint64_t size); }; }
--- a/OrthancFramework/Sources/DicomFormat/DicomImageInformation.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/DicomFormat/DicomImageInformation.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -42,6 +42,8 @@ #include <stdio.h> #include <memory> +static const uint64_t MAX_FRAME_SIZE = 4ul * 1024ul * 1024ul * 1024ul; // defensive approach: set a reasonable max size for a frame + namespace Orthanc { DicomImageInformation::DicomImageInformation(const DicomMap& values) @@ -130,6 +132,11 @@ values.GetValue(DICOM_TAG_COLUMNS).ParseFirstUnsignedInteger(width_); // in some US images, we've seen tag values of "800\0"; that's why we parse the 'first' value values.GetValue(DICOM_TAG_ROWS).ParseFirstUnsignedInteger(height_); + if (height_ > 65535 || width_ > 65535) + { + throw OrthancException(ErrorCode_BadFileFormat, "Image width or height exceed DICOM VR US range (65535)"); + } + if (!values.ParseUnsignedInteger32(bitsAllocated_, DICOM_TAG_BITS_ALLOCATED)) { throw OrthancException(ErrorCode_BadFileFormat); @@ -454,13 +461,15 @@ size_t DicomImageInformation::GetFrameSize() const { + uint64_t totalFrameSize; + if (bitsStored_ == 1) { assert(GetWidth() % 8 == 0); if (GetChannelCount() == 1) { - return GetHeight() * GetWidth() / 8; + totalFrameSize = static_cast<uint64_t>(GetHeight()) * static_cast<uint64_t>(GetWidth()) / 8; } else { @@ -470,10 +479,20 @@ } else { - return (GetHeight() * - GetWidth() * - GetBytesPerValue() * - GetChannelCount()); + totalFrameSize = (static_cast<uint64_t>(GetHeight()) * + static_cast<uint64_t>(GetWidth()) * + static_cast<uint64_t>(GetBytesPerValue()) * + static_cast<uint64_t>(GetChannelCount())); + } + + if (totalFrameSize > MAX_FRAME_SIZE || + static_cast<uint64_t>(static_cast<size_t>(totalFrameSize)) != totalFrameSize) + { + throw OrthancException(ErrorCode_BadFileFormat, "DICOM Frame size overflow"); + } + else + { + return static_cast<size_t>(totalFrameSize); } }
--- a/OrthancFramework/Sources/DicomFormat/DicomIntegerPixelAccessor.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/DicomFormat/DicomIntegerPixelAccessor.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -49,7 +49,7 @@ information_.GetBitsStored() >= 32) { // Not available, as the accessor internally uses int32_t values - throw OrthancException(ErrorCode_NotImplemented); + throw OrthancException(ErrorCode_NotImplemented, "No support for BitsAllocated > 32 or BitsStored >= 32"); } frame_ = 0; @@ -57,7 +57,7 @@ if (information_.GetNumberOfFrames() * frameOffset_ > size) { - throw OrthancException(ErrorCode_BadFileFormat); + throw OrthancException(ErrorCode_BadFileFormat, "Invalid size"); } if (information_.IsSigned())
--- a/OrthancFramework/Sources/DicomFormat/DicomStreamReader.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/DicomFormat/DicomStreamReader.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -204,6 +204,11 @@ { uint16_t length = ReadUnsignedInteger16(p + pos + 6, true); + if (pos + 8 + length > block.size()) + { + throw OrthancException(ErrorCode_BadFileFormat, "DICOM meta-header tag length exceeds available data"); + } + std::string value; value.assign(p + pos + 8, length); NormalizeValue(value, vr); @@ -237,6 +242,11 @@ uint32_t length = ReadUnsignedInteger32(p + pos + 8, true); + if (pos + 12 + static_cast<size_t>(length) > block.size()) + { + throw OrthancException(ErrorCode_BadFileFormat, "DICOM meta-header tag length exceeds available data"); + } + if (tag.GetGroup() == 0x0002) { std::string value;
--- a/OrthancFramework/Sources/DicomParsing/Internals/DicomImageDecoder.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/DicomParsing/Internals/DicomImageDecoder.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -192,6 +192,11 @@ { if (inbuffer[i] == 0xa5) { + if (i + 2 >= length) + { + throw OrthancException(ErrorCode_BadFileFormat, "Truncated PMSCT_RLE1 escape sequence"); + } + temp.push_back(inbuffer[i+2]); for (uint8_t repeat = inbuffer[i + 1]; repeat != 0; repeat--) { @@ -215,6 +220,11 @@ if (temp[i] == 0x5a) { + if (i + 2 >= temp.size()) + { + throw OrthancException(ErrorCode_BadFileFormat, "Truncated PMSCT_RLE1 delta sequence"); + } + uint16_t v1 = temp[i + 1]; uint16_t v2 = temp[i + 2]; value = (v2 << 8) + v1; @@ -460,9 +470,12 @@ throw OrthancException(ErrorCode_NotImplemented, std::string("Palette Color Lookup Table Descriptor invalid palette size: '") + r.c_str() + "'"); } - if (pixelLength != target->GetWidth() * target->GetHeight()) + const uint64_t expectedSize = (static_cast<uint64_t>(target->GetWidth()) * + static_cast<uint64_t>(target->GetHeight())); + + if (pixelLength != expectedSize) { - DcmElement *elem; + DcmElement *elem = NULL; Uint16 bitsAllocated = 0; if (!dataset.findAndGetUint16(DCM_BitsAllocated, bitsAllocated).good()) @@ -470,7 +483,8 @@ throw OrthancException(ErrorCode_NotImplemented); } - if (!dataset.findAndGetElement(DCM_PixelData, elem).good()) + if (!dataset.findAndGetElement(DCM_PixelData, elem).good() || + elem == NULL) { throw OrthancException(ErrorCode_NotImplemented); } @@ -478,9 +492,11 @@ // In implicit VR files, pixelLength is expressed in words (OW) although pixels can actually be 8 bits // -> pixelLength is wrong by a factor of two and the image can still be decoded! // seen in some Philips ClearVue 650 images (using 8 bits LUT) - if (!(elem->getVR() == EVR_OW && bitsAllocated == 8 && (2*pixelLength == target->GetWidth() * target->GetHeight()))) + if (elem->getVR() != EVR_OW || + bitsAllocated != 8 || + 2 * pixelLength != expectedSize) { - throw OrthancException(ErrorCode_NotImplemented); + throw OrthancException(ErrorCode_BadFileFormat, "Invalid size"); } } @@ -494,6 +510,11 @@ for (unsigned int x = 0; x < width; x++) { + if (*source >= paletteSize) + { + throw OrthancException(ErrorCode_BadFileFormat, "Pixel value exceeds palette"); + } + p[0] = lutRed[*source] >> offsetBits; p[1] = lutGreen[*source] >> offsetBits; p[2] = lutBlue[*source] >> offsetBits;
--- a/OrthancFramework/Sources/HttpServer/HttpOutput.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/HttpServer/HttpOutput.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -981,7 +981,7 @@ std::string filename; if (stream.HasContentFilename(filename)) { - stateMachine_.AddHeader("Content-Disposition", "filename=\"" + std::string(filename) + "\""); + stateMachine_.SetContentFilename(filename.c_str()); } stateMachine_.StartStream(contentType);
--- a/OrthancFramework/Sources/HttpServer/HttpServer.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/HttpServer/HttpServer.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -185,7 +185,8 @@ PostDataStatus_Success, PostDataStatus_NoLength, PostDataStatus_Pending, - PostDataStatus_Failure + PostDataStatus_Failure, + PostDataStatus_RequestEntityTooLarge // New in Orthanc 1.12.11 }; } @@ -491,57 +492,22 @@ reader.AddChunk(body); reader.CloseStream(); } - - static PostDataStatus ReadBodyWithContentLength(std::string& body, - struct mg_connection *connection, - const std::string& contentLength) - { - size_t length; - try - { - int64_t tmp = boost::lexical_cast<int64_t>(contentLength); - if (tmp < 0) - { - return PostDataStatus_NoLength; - } - - length = static_cast<size_t>(tmp); - } - catch (boost::bad_lexical_cast&) - { - return PostDataStatus_NoLength; - } - body.resize(length); - - size_t pos = 0; - while (length > 0) - { - int r = mg_read(connection, &body[pos], length); - if (r <= 0) - { - return PostDataStatus_Failure; - } + static PostDataStatus ReadBodyUsingFile(std::string& body, + struct mg_connection *connection, + bool hasMaxBodySize, + size_t maxBodySize) + { + assert(!hasMaxBodySize || maxBodySize > 0); - assert(static_cast<size_t>(r) <= length); - length -= r; - pos += r; - } - - return PostDataStatus_Success; - } - - - static PostDataStatus ReadBodyWithoutContentLength(std::string& body, - struct mg_connection *connection) - { // Store the individual chunks in a temporary file, then read it // back into the memory buffer "body" FileBuffer buffer; + uint64_t readSoFar = 0; std::string tmp(1024 * 1024, 0); - + for (;;) { int r = mg_read(connection, &tmp[0], tmp.size()); @@ -555,6 +521,15 @@ } else { + readSoFar += r; + + if (readSoFar > std::numeric_limits<size_t>::max() || + (hasMaxBodySize && + readSoFar > maxBodySize)) + { + return PostDataStatus_RequestEntityTooLarge; + } + buffer.Append(tmp.c_str(), r); } } @@ -563,30 +538,129 @@ return PostDataStatus_Success; } + + + static PostDataStatus ReadBodyWithContentLength(std::string& body, + struct mg_connection *connection, + const std::string& contentLength, + bool hasMaxBodySize, + size_t maxBodySize) + { + static const size_t MAXIMUM_BODY_SIZE_IN_MEMORY = 100 * 1024 * 1024; // 100MB + + assert(!hasMaxBodySize || maxBodySize > 0); + + size_t length; + try + { + int64_t tmp = boost::lexical_cast<int64_t>(contentLength); + if (tmp < 0) + { + return PostDataStatus_NoLength; + } + + length = static_cast<size_t>(tmp); + if (static_cast<int64_t>(length) != tmp) + { + return PostDataStatus_Failure; + } + } + catch (boost::bad_lexical_cast&) + { + return PostDataStatus_NoLength; + } + + if (hasMaxBodySize && + length > maxBodySize) + { + return PostDataStatus_RequestEntityTooLarge; + } + + if (length < MAXIMUM_BODY_SIZE_IN_MEMORY) + { + /** + * Small POST bodies should land into RAM to avoid creating + * temporary files, which would result in bad performance. + **/ + body.resize(length); + + size_t pos = 0; + while (length > 0) + { + int r = mg_read(connection, &body[pos], length); + if (r <= 0) + { + return PostDataStatus_Failure; + } + + assert(static_cast<size_t>(r) <= length); + length -= r; + pos += r; + } + + return PostDataStatus_Success; + } + else + { + /** + * Deal with CWE-770 (Machine Spirits UG). If the client wants + * to send a large body, use a temporary file to prevent memory + * exhaustion by a malicious client that would set a large + * "Content-Length" without sending any actual data. + **/ + + PostDataStatus status = ReadBodyUsingFile(body, connection, + true /* do not read after "Content-Length" */, length); + + if (status == PostDataStatus_Success) + { + return (body.size() == length ? + PostDataStatus_Success : + PostDataStatus_Failure); + } + else + { + return status; + } + } + } + + + static PostDataStatus ReadBodyWithoutContentLength(std::string& body, + struct mg_connection *connection, + bool hasMaxBodySize, + size_t maxBodySize) + { + return ReadBodyUsingFile(body, connection, hasMaxBodySize, maxBodySize); + } static PostDataStatus ReadBodyToString(std::string& body, struct mg_connection *connection, - const HttpToolbox::Arguments& headers) + const HttpToolbox::Arguments& headers, + bool hasMaxBodySize, + size_t maxBodySize) { HttpToolbox::Arguments::const_iterator contentLength = headers.find("content-length"); if (contentLength != headers.end()) { // "Content-Length" is available - return ReadBodyWithContentLength(body, connection, contentLength->second); + return ReadBodyWithContentLength(body, connection, contentLength->second, hasMaxBodySize, maxBodySize); } else { // No Content-Length - return ReadBodyWithoutContentLength(body, connection); + return ReadBodyWithoutContentLength(body, connection, hasMaxBodySize, maxBodySize); } } static PostDataStatus ReadBodyToStream(IHttpHandler::IChunkedRequestReader& stream, struct mg_connection *connection, - const HttpToolbox::Arguments& headers) + const HttpToolbox::Arguments& headers, + bool hasMaxBodySize, + size_t maxBodySize) { HttpToolbox::Arguments::const_iterator contentLength = headers.find("content-length"); @@ -594,7 +668,7 @@ { // "Content-Length" is available std::string body; - PostDataStatus status = ReadBodyWithContentLength(body, connection, contentLength->second); + PostDataStatus status = ReadBodyWithContentLength(body, connection, contentLength->second, hasMaxBodySize, maxBodySize); if (status == PostDataStatus_Success && !body.empty()) @@ -830,7 +904,9 @@ const std::string& method, const HttpToolbox::Arguments& headers, const std::string& uri, - struct mg_connection *connection /* to read the PUT body if need be */) + struct mg_connection *connection /* to read the PUT body if need be */, + bool hasMaxBodySize, + size_t maxBodySize) { if (buckets.empty()) { @@ -1040,7 +1116,7 @@ { #if CIVETWEB_HAS_WEBDAV_WRITING == 1 std::string body; - if (ReadBodyToString(body, connection, headers) == PostDataStatus_Success) + if (ReadBodyToString(body, connection, headers, hasMaxBodySize, maxBodySize) == PostDataStatus_Success) { if (bucket->second->StoreFile(body, path)) { @@ -1407,7 +1483,7 @@ #if ORTHANC_ENABLE_PUGIXML == 1 if (HandleWebDav(output, server.GetWebDavBuckets(), request->request_method, - headers, requestUri, connection)) + headers, requestUri, connection, server.HasMaxBodySize(), server.GetMaxBodySize())) { return; } @@ -1447,7 +1523,7 @@ **/ isMultipartForm = true; - postStatus = ReadBodyToString(body, connection, headers); + postStatus = ReadBodyToString(body, connection, headers, server.HasMaxBodySize(), server.GetMaxBodySize()); if (postStatus == PostDataStatus_Success) { server.ProcessMultipartFormData(remoteIp, username, uri, headers, body, boundary, authenticationPayload); @@ -1473,7 +1549,7 @@ throw OrthancException(ErrorCode_InternalError); } - postStatus = ReadBodyToStream(*stream, connection, headers); + postStatus = ReadBodyToStream(*stream, connection, headers, server.HasMaxBodySize(), server.GetMaxBodySize()); if (postStatus == PostDataStatus_Success) { @@ -1482,7 +1558,7 @@ } else { - postStatus = ReadBodyToString(body, connection, headers); + postStatus = ReadBodyToString(body, connection, headers, server.HasMaxBodySize(), server.GetMaxBodySize()); } } @@ -1492,6 +1568,10 @@ output.SendStatus(HttpStatus_411_LengthRequired); return; + case PostDataStatus_RequestEntityTooLarge: // New in Orthanc 1.12.11 + output.SendStatus(HttpStatus_413_RequestEntityTooLarge); + return; + case PostDataStatus_Failure: output.SendStatus(HttpStatus_400_BadRequest); return; @@ -1685,7 +1765,9 @@ threadsCount_(50), // Default value in mongoose/civetweb tcpNoDelay_(true), requestTimeout_(30), // Default value in mongoose/civetweb (30 seconds) - threadCounter_(0) + threadCounter_(0), + hasMaxBodySize_(false), + maxBodySize_(0) { #if ORTHANC_ENABLE_MONGOOSE == 1 CLOG(INFO, HTTP) << "This Orthanc server uses Mongoose as its embedded HTTP server"; @@ -2389,4 +2471,21 @@ Logging::SetCurrentThreadName(std::string("HTTP-") + boost::lexical_cast<std::string>(threadCounter_++)); } } + + + void HttpServer::SetMaxBodySize(uint64_t size) + { + Stop(); + + if (size == 0 || + static_cast<uint64_t>(static_cast<size_t>(size)) != size) + { + throw OrthancException(ErrorCode_ParameterOutOfRange); + } + else + { + hasMaxBodySize_ = true; + maxBodySize_ = static_cast<size_t>(size); + } + } }
--- a/OrthancFramework/Sources/HttpServer/HttpServer.h Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/HttpServer/HttpServer.h Fri Apr 03 11:46:14 2026 +0200 @@ -123,6 +123,10 @@ boost::mutex threadCounterMutex_; // New in Orthanc 1.12.9 uint16_t threadCounter_; // Introduced as a global, static variable in Orthanc 1.12.2 + // New in Orthanc 1.12.11 + bool hasMaxBodySize_; + size_t maxBodySize_; + #if ORTHANC_ENABLE_PUGIXML == 1 WebDavBuckets webDavBuckets_; #endif @@ -246,5 +250,17 @@ MetricsRegistry::AvailableResourcesDecounter* CreateAvailableHttpThreadsDecounter(); void UpdateCurrentThreadName(); + + void SetMaxBodySize(uint64_t size); + + bool HasMaxBodySize() const + { + return hasMaxBodySize_; + } + + size_t GetMaxBodySize() const + { + return maxBodySize_; + } }; }
--- a/OrthancFramework/Sources/Images/ImageAccessor.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/Images/ImageAccessor.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -165,7 +165,14 @@ { if (buffer_ != NULL) { - return buffer_ + static_cast<size_t>(y) * static_cast<size_t>(pitch_); + if (y < height_) + { + return buffer_ + static_cast<size_t>(y) * static_cast<size_t>(pitch_); + } + else + { + throw OrthancException(ErrorCode_ParameterOutOfRange); + } } else { @@ -184,7 +191,14 @@ if (buffer_ != NULL) { - return buffer_ + static_cast<size_t>(y) * static_cast<size_t>(pitch_); + if (y < height_) + { + return buffer_ + static_cast<size_t>(y) * static_cast<size_t>(pitch_); + } + else + { + throw OrthancException(ErrorCode_ParameterOutOfRange); + } } else { @@ -210,17 +224,20 @@ unsigned int pitch, const void *buffer) { + const uint64_t size = static_cast<uint64_t>(height) * static_cast<uint64_t>(pitch); + + if (static_cast<uint64_t>(GetBytesPerPixel() * width) > static_cast<uint64_t>(pitch) || + static_cast<uint64_t>(static_cast<size_t>(size)) != size) + { + throw OrthancException(ErrorCode_ParameterOutOfRange); + } + readOnly_ = true; format_ = format; width_ = width; height_ = height; pitch_ = pitch; buffer_ = reinterpret_cast<uint8_t*>(const_cast<void*>(buffer)); - - if (GetBytesPerPixel() * width_ > pitch_) - { - throw OrthancException(ErrorCode_ParameterOutOfRange); - } } void ImageAccessor::GetReadOnlyAccessor(ImageAccessor &target) const @@ -235,17 +252,20 @@ unsigned int pitch, void *buffer) { + const uint64_t size = static_cast<uint64_t>(height) * static_cast<uint64_t>(pitch); + + if (static_cast<uint64_t>(GetBytesPerPixel() * width) > static_cast<uint64_t>(pitch) || + static_cast<uint64_t>(static_cast<size_t>(size)) != size) + { + throw OrthancException(ErrorCode_ParameterOutOfRange); + } + readOnly_ = false; format_ = format; width_ = width; height_ = height; pitch_ = pitch; buffer_ = reinterpret_cast<uint8_t*>(buffer); - - if (GetBytesPerPixel() * width_ > pitch_) - { - throw OrthancException(ErrorCode_ParameterOutOfRange); - } }
--- a/OrthancFramework/Sources/Images/ImageBuffer.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/Images/ImageBuffer.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -46,8 +46,16 @@ } */ - pitch_ = GetBytesPerPixel() * width_; - size_t size = static_cast<size_t>(pitch_) * static_cast<size_t>(height_); + const uint64_t tmpPitch = static_cast<uint64_t>(GetBytesPerPixel()) * static_cast<uint64_t>(width_); + const uint64_t size = tmpPitch * static_cast<uint64_t>(height_); + + if (static_cast<uint64_t>(static_cast<unsigned int>(tmpPitch)) != tmpPitch || + static_cast<uint64_t>(static_cast<size_t>(size)) != size) + { + throw OrthancException(ErrorCode_NotEnoughMemory); + } + + pitch_ = static_cast<unsigned int>(tmpPitch); if (size == 0) { @@ -55,7 +63,7 @@ } else { - buffer_ = malloc(size); + buffer_ = malloc(static_cast<size_t>(size)); if (buffer_ == NULL) { throw OrthancException(ErrorCode_NotEnoughMemory,
--- a/OrthancFramework/Sources/Images/PamReader.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancFramework/Sources/Images/PamReader.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -38,6 +38,7 @@ #include <boost/algorithm/string/find.hpp> #include <boost/lexical_cast.hpp> +static const uint64_t MAX_PAM_IMAGE_BUFFER_SIZE = 4ul * 1024ul * 1024ul * 1024ul; // defensive approach: set a reasonable max size for a PAM image namespace Orthanc { @@ -181,11 +182,29 @@ const unsigned int maxValue = LookupIntegerParameter(parameters, "MAXVAL"); const std::string tupleType = LookupStringParameter(parameters, "TUPLTYPE"); + if (width > 65535 || height > 65535 || channelCount > 4 || maxValue > 65535) + { + throw OrthancException(ErrorCode_BadFileFormat, "PAM header values exceed reasonable limits"); + } + unsigned int bytesPerChannel; PixelFormat format; GetPixelFormat(format, bytesPerChannel, maxValue, channelCount, tupleType); - unsigned int pitch = width * channelCount * bytesPerChannel; + // unsigned int pitch = width * channelCount * bytesPerChannel; + uint64_t pitch = static_cast<uint64_t>(width) * channelCount * bytesPerChannel; + + if (pitch > std::numeric_limits<unsigned int>::max()) + { + throw OrthancException(ErrorCode_BadFileFormat, "PAM dimensions exceed limits"); + } + + uint64_t totalSize = pitch * height; + if (totalSize > MAX_PAM_IMAGE_BUFFER_SIZE || + static_cast<uint64_t>(static_cast<size_t>(totalSize)) != totalSize) + { + throw OrthancException(ErrorCode_BadFileFormat, "PAM image too large"); + } if (content_.size() != header.size() + headerDelimiter.size() + pitch * height) { @@ -196,7 +215,7 @@ { intptr_t bufferAddr = reinterpret_cast<intptr_t>(&content_[offset]); - if((bufferAddr % 8) == 0) + if ((bufferAddr % 8) == 0) LOG(TRACE) << "PamReader::ParseContent() image address = " << bufferAddr; else LOG(TRACE) << "PamReader::ParseContent() image address = " << bufferAddr << " (not a multiple of 8!)";
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/OrthancFramework/Sources/MultiThreading/ReaderWriterLock.h Fri Apr 03 11:46:14 2026 +0200 @@ -0,0 +1,76 @@ +/** + * Orthanc - A Lightweight, RESTful DICOM Store + * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics + * Department, University Hospital of Liege, Belgium + * Copyright (C) 2017-2023 Osimis S.A., Belgium + * Copyright (C) 2024-2026 Orthanc Team SRL, Belgium + * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program. If not, see + * <http://www.gnu.org/licenses/>. + **/ + + +#pragma once + +#include "../OrthancFramework.h" + +#if !defined(__EMSCRIPTEN__) +// Multithreading is not supported in WebAssembly +# include <boost/thread/shared_mutex.hpp> +# include <boost/thread/lock_types.hpp> // For boost::unique_lock<> and boost::shared_lock<> +#endif + + +namespace Orthanc +{ + class ORTHANC_PUBLIC ReaderWriterLock : public boost::noncopyable + { + private: + boost::shared_mutex mutex_; + + public: + class ReadLock : public boost::noncopyable + { + private: +#if !defined(__EMSCRIPTEN__) + boost::shared_lock<boost::shared_mutex> lock_; +#endif + + public: + explicit ReadLock(ReaderWriterLock& that) +#if !defined(__EMSCRIPTEN__) + : lock_(that.mutex_) +#endif + { + } + }; + + class WriteLock : public boost::noncopyable + { + private: +#if !defined(__EMSCRIPTEN__) + boost::unique_lock<boost::shared_mutex> lock_; +#endif + + public: + explicit WriteLock(ReaderWriterLock& that) +#if !defined(__EMSCRIPTEN__) + : lock_(that.mutex_) +#endif + { + } + }; + }; +}
--- a/OrthancServer/Resources/Configuration.json Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancServer/Resources/Configuration.json Fri Apr 03 11:46:14 2026 +0200 @@ -1115,6 +1115,17 @@ // "/tools/create-archives" routes. (new in Orthanc 1.12.11) "ZipUseUtf8" : false, + // Maximum allowed size (in MB) of the body of an HTTP request (POST + // or PUT), to prevent resource exhaustion. A value of "0" means no + // limit (default in Orthanc <= 1.12.10). (new in Orthanc 1.12.11) + "MaximumRequestBodySizeMB" : 2048, + + // Maximum allowed size (in MB) of a file after decompression when + // it is contained in a ZIP or gzip archive, to prevent resource + // exhaustion by ZIP/gzip bombs. A value of "0" means no limit (this + // was the default in Orthanc <= 1.12.10). (new in Orthanc 1.12.11) + "MaximumFileSizeInArchiveMB" : 512, + // When set to false, this option disables all /patients routes and // disables patients related sanity checks when performing resource // modification. This is required e.g when your Orthanc stores
--- a/OrthancServer/Sources/ServerContext.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancServer/Sources/ServerContext.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -1984,7 +1984,7 @@ } catch (OrthancException& e) { - LOG(INFO) << e.GetDetails(); + LOG(INFO) << "Failed to decode a DICOM frame: " << e.GetDetails(); } }
--- a/OrthancServer/Sources/main.cpp Mon Mar 30 14:21:48 2026 +0200 +++ b/OrthancServer/Sources/main.cpp Fri Apr 03 11:46:14 2026 +0200 @@ -25,6 +25,8 @@ #include "OrthancRestApi/OrthancRestApi.h" #include "../../OrthancFramework/Sources/Compatibility.h" +#include "../../OrthancFramework/Sources/Compression/GzipCompressor.h" +#include "../../OrthancFramework/Sources/Compression/ZipReader.h" #include "../../OrthancFramework/Sources/DicomFormat/DicomArray.h" #include "../../OrthancFramework/Sources/DicomNetworking/DicomAssociationParameters.h" #include "../../OrthancFramework/Sources/DicomNetworking/DicomServer.h" @@ -47,8 +49,8 @@ #include "OrthancWebDav.h" #include "ServerContext.h" #include "ServerEnumerations.h" +#include "ServerJobs/DicomRetrieveScuBaseJob.h" #include "ServerJobs/StorageCommitmentScpJob.h" -#include "ServerJobs/DicomRetrieveScuBaseJob.h" #include "ServerToolbox.h" #include "StorageCommitmentReports.h" @@ -1126,6 +1128,33 @@ httpServer.SetTcpNoDelay(lock.GetConfiguration().GetBooleanParameter("TcpNoDelay", true)); httpServer.SetRequestTimeout(lock.GetConfiguration().GetUnsignedIntegerParameter("HttpRequestTimeout", 30)); + // New in Orthanc 1.12.11 + const unsigned int maxBodySize = lock.GetConfiguration().GetUnsignedIntegerParameter("MaximumRequestBodySizeMB", 2048); + if (maxBodySize != 0) + { + LOG(WARNING) << "Limiting the maximum body size in HTTP requests to " << maxBodySize << "MB"; + httpServer.SetMaxBodySize(static_cast<uint64_t>(maxBodySize) * + static_cast<uint64_t>(1024 * 1024)); + } + else + { + LOG(WARNING) << "No limit on the maximum body size in HTTP requests"; + } + + const unsigned int maxSizeInArchive = lock.GetConfiguration().GetUnsignedIntegerParameter("MaximumFileSizeInArchiveMB", 512); + if (maxSizeInArchive != 0) + { + LOG(WARNING) << "Limiting on the maximum file size uncompressed from ZIP/gzip archives to " << maxSizeInArchive << "MB"; + ZipReader::SetMaximumUncompressedFileSize(static_cast<uint64_t>(maxSizeInArchive) * + static_cast<uint64_t>(1024 * 1024)); + GzipCompressor::SetMaximumUncompressedFileSize(static_cast<uint64_t>(maxSizeInArchive) * + static_cast<uint64_t>(1024 * 1024)); + } + else + { + LOG(WARNING) << "No limit on the maximum file size uncompressed from ZIP/gzip archives"; + } + // Let's assume that the HTTP server is secure context.SetHttpServerSecure(true);
