Files
dualfroz eb1d9c9bec fix: memoize css.recursiveCheck to remove exponential-time CSS DoS
Shorthand style property handlers (FontHandler and roughly a dozen
siblings) tokenize a CSS value and feed it to css.recursiveCheck,
which searches for a way to partition the tokens so every group is
accepted by one of a handful of validator functions. The search was
unmemoized: every recursive call is made on a suffix of the token
slice, and the same suffix was re-explored once per partition path
that reached it. For values with several tokens that more than one
validator accepts on their own (e.g. runs of whitespace, which
FontFamilyHandler treats as a valid empty entry), the number of such
paths grows combinatorially with the number of tokens, making
wall-clock cost roughly double per extra token. A short,
attacker-controlled style value can hang a server sanitizing
untrusted HTML.

Memoize recursiveCheck on the length of the remaining token suffix:
distinct suffixes of a given slice always have distinct lengths, and
the outcome for a suffix depends only on its length within one
top-level call (the validator set is constant throughout), so this
caches each of at most len(value) distinct subproblems exactly once.
This does not change any validator's behavior or which values are
accepted or rejected, only how many times each subproblem is solved,
turning the exponential search into a polynomial one.

Closes #225
2026-09-04 05:58:45 +02:00

122 lines
5.1 KiB
Go

// Copyright (c) 2019, David Kitchen <david@buro9.com>
//
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// * Redistributions of source code must retain the above copyright notice, this
// list of conditions and the following disclaimer.
//
// * Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// * Neither the name of the organisation (Microcosm) nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package css
import (
"strings"
"testing"
"time"
)
// TestIssue225 is a regression test for
// https://github.com/microcosm-cc/bluemonday/issues/225.
//
// Shorthand property handlers such as FontHandler tokenize their value
// and feed it to recursiveCheck, which searches for a way to partition
// the tokens so that every group is accepted by one of a handful of
// functions. A style value whose tokenization produces several
// whitespace-only entries (as happens when a declaration is indented
// with newlines and runs of spaces, like the payload in issue #225)
// gives that search many equally valid split points, since a
// whitespace-only entry is accepted as an "empty" font-family by
// FontFamilyHandler. Before this fix, recursiveCheck re-explored the
// same remaining tokens once per partition path that reached them,
// making it exponential in the number of tokens: on the vulnerable
// revision, 20 blank tokens took upwards of 15 seconds to evaluate
// (measured via the full bluemonday.Policy.Sanitize pipeline).
//
// This test guards against that regressing: it must complete well
// within the deadline below on a fixed/patched recursiveCheck, and it
// times out (rather than hanging for the several seconds the
// unmemoized search would take) if the exponential behaviour returns.
//
// Note that the pathological value below is ultimately rejected
// (FontHandler returns false for it, confirmed against both the fixed
// and the pre-fix code): the vulnerability is not about a value being
// wrongly accepted, it is that recursiveCheck could take exponential
// time to reach *either* verdict. Measured directly against FontHandler
// on the vulnerable revision, this exact value took ~4s; the equivalent
// value with 20 spaces of indentation took ~15s end-to-end through
// bluemonday.Policy.Sanitize.
func TestIssue225(t *testing.T) {
// 20 blank tokens, produced the same way the issue #225 payload
// produced them: a run of spaces immediately after a newline.
pathological := "normal\n" + strings.Repeat(" ", 20) + "16px\nroboto"
const deadline = 3 * time.Second
const want = false
result := make(chan bool, 1)
go func() {
result <- FontHandler(pathological)
}()
select {
case got := <-result:
if got != want {
t.Errorf("FontHandler(%q) = %v, want %v", pathological, got, want)
}
case <-time.After(deadline):
t.Fatalf("FontHandler did not return within %v for a 20-blank-token "+
"value; recursiveCheck may have regressed to exponential-time "+
"behaviour (see issue #225)", deadline)
}
}
// TestIssue225AcceptanceUnchanged asserts that the recursiveCheck
// memoization added to fix issue #225 does not change which values are
// accepted or rejected by a shorthand handler; it only changes how fast
// that determination is made. Expected results were captured from
// FontHandler's existing (pre-fix) behaviour.
func TestIssue225AcceptanceUnchanged(t *testing.T) {
tests := []struct {
value string
want bool
}{
// Representative valid "font" shorthand values (the first two
// mirror cases already exercised end-to-end in
// TestStyling in sanitize_test.go).
{"italic bold 12px/30px Georgia, serif", true},
{"icon", true},
{"caption", true},
{"normal 16px Roboto", true},
{"bold 16px/1.5 Arial, sans-serif", true},
// Representative invalid/unsafe values that must stay rejected.
{"expression(alert(1))", false},
{"javascript:alert(1)", false},
{"url(evil.com)", false},
}
for _, tt := range tests {
if got := FontHandler(tt.value); got != tt.want {
t.Errorf("FontHandler(%q) = %v, want %v", tt.value, got, tt.want)
}
}
}