From 147a7b08ba0aec172bae400dac4ce98eb3ceb32a Mon Sep 17 00:00:00 2001 From: Arthur Chan Date: Fri, 21 Aug 2026 12:59:14 +0100 Subject: [PATCH] OSS-Fuzz: Add new fuzzer targets render process Signed-off-by: Arthur Chan --- fuzz/CMakeLists.txt | 2 + fuzz/fuzz-render.C | 299 +++++++++++++++++++++++++++++++ fuzz/fuzz-render_seed_corpus.zip | Bin 0 -> 9108 bytes 3 files changed, 301 insertions(+) create mode 100644 fuzz/fuzz-render.C create mode 100644 fuzz/fuzz-render_seed_corpus.zip diff --git a/fuzz/CMakeLists.txt b/fuzz/CMakeLists.txt index df9b6b58d..dde949bf3 100644 --- a/fuzz/CMakeLists.txt +++ b/fuzz/CMakeLists.txt @@ -6,6 +6,7 @@ ADD_EXECUTABLE(fuzz-datetime fuzz-datetime.C) ADD_EXECUTABLE(fuzz-eval fuzz-eval.C) ADD_EXECUTABLE(fuzz-http fuzz-http.C) ADD_EXECUTABLE(fuzz-json fuzz-json.C) +ADD_EXECUTABLE(fuzz-render fuzz-render.C) ADD_EXECUTABLE(fuzz-uri fuzz-uri.C) ADD_EXECUTABLE(fuzz-xml fuzz-xml.C) @@ -15,6 +16,7 @@ TARGET_LINK_LIBRARIES(fuzz-datetime PRIVATE wt $ENV{LIB_FUZZING_ENGINE}) TARGET_LINK_LIBRARIES(fuzz-eval PRIVATE wt $ENV{LIB_FUZZING_ENGINE}) TARGET_LINK_LIBRARIES(fuzz-http PRIVATE wt wthttp $ENV{LIB_FUZZING_ENGINE}) TARGET_LINK_LIBRARIES(fuzz-json PRIVATE wt $ENV{LIB_FUZZING_ENGINE}) +TARGET_LINK_LIBRARIES(fuzz-render PRIVATE wt $ENV{LIB_FUZZING_ENGINE}) TARGET_LINK_LIBRARIES(fuzz-uri PRIVATE wt $ENV{LIB_FUZZING_ENGINE}) TARGET_LINK_LIBRARIES(fuzz-xml PRIVATE wt $ENV{LIB_FUZZING_ENGINE}) diff --git a/fuzz/fuzz-render.C b/fuzz/fuzz-render.C new file mode 100644 index 000000000..5d9e9870d --- /dev/null +++ b/fuzz/fuzz-render.C @@ -0,0 +1,299 @@ +/* + * Copyright (C) 2026 Emweb bv, Herent, Belgium. + * + * See the LICENSE file for terms of use. + */ + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define kMinInputLength 4 +#define kMaxInputLength 4096 + +namespace { + +// A paint device that measures but does not paint. +// +// Wt::Render needs font metrics to lay text out, and the devices that provide +// them are not usable here: WSvgImage::measureText() goes through +// WApplication::instance()->serverSideFontMetrics(), and WPdfImage needs +// libharu. This device returns synthetic metrics instead, which keeps layout +// deterministic and free of any dependency on fonts, a session or a server. +// +// The metrics are self-consistent: measureText() and fontMetrics() derive every +// width and height from the same per-character advance, so the line breaking +// loop in Render::Block always makes progress. They are also clamped away from +// zero, since WFontMetrics::size() is ascent + descent and Block treats a zero +// font height as "nothing was laid out". +class FuzzPaintDevice final : public Wt::WPaintDevice +{ +public: + FuzzPaintDevice(double width, double height) + : width_(width), + height_(height) + { } + + Wt::WFlags features() const override + { + return Wt::PaintDeviceFeatureFlag::FontMetrics + | Wt::PaintDeviceFeatureFlag::WordWrap; + } + + Wt::WLength width() const override { return Wt::WLength(width_); } + Wt::WLength height() const override { return Wt::WLength(height_); } + + void setChanged(WT_MAYBE_UNUSED Wt::WFlags flags) override { } + + void drawArc(WT_MAYBE_UNUSED const Wt::WRectF& rect, + WT_MAYBE_UNUSED double startAngle, + WT_MAYBE_UNUSED double spanAngle) override { } + + // Overridden because WPaintDevice's default implementation throws, which + // would end layout on the first element. + void drawImage(WT_MAYBE_UNUSED const Wt::WRectF& rect, + WT_MAYBE_UNUSED const std::string& imageUri, + WT_MAYBE_UNUSED int imgWidth, + WT_MAYBE_UNUSED int imgHeight, + WT_MAYBE_UNUSED const Wt::WRectF& sourceRect) override { } + + void drawImage(WT_MAYBE_UNUSED const Wt::WRectF& rect, + WT_MAYBE_UNUSED const Wt::WAbstractDataInfo* imageInfo, + WT_MAYBE_UNUSED int imgWidth, + WT_MAYBE_UNUSED int imgHeight, + WT_MAYBE_UNUSED const Wt::WRectF& sourceRect) override { } + + void drawLine(WT_MAYBE_UNUSED double x1, WT_MAYBE_UNUSED double y1, + WT_MAYBE_UNUSED double x2, WT_MAYBE_UNUSED double y2) override { } + + void drawPath(WT_MAYBE_UNUSED const Wt::WPainterPath& path) override { } + + void drawRect(WT_MAYBE_UNUSED const Wt::WRectF& rectangle) override { } + + void drawText(WT_MAYBE_UNUSED const Wt::WRectF& rect, + WT_MAYBE_UNUSED Wt::WFlags alignmentFlags, + WT_MAYBE_UNUSED Wt::TextFlag textFlag, + WT_MAYBE_UNUSED const Wt::WTextF& text, + WT_MAYBE_UNUSED const Wt::WPointF* clipPoint) override { } + + Wt::WTextItem measureText(const Wt::WString& text, double maxWidth = -1, + bool wordWrap = false) override + { + const std::string s = text.toUTF8(); + const double cw = charWidth(); + + if (maxWidth < 0 || textWidth(s, s.length(), cw) <= maxWidth) { + return Wt::WTextItem(text, textWidth(s, s.length(), cw)); + } + + // Longest prefix that fits, never splitting a multi-byte character. + std::size_t fit = 0; + for (std::size_t i = 1; i <= s.length(); ++i) { + if (i < s.length() && isContinuationByte(s[i])) { + continue; + } + if (textWidth(s, i, cw) > maxWidth) { + break; + } + fit = i; + } + + // With word wrapping, retreat to the last whitespace inside that prefix. + // Wt includes the whitespace in the returned text but not in the width. + std::size_t brk = fit; + if (wordWrap) { + brk = 0; + for (std::size_t i = 0; i < fit; ++i) { + if (isWhitespace(s[i])) { + brk = i + 1; + } + } + } + + if (brk == 0) { + // Nothing fits. Block starts a new line, and asks for the width of a + // single word when there is not even room for one. + return Wt::WTextItem(Wt::WString(), 0); + } + + std::size_t widthEnd = brk; + while (widthEnd > 0 && isWhitespace(s[widthEnd - 1])) { + --widthEnd; + } + + return Wt::WTextItem(Wt::WString::fromUTF8(s.substr(0, brk)), + textWidth(s, widthEnd, cw)); + } + + Wt::WFontMetrics fontMetrics() override + { + const double size = fontSize(); + return Wt::WFontMetrics(painter_ ? painter_->font() : Wt::WFont(), + 0.0, 0.8 * size, 0.2 * size); + } + + void init() override { } + void done() override { } + bool paintActive() const override { return painter_ != nullptr; } + +protected: + Wt::WPainter *painter() const override { return painter_; } + void setPainter(Wt::WPainter *painter) override { painter_ = painter; } + +private: + static bool isContinuationByte(char c) + { + return (static_cast(c) & 0xC0) == 0x80; + } + + static bool isWhitespace(char c) + { + return c == ' ' || c == '\t' || c == '\n' || c == '\r'; + } + + // The font size drives every metric. It is taken from the painter so that + // CSS font sizes still affect layout, but clamped so that the size is never + // zero, negative, NaN or large enough to make layout unbounded. + double fontSize() const + { + double size = 16.0; + if (painter_) { + size = painter_->font().sizeLength(16).toPixels(); + } + if (!(size >= 1.0)) { + size = 1.0; + } + if (size > 512.0) { + size = 512.0; + } + return size; + } + + double charWidth() const { return 0.5 * fontSize(); } + + // Width of the first nbytes of s, counting UTF-8 characters rather than + // bytes so that the result matches what Block sees. + static double textWidth(const std::string& s, std::size_t nbytes, double cw) + { + std::size_t chars = 0; + for (std::size_t i = 0; i < nbytes && i < s.length(); ++i) { + if (!isContinuationByte(s[i])) { + ++chars; + } + } + return cw * static_cast(chars); + } + + double width_, height_; + Wt::WPainter *painter_ = nullptr; +}; + +// Renders onto FuzzPaintDevice. Pages are reused: the same device and painter +// serve every page, so a document that spans many pages costs no allocations. +class FuzzTextRenderer final : public Wt::Render::WTextRenderer +{ +public: + FuzzTextRenderer(double pageWidth, double pageHeight, double margin) + : device_(pageWidth, pageHeight), + pageWidth_(pageWidth), + pageHeight_(pageHeight), + margin_(margin) + { } + + double pageWidth(WT_MAYBE_UNUSED int page) const override { return pageWidth_; } + double pageHeight(WT_MAYBE_UNUSED int page) const override { return pageHeight_; } + double margin(WT_MAYBE_UNUSED Wt::Side side) const override { return margin_; } + + Wt::WPaintDevice *startPage(WT_MAYBE_UNUSED int page) override + { + return &device_; + } + + void endPage(WT_MAYBE_UNUSED Wt::WPaintDevice *device) override + { + painter_.end(); + } + + Wt::WPainter *getPainter(Wt::WPaintDevice *device) override + { + painter_.begin(device); + return &painter_; + } + +private: + FuzzPaintDevice device_; + Wt::WPainter painter_; + double pageWidth_, pageHeight_, margin_; +}; + +} + +// Fuzzes the Wt::Render XHTML/CSS layout engine (src/Wt/Render/Block.C and +// friends) through WTextRenderer::render(), the entry point WPdfRenderer uses +// to turn rich text into a paint device. +// +// One call covers: rapidxml in parse_xhtml_entity_translation mode, the CSS +// cascade (CssParser, CssData, Specificity), Block::determineDisplay(), +// normalizeWhitespace(), two layout passes over blocks, tables, floats and +// inline text, page breaking, and finally the paint pass over every page. +// +// Input layout: +// byte 0 selector +// bytes 1..2 style sheet length, little endian +// remainder that many bytes of style sheet, then the XHTML +extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) { + if (Size < kMinInputLength || Size > kMaxInputLength) { + return 0; + } + + const uint8_t selector = Data[0]; + const size_t bodyLen = Size - 3; + const char *body = reinterpret_cast(Data + 3); + + size_t styleLen = static_cast(Data[1]) | (static_cast(Data[2]) << 8); + if (styleLen > bodyLen) { + styleLen = bodyLen; + } + + const std::string styleSheet(body, styleLen); + const std::string xhtml(body + styleLen, bodyLen - styleLen); + + // A page wide enough that ordinary text does not break on every character, + // and short enough that page breaking is exercised. + const double pageWidth = (selector & 0x04) ? 240 : 800; + const double pageHeight = (selector & 0x08) ? 200 : 1000; + + FuzzTextRenderer renderer(pageWidth, pageHeight, 10); + + if (selector & 0x01) { + // Ignores the return value on purpose: a style sheet that fails to parse + // leaves the previous one in place, which is also worth rendering with. + renderer.setStyleSheetText(Wt::WString::fromUTF8(styleSheet)); + } + + if (selector & 0x02) { + renderer.setFontScale(1.5); + } + + try { + renderer.render(Wt::WString::fromUTF8(xhtml)); + } catch (...) { + // render() propagates rapidxml::parse_error, and Block propagates + // WException for unresolvable sources and malformed CSS lengths. + } + + return 0; +} diff --git a/fuzz/fuzz-render_seed_corpus.zip b/fuzz/fuzz-render_seed_corpus.zip new file mode 100644 index 0000000000000000000000000000000000000000..5e813002d310ac164e770b6cd2406ed7d0d2435e GIT binary patch literal 9108 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 F{{Y5dP~!jq literal 0 HcmV?d00001