util: fix brotli decompression of empty input

This caused an infinite loop before since it would just keep asking the
underlying source for more data.

In practice this happened because an HTTP server served a
response to a HEAD request (for which curl will not retrieve any body or
call our write callback function) with Content-Encoding: br, leading to
decompressing nothing at all and going into an infinite loop.

This adds a test to make sure none of our compression methods do that
again, as well as just patching the HTTP client to never feed empty data
into a compression algorithm (since they absolutely have the right to
throw CompressionError on unexpectedly-short streams!).

Reported on Matrix: https://matrix.to/#/!lymvtcwDJ7ZA9Npq:lix.systems/$8BWQR_zKxCQDJ40C5NnDo4bQPId3pZ_aoDj2ANP7Itc?via=lix.systems&via=matrix.org&via=tchncs.de

Change-Id: I027566e280f0f569fdb8df40e5ecbf46c211dad1
This commit is contained in:
jade 2024-09-17 18:27:22 -07:00
parent 4046e019ca
commit 789b19a0cf
4 changed files with 55 additions and 6 deletions

View file

@ -337,7 +337,7 @@ struct curlFileTransfer : public FileTransfer
// wrapping user `callback`s instead is not possible because the // wrapping user `callback`s instead is not possible because the
// Callback api expects std::functions, and copying Callbacks is // Callback api expects std::functions, and copying Callbacks is
// not possible due the promises they hold. // not possible due the promises they hold.
if (code == CURLE_OK && !dataCallback) { if (code == CURLE_OK && !dataCallback && result.data.length() > 0) {
result.data = decompress(encoding, result.data); result.data = decompress(encoding, result.data);
} }

View file

@ -144,6 +144,7 @@ struct BrotliDecompressionSource : Source
std::unique_ptr<char[]> buf; std::unique_ptr<char[]> buf;
size_t avail_in = 0; size_t avail_in = 0;
const uint8_t * next_in; const uint8_t * next_in;
std::exception_ptr inputEofException = nullptr;
Source * inner; Source * inner;
std::unique_ptr<BrotliDecoderState, void (*)(BrotliDecoderState *)> state; std::unique_ptr<BrotliDecoderState, void (*)(BrotliDecoderState *)> state;
@ -167,23 +168,42 @@ struct BrotliDecompressionSource : Source
while (len && !BrotliDecoderIsFinished(state.get())) { while (len && !BrotliDecoderIsFinished(state.get())) {
checkInterrupt(); checkInterrupt();
while (avail_in == 0) { while (avail_in == 0 && inputEofException == nullptr) {
try { try {
avail_in = inner->read(buf.get(), BUF_SIZE); avail_in = inner->read(buf.get(), BUF_SIZE);
} catch (EndOfFile &) { } catch (EndOfFile &) {
// No more data, but brotli may still have output remaining
// from the last call.
inputEofException = std::current_exception();
break; break;
} }
next_in = charptr_cast<const uint8_t *>(buf.get()); next_in = charptr_cast<const uint8_t *>(buf.get());
} }
if (!BrotliDecoderDecompressStream( BrotliDecoderResult res = BrotliDecoderDecompressStream(
state.get(), &avail_in, &next_in, &len, &out, nullptr state.get(), &avail_in, &next_in, &len, &out, nullptr
)) );
{
switch (res) {
case BROTLI_DECODER_RESULT_SUCCESS:
// We're done here!
goto finish;
case BROTLI_DECODER_RESULT_NEEDS_MORE_INPUT:
// Grab more input. Don't try if we already have exhausted our input stream.
if (inputEofException != nullptr) {
std::rethrow_exception(inputEofException);
} else {
continue;
}
case BROTLI_DECODER_RESULT_NEEDS_MORE_OUTPUT:
// Need more output space: we can only get another buffer by someone calling us again, so get out.
goto finish;
case BROTLI_DECODER_RESULT_ERROR:
throw CompressionError("error while decompressing brotli file"); throw CompressionError("error while decompressing brotli file");
} }
} }
finish:
if (begin != out) { if (begin != out) {
return out - begin; return out - begin;
} else { } else {

View file

@ -77,6 +77,11 @@ struct Source
* Store up to len in the buffer pointed to by data, and * Store up to len in the buffer pointed to by data, and
* return the number of bytes stored. It blocks until at least * return the number of bytes stored. It blocks until at least
* one byte is available. * one byte is available.
*
* Should not return 0 (generally you want to throw EndOfFile), but nothing
* stops that.
*
* \throws EndOfFile if there is no more data.
*/ */
virtual size_t read(char * data, size_t len) = 0; virtual size_t read(char * data, size_t len) = 0;

View file

@ -136,4 +136,28 @@ TEST_P(PerTypeNonNullCompressionTest, bogusInputDecompression)
auto bogus = "this data is bogus and should throw when decompressing"; auto bogus = "this data is bogus and should throw when decompressing";
ASSERT_THROW(decompress(param, bogus), CompressionError); ASSERT_THROW(decompress(param, bogus), CompressionError);
} }
TEST_P(PerTypeNonNullCompressionTest, truncatedValidInput)
{
auto method = GetParam();
auto inputString = "the quick brown fox jumps over the lazy doggos";
auto compressed = compress(method, inputString);
/* n.b. This also tests zero-length input, which is also invalid.
* As of the writing of this comment, it returns empty output, but is
* allowed to throw a compression error instead. */
for (int i = 0; i < compressed.length(); ++i) {
auto newCompressed = compressed.substr(compressed.length() - i);
try {
decompress(method, newCompressed);
// Success is acceptable as well, even though it is corrupt data.
// The compression method is not expected to provide integrity,
// just, not break explosively on bad input.
} catch (CompressionError &) {
// Acceptable
}
}
}
} }