From 6dbd3f8f290f84928a976ece48576c6da75bf195 Mon Sep 17 00:00:00 2001 From: Panagiotis Koilakos <45639356+Svestis@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:38:35 +0200 Subject: [PATCH] BE: escape backslashes when serializing string settings to app.conf app.conf is written by util.php saveSettings() as Python source and later compiled/exec'd by the backend. String settings were emitted into single-quoted Python literals with only ' encoded (as {s-quote}), so backslashes were written raw. A regex such as 192\.0\.2\..* then produced "SyntaxWarning: invalid escape sequence '\.'", valid Python escapes such as \n were silently reinterpreted, and a trailing backslash made the file fail to compile. Move the encoder into a side-effect-free helper, app_conf_encode.php, as encode_python_string(). It now doubles backslashes before the existing {s-quote} replacement, so backslashes round-trip unchanged through app.conf serialization and Python parsing, while single quotes keep using the legacy {s-quote} placeholder. Both scalar string and array string serialization use the helper. Add regression tests that run the real PHP helper through the PHP CLI, compile the generated source with warnings promoted to errors, and check the scalar and array round trips. --- front/php/server/app_conf_encode.php | 18 ++++ front/php/server/util.php | 10 +- test/backend/test_app_conf_string_escaping.py | 96 +++++++++++++++++++ 3 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 front/php/server/app_conf_encode.php create mode 100644 test/backend/test_app_conf_string_escaping.py diff --git a/front/php/server/app_conf_encode.php b/front/php/server/app_conf_encode.php new file mode 100644 index 000000000..1690c1ef2 --- /dev/null +++ b/front/php/server/app_conf_encode.php @@ -0,0 +1,18 @@ + 3 && is_array($settingValue) == true) { foreach ($settingValue as $val) { - $temp .= "'" . encode_single_quotes($val) . "',"; + $temp .= "'" . encode_python_string($val) . "',"; } $temp = substr_replace($temp, "", -1); // remove last comma ',' @@ -271,11 +272,6 @@ function getSettingValue($setKey) { return 'Could not find setting '.$setKey; } -// ------------------------------------------------------------------------------------------- -function encode_single_quotes ($val) { - $result = str_replace ('\'','{s-quote}',$val); - return $result; -} // ------------------------------------------------------------------------------------------- // Helper function to send notifications via the backend API endpoint // ------------------------------------------------------------------------------------------- diff --git a/test/backend/test_app_conf_string_escaping.py b/test/backend/test_app_conf_string_escaping.py new file mode 100644 index 000000000..cb5db3812 --- /dev/null +++ b/test/backend/test_app_conf_string_escaping.py @@ -0,0 +1,96 @@ +""" +NetAlertX app.conf String Escaping Tests + +Runs the real PHP encode_python_string() (front/php/server/app_conf_encode.php) +through the PHP CLI, embeds its output in app.conf-style Python source the same +way util.php saveSettings() does, and checks that the source compiles without +warnings and parses back to the typed value (with ' mapped to {s-quote}). + +License: GNU GPLv3 +""" + +import json +import shutil +import subprocess +import warnings +from pathlib import Path + +import pytest + +ENCODER_PHP = Path(__file__).resolve().parents[2] / "front" / "php" / "server" / "app_conf_encode.php" +PHP_BIN = shutil.which("php") or shutil.which("php83") + +pytestmark = pytest.mark.skipif(PHP_BIN is None, reason="PHP CLI (php or php83) not available") + +# Reads {"path": ..., "cases": [...]} from stdin and prints the encoded cases as JSON. +PHP_RUNNER = ( + '$in = json_decode(stream_get_contents(STDIN), true);' + 'require $in["path"];' + 'echo json_encode(array_map("encode_python_string", $in["cases"]));' +) + +CASES = { + "ordinary_text": "hello world", + "doc_regex": r"192\.0\.2\..*", + "consecutive_backslashes": r"a\\b", + "backslashes_only": "\\" * 3, + "trailing_backslash": "trail" + "\\", + "existing_s_quote": "x{s-quote}y", + "literal_single_quote": "it's", + "regex_with_quote": r"\d+\s*'", + "backslash_before_quote": r"a\'b", +} + + +@pytest.fixture(scope="module") +def encoded(): + """Return {case_id: encoded} produced by one PHP CLI run of encode_python_string().""" + payload = json.dumps({"path": str(ENCODER_PHP), "cases": list(CASES.values())}) + result = subprocess.run( + [PHP_BIN, "-r", PHP_RUNNER], + input=payload, + capture_output=True, + text=True, + timeout=60, + check=True, + ) + return dict(zip(CASES.keys(), json.loads(result.stdout))) + + +def parse_app_conf(source): + """Compile source with all warnings as errors and exec it like the backend app.conf readers.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + code = compile(source, "app.conf", "exec") + conf = {} + exec(code, {"__builtins__": {}}, conf) + return conf + + +def test_warning_check_rejects_unescaped_backslash(): + """The warnings-as-errors compile rejects the unescaped regex source that util.php emitted before escaping.""" + with pytest.raises(SyntaxError): + parse_app_conf(r"X='192\.0\.2\..*'") + + +@pytest.mark.parametrize("case_id", CASES.keys()) +def test_scalar_string_round_trip(encoded, case_id): + """A scalar string setting (X='') compiles cleanly and parses back to the typed value.""" + typed = CASES[case_id] + conf = parse_app_conf(f"X='{encoded[case_id]}'\n") + assert conf["X"] == typed.replace("'", "{s-quote}") + + +@pytest.mark.parametrize("case_id", CASES.keys()) +def test_array_string_round_trip(encoded, case_id): + """An array setting element (X=['plain','']) compiles cleanly and parses back to the typed value.""" + typed = CASES[case_id] + conf = parse_app_conf(f"X=['plain','{encoded[case_id]}']\n") + assert conf["X"] == ["plain", typed.replace("'", "{s-quote}")] + + +def test_doc_regex_exact_source(encoded): + """The documentation regex is emitted with doubled backslashes and parses back unchanged.""" + source = f"ICMP_IN_REGEX='{encoded['doc_regex']}'" + assert source == r"ICMP_IN_REGEX='192\\.0\\.2\\..*'" + assert parse_app_conf(source)["ICMP_IN_REGEX"] == r"192\.0\.2\..*"