]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Fix] css: detect text hidden via overflow clipping, opacity and max-* sizes
authorVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 11 Jun 2026 22:02:56 +0000 (23:02 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 11 Jun 2026 22:05:51 +0000 (23:05 +0100)
Phishing messages dilute the visible content with hidden ham text using
CSS hiding techniques that the parser did not understand:

- 'max-width:0; max-height:0; overflow:hidden' was fully ignored as
  max-width/max-height/overflow were not parsed at all
- 'opacity:0' was parsed but the value was silently discarded in
  compile_to_block
- 'height:0' was applied to the block width due to a copy-paste bug,
  and zero dimensions were never considered by compute_visibility

Fixes:

- parse max-width/max-height (clamping width/height) and overflow
- treat a block with zero height or width and overflow:hidden as
  invisible, propagating it to descendants via the display value
- treat opacity < 0.1 as a hidden display, as descendants cannot reset
  the ancestor opacity
- do not allow a child display value to resurrect content of a hidden
  ancestor in propagate_block (display:none is not resettable in CSS)
- fix the height->width copy-paste bug and a missing break that made
  the font-size case fall through into the opacity case

src/libserver/css/css_property.cxx
src/libserver/css/css_property.hxx
src/libserver/css/css_rule.cxx
src/libserver/html/html_block.hxx

index 15571094280745d4aabda65cb85ca96e7a774b8e..b1446599f442b6b6a9844d149c9887824d1007ca 100644 (file)
@@ -31,9 +31,12 @@ constexpr const auto prop_names_map = frozen::make_unordered_map<frozen::string,
        {"background", css_property_type::PROPERTY_BACKGROUND},
        {"height", css_property_type::PROPERTY_HEIGHT},
        {"width", css_property_type::PROPERTY_WIDTH},
+       {"max-height", css_property_type::PROPERTY_MAX_HEIGHT},
+       {"max-width", css_property_type::PROPERTY_MAX_WIDTH},
        {"display", css_property_type::PROPERTY_DISPLAY},
        {"visibility", css_property_type::PROPERTY_VISIBILITY},
        {"opacity", css_property_type::PROPERTY_OPACITY},
+       {"overflow", css_property_type::PROPERTY_OVERFLOW},
 });
 
 /* Ensure that we have all cases listed */
index 9661222de68ef6a31cf5438da8202942e50b127c..7d5e37a396d217096b35d14be0535f16e236eda6 100644 (file)
@@ -38,9 +38,12 @@ enum class css_property_type : std::uint16_t {
        PROPERTY_BACKGROUND,
        PROPERTY_HEIGHT,
        PROPERTY_WIDTH,
+       PROPERTY_MAX_HEIGHT,
+       PROPERTY_MAX_WIDTH,
        PROPERTY_DISPLAY,
        PROPERTY_VISIBILITY,
        PROPERTY_OPACITY,
+       PROPERTY_OVERFLOW,
        PROPERTY_NYI,
 };
 
@@ -90,6 +93,12 @@ struct alignas(int) css_property {
                case css_property_type::PROPERTY_WIDTH:
                        ret = "width";
                        break;
+               case css_property_type::PROPERTY_MAX_HEIGHT:
+                       ret = "max-height";
+                       break;
+               case css_property_type::PROPERTY_MAX_WIDTH:
+                       ret = "max-width";
+                       break;
                case css_property_type::PROPERTY_DISPLAY:
                        ret = "display";
                        break;
@@ -99,6 +108,9 @@ struct alignas(int) css_property {
                case css_property_type::PROPERTY_OPACITY:
                        ret = "opacity";
                        break;
+               case css_property_type::PROPERTY_OVERFLOW:
+                       ret = "overflow";
+                       break;
                default:
                        break;
                }
@@ -119,6 +131,8 @@ struct alignas(int) css_property {
        {
                return type == css_property_type::PROPERTY_HEIGHT ||
                           type == css_property_type::PROPERTY_WIDTH ||
+                          type == css_property_type::PROPERTY_MAX_HEIGHT ||
+                          type == css_property_type::PROPERTY_MAX_WIDTH ||
                           type == css_property_type::PROPERTY_FONT_SIZE ||
                           type == css_property_type::PROPERTY_FONT;
        }
@@ -138,6 +152,11 @@ struct alignas(int) css_property {
                return type == css_property_type::PROPERTY_VISIBILITY;
        }
 
+       auto is_overflow(void) const -> bool
+       {
+               return type == css_property_type::PROPERTY_OVERFLOW;
+       }
+
        auto operator==(const css_property &other) const
        {
                return type == other.type;
index 53001e577450c7eaa286c0b78666ede080ad37a9..c1315462f7555fbfc042c75f39d016edc70bc21f 100644 (file)
@@ -202,6 +202,24 @@ allowed_property_value(const css_property &prop, const css_consumed_block &parse
                        }
                }
        }
+       if (prop.is_overflow()) {
+               if (parser_block.is_token()) {
+                       /* A single token */
+                       const auto &tok = parser_block.get_token_or_empty();
+
+                       if (tok.type == css_parser_token::token_type::ident_token) {
+                               auto sv = tok.get_string_or_default("");
+
+                               /* Distinguish only the clipping values, as they hide
+                                * the content of a zero sized block entirely */
+                               if (sv == "hidden" || sv == "clip") {
+                                       return css_value{css_display_value::DISPLAY_HIDDEN};
+                               }
+
+                               return css_value{css_display_value::DISPLAY_INLINE};
+                       }
+               }
+       }
 
        return std::nullopt;
 }
