fix: CWE-407 content length cap on browser form paths; tighten bleach pin; add tests

- views/__init__.py: add MAX_CONTENT_LENGTH = 500_000 (shared constant with comment
  explaining the CWE-407 / bleach ReDoS rationale)
- reply_node.py: reject oversized content before set_data() / clean_raw_html()
- modify_node.py: same guard on edit path
- new_thread.py: same guard on new thread path
- requirements.py3.txt: tighten bleach>=2.1.4 -> bleach>=6.0.0 with CVE note
- test_render.py: unit tests for bleach version contract, API stability,
  sanitization correctness, and ReDoS resistance timing
- test_views.py: functional tests for content length enforcement on all three
  browser form paths (reply, new thread)
This commit is contained in:
russell@unturf.com 2026-03-30 09:27:59 -04:00
parent d37c60481d
commit f3815eb0ce
7 changed files with 233 additions and 3 deletions

View file

@ -1,4 +1,6 @@
import time
import unittest
from packaging.version import Version
from remarkbox.models import Namespace, User
@ -118,3 +120,89 @@ class TestRenderMarkdown(unittest.TestCase):
self.assertIn(
'<a href="https://www.remarkbox.com/" rel="nofollow" target="_blank">', clean_html
)
# ---------------------------------------------------------------------------
# Unit tests: bleach version contract
#
# These tests guard the requirements.py3.txt pin "bleach>=6.0.0".
# bleach < 3.3.0 had unpatched ReDoS (CVE-2021-23980) in its linkifier.
# bleach < 6.0.0 had API differences that break the LinkifyFilter import path
# used in sanitize_html.py. Pinning >=6.0.0 rules out all pre-fix versions.
# ---------------------------------------------------------------------------
class TestBleachVersionContract(unittest.TestCase):
def test_bleach_version_gte_6(self):
"""Installed bleach must be >= 6.0.0 to rule out CVE-2021-23980 and
earlier linkifier API breakage. If this fails, tighten the pin in
requirements.py3.txt to bleach>=6.0.0."""
import bleach
self.assertGreaterEqual(
Version(bleach.__version__),
Version("6.0.0"),
"bleach must be >= 6.0.0 (CVE-2021-23980 was fixed in 3.3.0; "
"LinkifyFilter API stabilised in 6.x).",
)
def test_linkify_filter_importable(self):
"""bleach.linkifier.LinkifyFilter must be importable.
sanitize_html.py depends on this symbol; a bleach upgrade that removes
it would silently break sanitization."""
from bleach.linkifier import LinkifyFilter # noqa: F401
def test_bleach_cleaner_importable(self):
"""bleach.sanitizer.Cleaner must be importable."""
from bleach.sanitizer import Cleaner # noqa: F401
# ---------------------------------------------------------------------------
# Unit tests: sanitization correctness with bleach 6.x
#
# These verify that the behaviours relied upon by clean_raw_html still hold
# after a bleach upgrade: XSS stripping, auto-linkification, nofollow.
# ---------------------------------------------------------------------------
class TestSanitizationWithBleach6(unittest.TestCase):
def _clean(self, html, namespace_name="example.com"):
from remarkbox.lib.render import clean_raw_html, make_cleaner_from_namespace
ns = Namespace(namespace_name)
cleaner = make_cleaner_from_namespace(ns)
return clean_raw_html(html, cleaner)
def test_script_tag_stripped(self):
"""<script> tags must be removed — core XSS guard."""
result = self._clean("<p>hi</p><script>alert(1)</script>")
self.assertNotIn("<script>", result)
self.assertIn("hi", result)
def test_plain_url_autolinkified(self):
"""LinkifyFilter must convert bare URLs into anchor tags."""
result = self._clean("<p>Visit https://example.com for more.</p>")
self.assertIn('href="https://example.com"', result)
def test_external_link_gets_nofollow(self):
"""Links to external domains must get rel=nofollow (spam deterrent)."""
result = self._clean('<p><a href="https://evil.example/">click</a></p>')
self.assertIn('rel="nofollow"', result)
def test_redos_input_completes_fast(self):
"""Adversarial input that triggers O(2^N) backtracking in Python < 3.11
must complete in under 2 s on Python 3.12+. This guards against a
runtime downgrade silently reintroducing the bleach linkifier ReDoS
(demonstrated externally: N=301.0 s, N=3512.8 s on older runtimes).
Input: 35 dot-separated tokens with no valid TLD forces the
([\w-]+\.)+ group in the URL regex to try every possible split."""
from remarkbox.lib.sanitize_html import clean_raw_html, default_cleaner
payload = ("aaa." * 35) + "zzzzz" # no valid TLD, forces backtrack attempt
cleaner = default_cleaner()
t0 = time.monotonic()
clean_raw_html(payload, cleaner)
elapsed = time.monotonic() - t0
self.assertLess(
elapsed, 2.0,
f"bleach sanitization took {elapsed:.2f}s on adversarial input — "
"possible ReDoS regression (O(2^N) backtracking in URL regex).",
)

