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.
This commit is contained in:
Panagiotis Koilakos committed 2026-09-28 19:54:45 +02:00
1 parent 0d60071d49
commit 6dbd3f8f29
3 files changed
+117 -7

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
<?php
//------------------------------------------------------------------------------
// NetAlertX
// Open Source Network Guard / WIFI & LAN intrusion detector
//
// app_conf_encode.php - Side-effect-free helpers for serializing app.conf values
//------------------------------------------------------------------------------
# Puche 2021 / 2022+ jokob support@netalertx.com GNU GPLv3
//------------------------------------------------------------------------------
/**
* Encode a string for use inside a single-quoted Python literal in app.conf.
* Doubles backslashes so they round-trip unchanged, and replaces ' with the
* legacy {s-quote} placeholder that the backend converts back per use.
*/
function encode_python_string($val) {
return str_replace(['\\', '\''], ['\\\\', '{s-quote}'], $val);
}
+3 -7
View File
@@ -10,6 +10,7 @@
require dirname(__FILE__).'/../templates/globals.php';
require dirname(__FILE__).'/../templates/skinUI.php';
require_once dirname(__FILE__).'/app_conf_encode.php';
//------------------------------------------------------------------------------
@@ -116,7 +117,7 @@ function saveSettings()
if ($group == $settingGroup) {
if ($dataType == 'string' ) {
$val = encode_single_quotes($settingValue);
$val = encode_python_string($settingValue);
$txt .= $setKey . "='" . $val . "'\n";
} elseif ($dataType == 'integer') {
$txt .= $setKey . "=" . $settingValue . "\n";
@@ -137,7 +138,7 @@ function saveSettings()
// skipping __metadata entries (?)
if (count($setting) > 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
// -------------------------------------------------------------------------------------------
@@ -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='<encoded>') 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','<encoded>']) 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\..*"