@@ -385,7 +403,8 @@ auto css_declarations_block::merge_block(const css_declarations_block &other, me
 auto css_declarations_block::compile_to_block(rspamd_mempool_t *pool) const -> rspamd::html::html_block *
 {
        auto *block = rspamd_mempool_alloc0_type(pool, rspamd::html::html_block);
-       auto opacity = -1;
+       auto opacity = -1.0f;
+       std::optional<css_dimension> height, width, max_height, max_width;
        const css_rule *font_rule = nullptr, *background_rule = nullptr;
 
        for (const auto &rule: rules) {
@@ -408,6 +427,7 @@ auto css_declarations_block::compile_to_block(rspamd_mempool_t *pool) const -> r
                        if (fs) {
                                block->set_font_size(fs.value().dim, fs.value().is_percent);
                        }
+                       break;
                }
                case css_property_type::PROPERTY_OPACITY: {
                        opacity = vals.back().to_number().value_or(opacity);
@@ -429,16 +449,25 @@ auto css_declarations_block::compile_to_block(rspamd_mempool_t *pool) const -> r
                        break;
                }
                case css_property_type::PROPERTY_HEIGHT: {
-                       auto w = vals.back().to_dimension();
-                       if (w) {
-                               block->set_width(w.value().dim, w.value().is_percent);
-                       }
+                       height = vals.back().to_dimension();
                        break;
                }
                case css_property_type::PROPERTY_WIDTH: {
-                       auto h = vals.back().to_dimension();
-                       if (h) {
-                               block->set_width(h.value().dim, h.value().is_percent);
+                       width = vals.back().to_dimension();
+                       break;
+               }
+               case css_property_type::PROPERTY_MAX_HEIGHT: {
+                       max_height = vals.back().to_dimension();
+                       break;
+               }
+               case css_property_type::PROPERTY_MAX_WIDTH: {
+                       max_width = vals.back().to_dimension();
+                       break;
+               }
+               case css_property_type::PROPERTY_OVERFLOW: {
+                       auto disp = vals.back().to_display();
+                       if (disp) {
+                               block->set_overflow(disp.value() == css_display_value::DISPLAY_HIDDEN);
                        }
                        break;
                }
@@ -455,6 +484,41 @@ auto css_declarations_block::compile_to_block(rspamd_mempool_t *pool) const -> r
                }
        }
 
+       /*
+        * max-height/max-width clamp the corresponding dimension; mixed
+        * percent/absolute values cannot be compared, so prefer the zero
+        * limit in that case as it is the only value that matters for
+        * the visibility detection
+        */
+       auto clamp_dim = [](std::optional<css_dimension> dim,
+                                               std::optional<css_dimension> max_dim) -> std::optional<css_dimension> {
+               if (dim && max_dim) {
+                       if (dim->is_percent == max_dim->is_percent) {
+                               return max_dim->dim < dim->dim ? max_dim : dim;
+                       }
+
+                       return max_dim->dim == 0 ? max_dim : dim;
+               }
+
+               return dim ? dim : max_dim;
+       };
+
+       if (auto h = clamp_dim(height, max_height); h) {
+               block->set_height(h->dim, h->is_percent);
+       }
+       if (auto w = clamp_dim(width, max_width); w) {
+               block->set_width(w->dim, w->is_percent);
+       }
+
+       if (opacity >= 0 && opacity < 0.1) {
+               /*
+                * A (nearly) zero opacity makes the block invisible, and, unlike
+                * display, it cannot be reset by descendants; reuse the display
+                * value as it has the desired inheritance semantics
+                */
+               block->set_display(css_display_value::DISPLAY_HIDDEN);
+       }
+
        /* Optional properties */
        if (!(block->fg_color_mask) && font_rule) {
                auto &vals = font_rule->get_values();
@@ -527,6 +591,96 @@ TEST_SUITE("css")
                }
        }
 