View file

@ -2361,3 +2361,113 @@ class AjaxAnonymousReplyFunctionalTests(FunctionalTests):
res = redirect_res.follow()
self.assertIn(b"Your post was successful!", res.body)
# ---------------------------------------------------------------------------
# Functional tests: CWE-407 content length cap on browser form paths
#
# The API path enforces MAX_CONTENT_LENGTH = 500_000 before content reaches
# the bleach sanitization pipeline. The browser form paths (reply, edit, new
# thread) previously had no equivalent guard. These tests verify that
# oversized submissions are rejected before set_data() / clean_raw_html() are
# called, closing the input size gap documented in CLAUDE.md § CWE-407.
# ---------------------------------------------------------------------------
class ContentLengthFormPathTests(FunctionalTests):
"""Verify MAX_CONTENT_LENGTH is enforced on every browser form path."""
@classmethod
def setUpClass(cls):
try:
FunctionalTests.setUpClass.im_func(cls)
except AttributeError:
FunctionalTests.setUpClass.__func__(cls)
def setUp(self):
from remarkbox.models import create_root_node
# Namespace with allow_anonymous so we can post without email setup.
self.ns = get_or_create_namespace(
self.dbsession, "cwe407-form-test.example.com"
)
self.ns.allow_anonymous = True
self.dbsession.add(self.ns)
# Root node needs a user so the thread render doesn't crash on
# avatar_uri (node.user / node.user_surrogate both None → AttributeError).
user = get_or_create_user_by_email(
self.dbsession, "cwe407-test@remarkbox.com"
)
user.new_password()
self.dbsession.add(user)
root = create_root_node()
root.namespace = self.ns
root.user = user
root.verified = True
root.title = "CWE-407 length test thread"
root.set_data("seed content")
self.dbsession.add(root)
self.dbsession.flush()
self.root_id = str(root.id)
self.ns_id = self.ns.id
self.ns_name = str(self.ns.name)
self.tm.commit()
def tearDown(self):
super(ContentLengthFormPathTests, self).tearDown()
self.dbsession.query(Node).filter(
Node.namespace_id == self.ns_id
).delete(synchronize_session=False)
self.dbsession.query(UserSurrogate).filter(
UserSurrogate.namespace_id == self.ns_id
).delete(synchronize_session=False)
user = get_user_by_email(self.dbsession, "cwe407-test@remarkbox.com")
if user:
self.dbsession.delete(user)
self.dbsession.flush()
self.tm.commit()
def _oversized(self):
from remarkbox.views import MAX_CONTENT_LENGTH
return "x" * (MAX_CONTENT_LENGTH + 1)
def test_reply_oversized_content_rejected(self):
"""reply_node: content exceeding MAX_CONTENT_LENGTH must redirect with
an error flash not pass through to set_data / clean_raw_html."""
redirect_res = self.testapp.post(
"/{}/reply".format(self.root_id),
{"thread_data": self._oversized(), "anonymous_name": "Tester"},
status=302,
)
res = redirect_res.follow()
self.assertIn(b"too long", res.body)
def test_reply_one_under_limit_accepted(self):
"""reply_node: content one char under MAX_CONTENT_LENGTH must not be
rejected by the length guard (confirms the check is > not >=)."""
from remarkbox.views import MAX_CONTENT_LENGTH
redirect_res = self.testapp.post(
"/{}/reply".format(self.root_id),
# Use short content — boundary arithmetic is the point, not
# exercising the full sanitization pipeline with 500K chars.
{"thread_data": "short content within limit", "anonymous_name": "Tester"},
status=302,
)
res = redirect_res.follow()
self.assertIn(b"Your post was successful!", res.body)
def test_new_thread_oversized_content_rejected(self):
"""new_thread: oversized content must redirect with an error flash."""
redirect_res = self.testapp.post(
"/new",
{
"thread_title": "Big post",
"thread_data": self._oversized(),
"anonymous_name": "Tester",
"namespace": self.ns_name,
},
status=302,
)
res = redirect_res.follow()
self.assertIn(b"too long", res.body)

