mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-13 14:48:06 +00:00
validators.py had tests for dedup_unique_arrays only; the other eight functions were untested. Three bugs surfaced. validate_image_url checked for '..' only inside its relative-path branch, so http://host/../secret passed validation while /../secret was rejected — the traversal check now runs before the branch split, which is where a safety check on the whole URL belongs. validate_file_upload lowercased the uploaded filename's extension but compared it against the caller's list verbatim, so allowed_extensions of ['.TTF'] rejected every valid .ttf file. Both sides are lowercased now. The one in-tree caller passes lowercase already, so this only widens what future callers can hand it. validate_numeric_range accepted True and False, because bool subclasses int; a boolean then compared as 1 or 0 against the range and validated cleanly. Excluded explicitly, matching how base_plugin.py already handles the same trap for display_duration. 84 tests. Two behaviours are pinned rather than changed: sanitize_plugin_config deliberately does not HTML-escape strings, since escaping at this layer would store the escaped form in config.json — the docstring said "prevent injection", which read as a promise it does not keep, and now says what it actually does. validate_font_awesome_class's second 'fa-' check is unreachable behind its own regex; harmless, so characterized rather than removed. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
285 lines
12 KiB
Python
285 lines
12 KiB
Python
"""
|
|
Tests for src/web_interface/validators.py.
|
|
|
|
dedup_unique_arrays is already covered by test_dedup_unique_arrays.py and
|
|
is not repeated here; this file covers the other eight functions, none of
|
|
which had any tests.
|
|
|
|
Regression coverage for three fixed bugs:
|
|
- validate_numeric_range accepted True/False, since bool subclasses int.
|
|
- validate_file_upload lowercased the filename's extension but not the
|
|
caller's allowed_extensions list, so ['.TTF'] rejected 'font.ttf'.
|
|
- validate_image_url only checked for '..' inside the relative-path
|
|
branch, so http://host/../secret passed validation untouched.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from src.web_interface.validators import (
|
|
escape_html,
|
|
sanitize_plugin_config,
|
|
validate_file_upload,
|
|
validate_font_awesome_class,
|
|
validate_image_url,
|
|
validate_mime_type,
|
|
validate_numeric_range,
|
|
validate_string_length,
|
|
)
|
|
|
|
|
|
class TestEscapeHtml:
|
|
def test_escapes_all_five_entities(self):
|
|
assert escape_html("""<a href="x">O'Neill & co</a>""") == (
|
|
"<a href="x">O'Neill & co</a>")
|
|
|
|
def test_ampersand_is_escaped_first_so_nothing_double_escapes(self):
|
|
# If '<' were replaced before '&', the '&' of '<' would be
|
|
# escaped again into '&lt;'.
|
|
assert escape_html("<") == "<"
|
|
assert escape_html("&") == "&"
|
|
assert escape_html("&<") == "&<"
|
|
|
|
def test_plain_text_unchanged(self):
|
|
assert escape_html("hello world") == "hello world"
|
|
|
|
def test_non_string_is_coerced(self):
|
|
assert escape_html(42) == "42"
|
|
assert escape_html(None) == "None"
|
|
|
|
def test_script_tag_neutralized(self):
|
|
assert "<script>" not in escape_html("<script>alert(1)</script>")
|
|
|
|
|
|
class TestValidateImageUrl:
|
|
@pytest.mark.parametrize("url", [
|
|
"javascript:alert(1)",
|
|
"JavaScript:alert(1)",
|
|
"JAVASCRIPT:alert(1)",
|
|
"data:text/html;base64,PHNjcmlwdD4=",
|
|
"vbscript:msgbox(1)",
|
|
"file:///etc/passwd",
|
|
])
|
|
def test_dangerous_protocols_rejected(self, url):
|
|
valid, error = validate_image_url(url)
|
|
assert valid is False and "protocol" in error.lower()
|
|
|
|
@pytest.mark.parametrize("url", [
|
|
"http://x/a.png?onerror=alert(1)",
|
|
"http://x/a.png#onload=alert(1)",
|
|
"http://x/onclick=alert(1).png",
|
|
])
|
|
def test_event_handlers_rejected(self, url):
|
|
valid, error = validate_image_url(url)
|
|
assert valid is False and "Event handlers" in error
|
|
|
|
@pytest.mark.parametrize("url", ["", None, 123, []])
|
|
def test_empty_or_non_string_rejected(self, url):
|
|
assert validate_image_url(url)[0] is False
|
|
|
|
def test_http_and_https_allowed(self):
|
|
assert validate_image_url("http://example.com/logo.png") == (True, None)
|
|
assert validate_image_url("https://example.com/logo.png") == (True, None)
|
|
|
|
def test_other_schemes_rejected(self):
|
|
valid, error = validate_image_url("ftp://example.com/logo.png")
|
|
assert valid is False and "http://" in error
|
|
|
|
def test_relative_path_allowed(self):
|
|
assert validate_image_url("/static/logo.png") == (True, None)
|
|
|
|
def test_protocol_relative_url_rejected(self):
|
|
assert validate_image_url("//evil.com/logo.png")[0] is False
|
|
|
|
def test_relative_traversal_rejected(self):
|
|
assert validate_image_url("/static/../../etc/passwd")[0] is False
|
|
|
|
def test_absolute_url_traversal_rejected(self):
|
|
# Regression: the '..' check used to sit inside the leading-slash
|
|
# branch, so an absolute URL skipped it entirely.
|
|
valid, error = validate_image_url("http://example.com/../secret")
|
|
assert valid is False and "traversal" in error.lower()
|
|
|
|
def test_bare_traversal_rejected(self):
|
|
assert validate_image_url("../../etc/passwd")[0] is False
|
|
|
|
|
|
class TestValidateFontAwesomeClass:
|
|
@pytest.mark.parametrize("cls", ["fa-star", "fas fa-star", "fa-solid fa-house"])
|
|
def test_valid_classes_accepted(self, cls):
|
|
assert validate_font_awesome_class(cls) == (True, None)
|
|
|
|
@pytest.mark.parametrize("cls", ["star", "glyphicon-star", ""])
|
|
def test_classes_without_fa_prefix_rejected(self, cls):
|
|
assert validate_font_awesome_class(cls)[0] is False
|
|
|
|
def test_injection_attempt_rejected(self):
|
|
assert validate_font_awesome_class('fa-star" onload="alert(1)')[0] is False
|
|
|
|
def test_angle_brackets_rejected(self):
|
|
assert validate_font_awesome_class("<script>fa-star</script>")[0] is False
|
|
|
|
def test_non_string_rejected(self):
|
|
valid, error = validate_font_awesome_class(None)
|
|
assert valid is False and "string" in error
|
|
|
|
def test_explicit_fa_check_is_unreachable_but_harmless(self):
|
|
# Characterized, not fixed: the regex already requires 'fa-', so the
|
|
# follow-up `if 'fa-' not in class_name` can never fire. Anything
|
|
# lacking 'fa-' is rejected by the pattern first, with the pattern's
|
|
# own message.
|
|
valid, error = validate_font_awesome_class("star")
|
|
assert valid is False
|
|
assert error == "Invalid Font Awesome class name format"
|
|
|
|
|
|
class TestValidateFileUpload:
|
|
def test_plain_filename_accepted(self):
|
|
assert validate_file_upload("logo.png") == (True, None)
|
|
|
|
@pytest.mark.parametrize("filename", [
|
|
"../etc/passwd", "dir/file.png", "dir\\file.png", "..\\..\\secrets",
|
|
])
|
|
def test_traversal_characters_rejected(self, filename):
|
|
valid, error = validate_file_upload(filename)
|
|
assert valid is False and "invalid characters" in error
|
|
|
|
@pytest.mark.parametrize("filename", ["", None, 123])
|
|
def test_empty_or_non_string_rejected(self, filename):
|
|
assert validate_file_upload(filename)[0] is False
|
|
|
|
def test_allowed_extension_accepted(self):
|
|
assert validate_file_upload("font.ttf", allowed_extensions=[".ttf", ".otf"]) == (True, None)
|
|
|
|
def test_disallowed_extension_rejected(self):
|
|
valid, error = validate_file_upload("evil.exe", allowed_extensions=[".ttf"])
|
|
assert valid is False and "extension" in error
|
|
|
|
def test_uppercase_filename_extension_matches(self):
|
|
assert validate_file_upload("FONT.TTF", allowed_extensions=[".ttf"]) == (True, None)
|
|
|
|
def test_uppercase_allowed_list_matches(self):
|
|
# Regression: only the filename side was lowercased, so a caller
|
|
# passing ['.TTF'] rejected every valid .ttf upload.
|
|
assert validate_file_upload("font.ttf", allowed_extensions=[".TTF"]) == (True, None)
|
|
|
|
def test_no_extension_list_skips_the_check(self):
|
|
assert validate_file_upload("anything.xyz") == (True, None)
|
|
|
|
|
|
class TestValidateMimeType:
|
|
def test_known_type_accepted(self):
|
|
assert validate_mime_type("logo.png", ["image/png"]) == (True, None)
|
|
|
|
def test_mismatched_type_rejected(self):
|
|
valid, error = validate_mime_type("logo.png", ["image/jpeg"])
|
|
assert valid is False and "not allowed" in error
|
|
|
|
def test_undeterminable_type_rejected(self):
|
|
valid, error = validate_mime_type("mystery.zzz", ["image/png"])
|
|
assert valid is False and "Could not determine" in error
|
|
|
|
def test_guess_type_failure_is_caught(self, monkeypatch):
|
|
import mimetypes
|
|
monkeypatch.setattr(mimetypes, "guess_type",
|
|
lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("boom")))
|
|
valid, error = validate_mime_type("logo.png", ["image/png"])
|
|
assert valid is False and "Error validating MIME type" in error
|
|
|
|
|
|
class TestValidateNumericRange:
|
|
def test_value_in_range(self):
|
|
assert validate_numeric_range(5, min_val=0, max_val=10) == (True, None)
|
|
|
|
def test_boundaries_are_inclusive(self):
|
|
assert validate_numeric_range(0, min_val=0, max_val=10) == (True, None)
|
|
assert validate_numeric_range(10, min_val=0, max_val=10) == (True, None)
|
|
|
|
def test_below_minimum_rejected(self):
|
|
valid, error = validate_numeric_range(-1, min_val=0)
|
|
assert valid is False and "at least" in error
|
|
|
|
def test_above_maximum_rejected(self):
|
|
valid, error = validate_numeric_range(11, max_val=10)
|
|
assert valid is False and "at most" in error
|
|
|
|
def test_floats_accepted(self):
|
|
assert validate_numeric_range(2.5, min_val=0, max_val=10) == (True, None)
|
|
|
|
def test_no_bounds_accepts_any_number(self):
|
|
assert validate_numeric_range(-9999) == (True, None)
|
|
|
|
@pytest.mark.parametrize("value", ["5", None, [], {}])
|
|
def test_non_numeric_rejected(self, value):
|
|
valid, error = validate_numeric_range(value, min_val=0, max_val=10)
|
|
assert valid is False and error == "Value must be a number"
|
|
|
|
@pytest.mark.parametrize("value", [True, False])
|
|
def test_booleans_rejected(self, value):
|
|
# Regression: bool subclasses int, so True passed the isinstance
|
|
# check and then compared as 1 against the range.
|
|
valid, error = validate_numeric_range(value, min_val=0, max_val=10)
|
|
assert valid is False and error == "Value must be a number"
|
|
|
|
|
|
class TestValidateStringLength:
|
|
def test_within_range(self):
|
|
assert validate_string_length("hello", min_length=1, max_length=10) == (True, None)
|
|
|
|
def test_boundaries_are_inclusive(self):
|
|
assert validate_string_length("abc", min_length=3, max_length=3) == (True, None)
|
|
|
|
def test_too_short_rejected(self):
|
|
valid, error = validate_string_length("", min_length=1)
|
|
assert valid is False and "at least" in error
|
|
|
|
def test_too_long_rejected(self):
|
|
valid, error = validate_string_length("abcdef", max_length=3)
|
|
assert valid is False and "at most" in error
|
|
|
|
def test_non_string_rejected(self):
|
|
valid, error = validate_string_length(123, max_length=10)
|
|
assert valid is False and "must be a string" in error
|
|
|
|
def test_no_bounds_accepts_anything(self):
|
|
assert validate_string_length("") == (True, None)
|
|
|
|
|
|
class TestSanitizePluginConfig:
|
|
def test_valid_keys_and_scalars_kept(self):
|
|
config = {"enabled": True, "count": 3, "ratio": 1.5, "name": "clock"}
|
|
assert sanitize_plugin_config(config) == config
|
|
|
|
@pytest.mark.parametrize("key", ["has space", "has-dash", "has.dot", "has/slash", ""])
|
|
def test_invalid_key_names_dropped(self, key):
|
|
assert sanitize_plugin_config({key: "value", "good": 1}) == {"good": 1}
|
|
|
|
def test_non_string_keys_dropped(self):
|
|
assert sanitize_plugin_config({1: "a", "good": 2}) == {"good": 2}
|
|
|
|
def test_nested_dicts_recursed(self):
|
|
result = sanitize_plugin_config({"outer": {"inner": 1, "bad key": 2}})
|
|
assert result == {"outer": {"inner": 1}}
|
|
|
|
def test_list_of_scalars_preserved(self):
|
|
assert sanitize_plugin_config({"teams": ["PHI", "NYG"]})["teams"] == ["PHI", "NYG"]
|
|
|
|
def test_list_of_dicts_recursed(self):
|
|
result = sanitize_plugin_config({"items": [{"ok": 1, "bad key": 2}]})
|
|
assert result["items"] == [{"ok": 1}]
|
|
|
|
def test_unknown_value_types_dropped(self):
|
|
assert sanitize_plugin_config({"weird": {1, 2, 3}, "good": 1}) == {"good": 1}
|
|
|
|
def test_none_values_dropped(self):
|
|
assert sanitize_plugin_config({"nothing": None, "good": 1}) == {"good": 1}
|
|
|
|
def test_strings_are_not_html_escaped(self):
|
|
# Pinned, not a bug: escaping here would persist the escaped form in
|
|
# config.json. Output escaping belongs to the template layer, which
|
|
# the function's docstring now says explicitly.
|
|
payload = "<script>alert(1)</script>"
|
|
assert sanitize_plugin_config({"title": payload})["title"] == payload
|
|
|
|
def test_empty_config(self):
|
|
assert sanitize_plugin_config({}) == {}
|