+       TEST_CASE("hidden blocks are compiled as invisible")
+       {
+               /* Real-world hiding techniques: text is removed from the rendered
+                * view to dilute the visible content (e.g. of a phishing message) */
+               const std::vector<const char *> hidden_styles{
+                       "display:block; max-width:0; max-height:0; overflow:hidden",
+                       "opacity:0; height:0; line-height:0; overflow:hidden",
+                       "overflow:hidden; max-height:0",
+                       "height:0px; overflow:clip",
+                       "width:0; overflow:hidden",
+                       "opacity:0.01",
+                       "max-height:0; height:50px; overflow:hidden",
+               };
+               const std::vector<const char *> visible_styles{
+                       "max-width:600px; width:100%",
+                       /* Without overflow:hidden the content of a zero sized block
+                        * is rendered outside of the block */
+                       "height:0",
+                       "max-height:0",
+                       "opacity:0.5",
+                       "overflow:hidden; max-height:10px",
+                       "overflow:hidden",
+                       "display:block",
+               };
+
+               auto *pool = rspamd_mempool_new(rspamd_mempool_suggest_size(),
+                                                                               "css", 0);
+
+               for (const auto *css_text: hidden_styles) {
+                       auto res = process_declaration_tokens(pool,
+                                                                                                 get_rules_parser_functor(pool, css_text));
+                       CHECK(res.get() != nullptr);
+                       auto *block = res->compile_to_block(pool);
+                       CHECK(block != nullptr);
+                       block->compute_visibility();
+                       CHECK_MESSAGE(!block->is_visible(), css_text);
+               }
+
+               for (const auto *css_text: visible_styles) {
+                       auto res = process_declaration_tokens(pool,
+                                                                                                 get_rules_parser_functor(pool, css_text));
+                       CHECK(res.get() != nullptr);
+                       auto *block = res->compile_to_block(pool);
+                       CHECK(block != nullptr);
+                       block->compute_visibility();
+                       CHECK_MESSAGE(block->is_visible(), css_text);
+               }
+
+               rspamd_mempool_delete(pool);
+       }
+
+       TEST_CASE("height and width are applied to the proper fields")
+       {
+               auto *pool = rspamd_mempool_new(rspamd_mempool_suggest_size(),
+                                                                               "css", 0);
+
+               auto res = process_declaration_tokens(pool,
+                                                                                         get_rules_parser_functor(pool, "height:10px;width:20px"));
+               CHECK(res.get() != nullptr);
+               auto *block = res->compile_to_block(pool);
+               CHECK(block != nullptr);
+               CHECK(static_cast<int>(block->height_mask) != 0);
+               CHECK(static_cast<int>(block->width_mask) != 0);
+               CHECK(block->height == 10);
+               CHECK(block->width == 20);
+
+               rspamd_mempool_delete(pool);
+       }
+
+       TEST_CASE("hidden parent cannot be reset by child display")
+       {
+               auto *pool = rspamd_mempool_new(rspamd_mempool_suggest_size(),
+                                                                               "css", 0);
+
+               auto parent_res = process_declaration_tokens(pool,
+                                                                                                        get_rules_parser_functor(pool, "display:none"));
+               auto *parent = parent_res->compile_to_block(pool);
+               parent->compute_visibility();
+               CHECK(!parent->is_visible());
+
+               auto child_res = process_declaration_tokens(pool,
+                                                                                                       get_rules_parser_functor(pool, "display:block"));
+               auto *child = child_res->compile_to_block(pool);
+               child->propagate_block(*parent);
+               child->compute_visibility();
+               CHECK(!child->is_visible());
+
+               rspamd_mempool_delete(pool);
+       }
+
        TEST_CASE("duplicate color properties - last wins")
        {
                /* Test case: duplicate color declarations should use the last value
index f9b51847224003cd79d938e3bd8d00e03df13ade..bcae5dfab0e64ccc72332b4c04a993d8c9e533da 100644 (file)
@@ -32,6 +32,7 @@ struct html_block {
        std::int16_t width;
        rspamd::css::css_display_value display;
        std::int8_t font_size;
+       bool overflow_hidden;
 
        unsigned fg_color_mask : 2;
        unsigned bg_color_mask : 2;
@@ -40,6 +41,7 @@ struct html_block {
        unsigned font_mask : 2;
        unsigned display_mask : 2;
        unsigned visibility_mask : 2;
+       unsigned overflow_mask : 2;
 
        constexpr static const auto unset = 0;
        constexpr static const auto inherited = 1;
@@ -107,6 +109,12 @@ struct html_block {
                display_mask = how;
        }
 
+       auto set_overflow(bool hidden, int how = html_block::set) -> void
+       {
+               overflow_hidden = hidden;
+               overflow_mask = how;
+       }
+
        auto set_font_size(float fs, bool is_percent = false, int how = html_block::set) -> void
        {
                fs = is_percent ? (-fs) : fs;
@@ -192,8 +200,20 @@ public:
                                                                                                fg_color, other.fg_color);
                bg_color_mask = html_block::simple_prop(bg_color_mask, other.bg_color_mask,
                                                                                                bg_color, other.bg_color);
-               display_mask = html_block::simple_prop(display_mask, other.display_mask,
-                                                                                          display, other.display);
+
+               if (other.display_mask && other.display == css::css_display_value::DISPLAY_HIDDEN) {
+                       /*
+                        * A hidden ancestor hides all descendants: a child cannot reset
+                        * display of a display:none parent, so ignore the own display
+                        * value in this case
+                        */
+                       display = css::css_display_value::DISPLAY_HIDDEN;
+                       display_mask = html_block::inherited;
+               }
+               else {
+                       display_mask = html_block::simple_prop(display_mask, other.display_mask,
+                                                                                                  display, other.display);
+               }
 
                height_mask = html_block::size_prop(height_mask, other.height_mask,
                                                                                        height, other.height, static_cast<std::int16_t>(800));