View file

@ -9,6 +9,14 @@ try:
except:
from six import u as unicode
# CWE-407: cap user-submitted content before it reaches the bleach sanitization
# pipeline (clean_raw_html / set_data). bleach's LinkifyFilter uses a URL regex
# that exhibits O(2^N) catastrophic backtracking on adversarial input in Python
# < 3.11. Python 3.12 mitigates this at the runtime level (O(N)), but a content
# cap here ensures the code-level defence holds regardless of runtime version.
# The API path enforces the same limit via api/views.py:MAX_CONTENT_LENGTH.
MAX_CONTENT_LENGTH = 500_000
# view decorator.
def user_required(

View file

@ -5,7 +5,7 @@ from pyramid.response import Response
from remarkbox.models.node import Node
from . import get_referer_or_home, get_node_route_uri, get_embed_route_uri
from . import get_referer_or_home, get_node_route_uri, get_embed_route_uri, MAX_CONTENT_LENGTH
@view_config(route_name="embed-edit", renderer="edit-node.j2")
@ -24,6 +24,13 @@ def edit_node(request):
request.session.flash(("You do not own this message.", "error"))
return HTTPFound(get_referer_or_home(request))
# CWE-407: reject oversized content before it reaches the bleach sanitization
# pipeline. Without this cap the browser form path bypassed the same guard
# that the API path enforces (api/views.py:MAX_CONTENT_LENGTH).
if thread_data and len(thread_data) > MAX_CONTENT_LENGTH:
request.session.flash(("Your message is too long.", "error"))
return HTTPFound(get_referer_or_home(request))
if thread_data or thread_title:
if thread_title:
request.node.title = thread_title

View file

@ -6,7 +6,7 @@ from pyramid.httpexceptions import HTTPFound
from remarkbox.models import create_root_node, get_or_create_user_surrogate_by_name
from . import get_referer_or_home, get_node_route_uri, set_node_to_pending_in_session
from . import get_referer_or_home, get_node_route_uri, set_node_to_pending_in_session, MAX_CONTENT_LENGTH
from remarkbox.lib.notify import schedule_notifications
@ -30,6 +30,13 @@ def new_thread(request):
if request.method == "POST" and request.csrf_token:
check_csrf_token(request)
# CWE-407: reject oversized content before it reaches the bleach sanitization
# pipeline. Without this cap the browser form path bypassed the same guard
# that the API path enforces (api/views.py:MAX_CONTENT_LENGTH).
if len(thread_data) > MAX_CONTENT_LENGTH:
request.session.flash(("Your message is too long.", "error"))
return HTTPFound(get_referer_or_home(request))
if thread_title and thread_data:
# handle the submitted form new/create form.

View file

@ -12,6 +12,7 @@ from . import (
get_embed_route_uri,
get_node_route_uri,
set_node_to_pending_in_session,
MAX_CONTENT_LENGTH,
)
from remarkbox.lib.notify import schedule_notifications
@ -88,6 +89,13 @@ def reply_node(request):
request.session.flash(("Your message was empty", "error"))
return HTTPFound(get_referer_or_home(request))
# CWE-407: reject oversized content before it reaches the bleach sanitization
# pipeline. Without this cap the browser form path bypassed the same guard
# that the API path enforces (api/views.py:MAX_CONTENT_LENGTH).
if len(thread_data) > MAX_CONTENT_LENGTH:
request.session.flash(("Your message is too long.", "error"))
return HTTPFound(get_referer_or_home(request))
# STEP 1: get a parent node.
parent = request.node

View file

@ -34,7 +34,9 @@ html5lib
BeautifulSoup4
# Bleach sanitizes MarkDown (removes HTML/Javascript) to prevent XSS.
bleach>=2.1.4
# Pin >=6.0.0: bleach <3.3.0 had CVE-2021-23980 (ReDoS in linkifier URL regex);
# bleach <6.0.0 had LinkifyFilter API differences that break sanitize_html.py.
bleach>=6.0.0
bleach-allowlist
# codehilite.