From ad269d8157724e4feb7928aca96ed05d76457f44 Mon Sep 17 00:00:00 2001 From: Marcus Holland-Moritz Date: Fri, 17 Jul 2026 14:09:50 +0200 Subject: [PATCH] feat: add header and compressed string validation functions The interface for `fsst_import` relies on being passed a pointer to a well-formed, valid header that was generated by `fsst_export`. Passing it a corrupted header can easily lead to out-of-bounds accesses. The same is true for `fsst_decompress`, albeit to a lesser extent. The function already takes care of not writing past the end of `output`, but it can read past the end of `strIn` if the last byte of the compressed string is (erroneously) an escape byte. In practice, this will only be the case if the compressed data is corrupted. I'm using FSST in a file system implementation and I'm currently in the process of hardening the implementation against out-of-bounds accesses wherever possible. For "trusted" file system images, internal checksums take care of corruption / bit-rot. But for "untrusted", potentially malicious, images with forged checksums, I'd like to make sure to catch all errors that could lead to OOB accesses during a full file system check. The `fsst_validate_header()` call is cheap enough to be always-on. In order to not impact API and/or performance, the validation checks are implemented as separate functions that can be called on demand. --- fsst.h | 10 ++++++++++ libfsst.cpp | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/fsst.h b/fsst.h index 71085d5..d9836c3 100644 --- a/fsst.h +++ b/fsst.h @@ -112,6 +112,16 @@ fsst_export( void fsst_destroy(fsst_encoder_t*); +/* Validate a serialized symbol table (as produced by fsst_export) before importing it. + Returns the number of header bytes fsst_import would consume (== its return value) if the + import is guaranteed memory-safe, or 0 if buf[0..bufLen) is malformed. Use this to screen + untrusted (possibly corrupted) input before calling fsst_import. */ +size_t fsst_validate_header(unsigned char const *buf, size_t bufLen); + +/* Validate a compressed string before decompressing it. Returns 1 if fsst_decompress cannot + over-read strIn[0..lenIn), 0 otherwise. */ +int fsst_validate_compressed(unsigned char const *strIn, size_t lenIn); + /* Return a decoder structure from serialized format (typically used in a block-, file- or row-group header). */ unsigned int /* OUT: number of bytes consumed in buf (0 on failure). */ fsst_import( diff --git a/libfsst.cpp b/libfsst.cpp index e3ba787..4b0c1ce 100644 --- a/libfsst.cpp +++ b/libfsst.cpp @@ -649,3 +649,55 @@ extern "C" fsst_decoder_t fsst_decoder(fsst_encoder_t *encoder) { assert(cnt1 == cnt2); (void) cnt1; (void) cnt2; return decoder; } + +// Validate a serialized FSST symbol table ("header") before handing it to fsst_import(). +// This is a pure memory-safety predicate: it does not check whether the table decodes to meaningful +// data, only that fsst_import() cannot overrun the decoder arrays or read past the buffer. +extern "C" size_t fsst_validate_header(const unsigned char *buf, size_t bufLen) { + if (bufLen < 17) return 0; // version(8) + zeroTerminated(1) + lenHisto(8) + + u64 version = 0; // same logic as fsst_import() + memcpy(&version, buf, 8); + version = swap64_if_be(version); + if ((version >> 32) != FSST_VERSION) return 0; + + unsigned zeroTerminated = buf[8] & 1; + u8 lenHisto[8]; + memcpy(lenHisto, buf + 9, 8); + + unsigned code = zeroTerminated; + if (zeroTerminated) { + if (lenHisto[0] == 0) return 0; // fsst_import() would underflows lenHisto[0] + lenHisto[0]--; + } + + size_t pos = 17; + for (unsigned l = 1; l <= 8; l++) { + const unsigned symLen = (l & 7) + 1; // 2,3,4,5,6,7,8,1 (same logic as fsst_import()) + for (unsigned i = 0; i < lenHisto[l & 7]; i++) { + if (code >= 255) return 0; // fsst_import() would overflow the code table + pos += symLen; + code++; + } + } + + if (pos > bufLen) return 0; // fsst_import() would read past the buffer + + return pos; +} + +// Validate a single FSST-compressed string before handing it to fsst_decompress(). +// The only way fsst_decompress() can read past the input buffer is if the last input +// byte is a dangling escape. +extern "C" int fsst_validate_compressed(const unsigned char *strIn, size_t lenIn) { + size_t posIn = 0; + while (posIn < lenIn) { + if (strIn[posIn] == FSST_ESC) { + if (lenIn - posIn < 2) return 0; // last byte -> dangling escape + posIn += 2; // skip over the escaped byte + } else { + posIn += 1; + } + } + return 1; +}