@@ -224,6 +244,7 @@ public:
                height_mask = set_value(height_mask, other.height_mask, height, other.height);
                width_mask = set_value(width_mask, other.width_mask, width, other.width);
                font_mask = set_value(font_mask, other.font_mask, font_size, other.font_size);
+               overflow_mask = set_value(overflow_mask, other.overflow_mask, overflow_hidden, other.overflow_hidden);
        }
 
        auto compute_visibility(void) -> void
@@ -244,6 +265,21 @@ public:
                        }
                }
 
+               if (overflow_mask && overflow_hidden) {
+                       /* Zero height or width with overflow:hidden clips the content entirely */
+                       if ((height_mask && height == 0) || (width_mask && width == 0)) {
+                               /*
+                                * Convert to hidden display as well: clipping cannot be resurrected
+                                * by descendants, so it should be propagated to children as
+                                * the display value
+                                */
+                               set_display(css::css_display_value::DISPLAY_HIDDEN);
+                               visibility_mask = html_block::invisible_flag;
+
+                               return;
+                       }
+               }
+
                auto is_similar_colors = [](const rspamd::css::css_color &fg, const rspamd::css::css_color &bg) -> bool {
                        constexpr const auto min_visible_diff = 0.1f;
                        auto diff_r = ((float) fg.r - bg.r);
@@ -332,13 +368,15 @@ public:
                                                  .width = 0,
                                                  .display = rspamd::css::css_display_value::DISPLAY_INLINE,
                                                  .font_size = 12,
+                                                 .overflow_hidden = false,
                                                  .fg_color_mask = html_block::inherited,
                                                  .bg_color_mask = html_block::inherited,
                                                  .height_mask = html_block::unset,
                                                  .width_mask = html_block::unset,
                                                  .font_mask = html_block::unset,
                                                  .display_mask = html_block::inherited,
-                                                 .visibility_mask = html_block::unset};
+                                                 .visibility_mask = html_block::unset,
+                                                 .overflow_mask = html_block::unset};
        }
        /**
         * Produces html block with no defined values allocated from the pool