mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-10-02 10:45:05 -04:00
BE: plugin input improvements
This commit is contained in:
1 parent
1c410bf2be
commit
6cc092f2ad
22 files changed
+1499
-330
No files matched your search
@@ -369,3 +369,27 @@ def test_run_timeout_not_reused_in_loop(plugin_name):
|
||||
'plugin_helper.per_item_timeout() for a runtime-variable-length one. '
|
||||
"See docs/PLUGINS_DEV.md#conventions-checklist:\n" + "\n".join(issues)
|
||||
)
|
||||
|
||||
|
||||
# Column types where the rendered value is never HTML-interpreted and a plugin
|
||||
# legitimately needs to show raw text (e.g. an API response body) - the only
|
||||
# types allowed to opt out of the default sanitize-on-persist pass via
|
||||
# "allow_raw_text": true. See docs/PLUGINS_DEV.md#conventions-checklist.
|
||||
_ALLOW_RAW_TEXT_TYPES = {"textarea_readonly"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('plugin_name', _PLUGIN_NAMES)
|
||||
def test_allow_raw_text_only_on_safe_types(plugin_name):
|
||||
config = _load_config(plugin_name)
|
||||
for col in config.get('database_column_definitions', []):
|
||||
if not col.get('allow_raw_text'):
|
||||
continue
|
||||
col_type = col.get('type')
|
||||
assert col_type in _ALLOW_RAW_TEXT_TYPES, (
|
||||
f"{plugin_name}: column {col.get('column')!r} sets \"allow_raw_text\": true "
|
||||
f"but has type {col_type!r}, not one of {sorted(_ALLOW_RAW_TEXT_TYPES)}. "
|
||||
'allow_raw_text skips HTML/control-char stripping for this field - only safe '
|
||||
'on a type that is never rendered as HTML (e.g. a read-only textarea), and '
|
||||
'still requires the renderer to escape on display - see '
|
||||
'docs/PLUGINS_DEV.md#conventions-checklist.'
|
||||
)
|
||||
@@ -1,11 +1,9 @@
|
||||
"""
|
||||
Regression guard for server/app_state.py's updateState()/broadcast_state_update()
|
||||
call - pluginsStates must reach the SSE broadcast payload, not just the
|
||||
persisted app_state.json. See
|
||||
.gemini/internal-docs/PRDs/to_review/execution-queue-fe-locking-fix.md's
|
||||
post-implementation addendum: the original broadcast_state_update() call
|
||||
never passed pluginsStates, so front/js/ui_components.js's watchPluginState()
|
||||
(fix C1) waited on an SSE event that could never arrive.
|
||||
persisted app_state.json. The original broadcast_state_update() call never
|
||||
passed pluginsStates, so front/js/ui_components.js's watchPluginState() waited
|
||||
on an SSE event that could never arrive.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Tests for the sanitize-by-default pass in plugin_object_class.__init__.
|
||||
|
||||
A plugin's watchedValue*/extra/helpVal*/foreignKey fields are attacker-
|
||||
influenced (parsed from network responses, headers, etc.) but persisted and
|
||||
later rendered. plugin_object_class strips HTML tag-delimiter and control
|
||||
characters from every mapped field by default; a column opts out via
|
||||
config.json's "allow_raw_text": true, restricted to display-only types
|
||||
(textarea_readonly) by
|
||||
test/plugins/test_plugin_conventions.py::test_allow_raw_text_only_on_safe_types.
|
||||
|
||||
Run from inside the NetAlertX container - server/plugin.py isn't importable
|
||||
standalone outside it (real conf/database/api imports).
|
||||
|
||||
pytest "test/server/test_plugin_object_field_sanitization.py" -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path setup
|
||||
# ---------------------------------------------------------------------------
|
||||
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
|
||||
sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"])
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from db_test_helpers import make_plugin_event_row # noqa: E402
|
||||
|
||||
import plugin as plugin_module # noqa: E402
|
||||
from plugin import plugin_object_class # noqa: E402
|
||||
|
||||
PREFIX = "TESTPLG"
|
||||
PAYLOAD = "<img src=x onerror=alert(1)>"
|
||||
STRIPPED = "img src=x onerror=alert(1)"
|
||||
|
||||
|
||||
def _publisher_plugin(allow_raw_text_on_watched2=False):
|
||||
"""Shaped like a real publisher plugin's config.json: watchedValue2
|
||||
holds a raw API-response body, optionally marked allow_raw_text."""
|
||||
return {
|
||||
"unique_prefix": PREFIX,
|
||||
"settings": [],
|
||||
"database_column_definitions": [
|
||||
{"column": "watchedValue1", "type": "text"},
|
||||
{
|
||||
"column": "watchedValue2",
|
||||
"type": "textarea_readonly",
|
||||
"allow_raw_text": allow_raw_text_on_watched2,
|
||||
},
|
||||
{"column": "extra", "type": "text"},
|
||||
{"column": "helpVal1", "type": "text"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestDefaultSanitization:
|
||||
def test_watched_field_without_allow_raw_text_is_sanitized(self):
|
||||
row = make_plugin_event_row(PREFIX, "id1", watched2=PAYLOAD)
|
||||
obj = plugin_object_class(_publisher_plugin(), row)
|
||||
assert obj.watched2 == STRIPPED
|
||||
|
||||
def test_extra_and_helpval_are_sanitized_by_default(self):
|
||||
row = make_plugin_event_row(PREFIX, "id1", extra=PAYLOAD, help_val1=PAYLOAD)
|
||||
obj = plugin_object_class(_publisher_plugin(), row)
|
||||
assert obj.extra == STRIPPED
|
||||
assert obj.helpVal1 == STRIPPED
|
||||
|
||||
def test_clean_text_passes_through_unchanged(self):
|
||||
row = make_plugin_event_row(PREFIX, "id1", watched2="200 OK")
|
||||
obj = plugin_object_class(_publisher_plugin(), row)
|
||||
assert obj.watched2 == "200 OK"
|
||||
|
||||
def test_preexisting_dirty_value_is_sanitized_on_readback(self):
|
||||
"""A row already persisted with an unsanitized value before this
|
||||
mechanism shipped must be cleaned the next time it's read, not just
|
||||
at write time - __init__ runs on every DB read, not only on insert."""
|
||||
row = make_plugin_event_row(PREFIX, "id1", watched2=PAYLOAD)
|
||||
obj = plugin_object_class(_publisher_plugin(), row)
|
||||
assert "<" not in obj.watched2 and ">" not in obj.watched2
|
||||
|
||||
|
||||
class TestAllowRawTextOptOut:
|
||||
def test_column_with_allow_raw_text_true_is_not_sanitized(self):
|
||||
row = make_plugin_event_row(PREFIX, "id1", watched2=PAYLOAD)
|
||||
obj = plugin_object_class(_publisher_plugin(allow_raw_text_on_watched2=True), row)
|
||||
assert obj.watched2 == PAYLOAD
|
||||
|
||||
def test_other_fields_still_sanitized_when_one_column_opts_out(self):
|
||||
row = make_plugin_event_row(PREFIX, "id1", watched2=PAYLOAD, extra=PAYLOAD)
|
||||
obj = plugin_object_class(_publisher_plugin(allow_raw_text_on_watched2=True), row)
|
||||
assert obj.watched2 == PAYLOAD # opted out
|
||||
assert obj.extra == STRIPPED # no opt-out on this column
|
||||
|
||||
|
||||
class TestForeignKeySanitization:
|
||||
"""foreignKey has no database_column_definitions entry of its own (no
|
||||
config flag to attach an opt-out to) - always sanitized, unconditionally."""
|
||||
|
||||
def test_mac_shaped_foreign_key_passes_unchanged(self):
|
||||
row = make_plugin_event_row(PREFIX, "id1", foreign_key="aa:bb:cc:dd:ee:ff")
|
||||
obj = plugin_object_class(_publisher_plugin(), row)
|
||||
assert obj.foreignKey == "aa:bb:cc:dd:ee:ff"
|
||||
|
||||
def test_guid_shaped_foreign_key_passes_unchanged(self):
|
||||
guid = "550e8400-e29b-41d4-a716-446655440000"
|
||||
row = make_plugin_event_row(PREFIX, "id1", foreign_key=guid)
|
||||
obj = plugin_object_class(_publisher_plugin(), row)
|
||||
assert obj.foreignKey == guid
|
||||
|
||||
def test_internet_sentinel_passes_unchanged(self):
|
||||
row = make_plugin_event_row(PREFIX, "id1", foreign_key="internet")
|
||||
obj = plugin_object_class(_publisher_plugin(), row)
|
||||
assert obj.foreignKey == "internet"
|
||||
|
||||
def test_injection_payload_is_stripped_not_blanked(self):
|
||||
row = make_plugin_event_row(PREFIX, "id1", foreign_key=PAYLOAD)
|
||||
obj = plugin_object_class(_publisher_plugin(), row)
|
||||
assert obj.foreignKey == STRIPPED
|
||||
|
||||
|
||||
class TestSanitizationLogging:
|
||||
def test_mylog_fires_when_value_changes(self, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(plugin_module, "mylog", lambda level, msg: calls.append((level, msg)))
|
||||
row = make_plugin_event_row(PREFIX, "id1", watched2=PAYLOAD)
|
||||
plugin_object_class(_publisher_plugin(), row)
|
||||
assert any(level == "none" and "watchedValue2" in msg for level, msg in calls)
|
||||
|
||||
def test_mylog_does_not_fire_for_clean_values(self, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(plugin_module, "mylog", lambda level, msg: calls.append((level, msg)))
|
||||
row = make_plugin_event_row(PREFIX, "id1", watched2="200 OK", foreign_key="aa:bb:cc:dd:ee:ff")
|
||||
plugin_object_class(_publisher_plugin(), row)
|
||||
assert calls == []
|
||||
@@ -1,4 +1,4 @@
|
||||
from server.plugins.plugin_helper import Plugin_Object, is_mac, normalize_mac, per_item_timeout
|
||||
from server.plugins.plugin_helper import Plugin_Object, is_mac, normalize_mac, per_item_timeout, sanitize_plugin_text
|
||||
|
||||
|
||||
def test_is_mac_accepts_wildcard():
|
||||
@@ -64,4 +64,21 @@ def test_watched_columns_unaffected_by_helpval_fix():
|
||||
assert obj.watched1 == 0
|
||||
assert obj.watched2 is False
|
||||
assert obj.watched3 == ""
|
||||
assert obj.watched4 is None
|
||||
assert obj.watched4 is None
|
||||
|
||||
|
||||
def test_sanitize_plugin_text_strips_tag_delimiters():
|
||||
assert sanitize_plugin_text("<img src=x onerror=alert(1)>") == "img src=x onerror=alert(1)"
|
||||
|
||||
|
||||
def test_sanitize_plugin_text_strips_control_chars_but_keeps_tab_and_newline():
|
||||
assert sanitize_plugin_text("a\x00b\x1fc\td\ne\rf") == "abc\td\ne\rf"
|
||||
|
||||
|
||||
def test_sanitize_plugin_text_passes_through_clean_text_unchanged():
|
||||
text = "Living Room TV (Samsung) - " + ("x" * 2000) # no length cap
|
||||
assert sanitize_plugin_text(text) == text
|
||||
|
||||
|
||||
def test_sanitize_plugin_text_passes_none_through():
|
||||
assert sanitize_plugin_text(None) is None
|
||||
Reference in new issue
Block a user