BE: address app.conf escaping review feedback

Keep encode_python_string() in front/php/server/util.php, replacing
encode_single_quotes() in place, and remove the standalone
app_conf_encode.php helper file and its require.

Rework the regression test to dispatch the real util.php savesettings
path through the PHP CLI against temporary synthetic config, API and
session directories, covering both scalar string and array string
settings: the generated app.conf must compile with warnings as errors
and parse back to the typed values.

The existing {s-quote} lifecycle is preserved (single quotes are still
written as {s-quote} and converted back by the same consumers), and the
app.conf readers in initialise.py and plugin_helper.py are unchanged.
This commit is contained in:
Panagiotis Koilakos committed 2026-09-29 23:11:07 +02:00
1 parent 6dbd3f8f29
commit 1c3139bb7b
3 files changed
+88 -48

No files matched your search

-18
View File
@@ -1,18 +0,0 @@
<?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);
}
+9 -1
View File
@@ -10,7 +10,6 @@
require dirname(__FILE__).'/../templates/globals.php';
require dirname(__FILE__).'/../templates/skinUI.php';
require_once dirname(__FILE__).'/app_conf_encode.php';
//------------------------------------------------------------------------------
@@ -272,6 +271,15 @@ function getSettingValue($setKey) {
return 'Could not find setting '.$setKey;
}
// -------------------------------------------------------------------------------------------
/**
* 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);
}
// -------------------------------------------------------------------------------------------
// Helper function to send notifications via the backend API endpoint
// -------------------------------------------------------------------------------------------
+79 -29
View File
@@ -1,15 +1,16 @@
"""
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}).
Dispatches the real front/php/server/util.php savesettings path through the PHP
CLI against a temporary config directory, then checks that the generated
app.conf compiles without warnings and parses back to the typed values (with '
mapped to {s-quote}) for both scalar string and array string settings.
License: GNU GPLv3
"""
import json
import os
import shutil
import subprocess
import warnings
@@ -17,18 +18,26 @@ from pathlib import Path
import pytest
ENCODER_PHP = Path(__file__).resolve().parents[2] / "front" / "php" / "server" / "app_conf_encode.php"
FRONT_DIR = Path(__file__).resolve().parents[2] / "front"
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.
# Stands in for the web request: reads {"front": ..., "settings": [...]} from stdin,
# satisfies security.php's request-only dependencies and lets util.php dispatch savesettings.
PHP_RUNNER = (
'$in = json_decode(stream_get_contents(STDIN), true);'
'require $in["path"];'
'echo json_encode(array_map("encode_python_string", $in["cases"]));'
'if (!function_exists("apache_request_headers")) { function apache_request_headers() { return []; } }'
'$_SERVER["DOCUMENT_ROOT"] = $in["front"];'
'$_SERVER["HTTP_HOST"] = "localhost";'
'$_SERVER["REQUEST_URI"] = "/php/server/util.php";'
'$_REQUEST = ["function" => "savesettings", "settings" => json_encode($in["settings"])];'
'require $in["front"] . "/php/server/util.php";'
)
# Minimal app.conf that lets globals.php and security.php load without a password prompt.
SEED_APP_CONF = "TIMEZONE='UTC'\nSETPWD_enable_password=False\n"
CASES = {
"ordinary_text": "hello world",
"doc_regex": r"192\.0\.2\..*",
@@ -42,19 +51,52 @@ CASES = {
}
def scalar_key(case_id):
"""Return the app.conf key used for the scalar string setting of a case."""
return f"S_{case_id.upper()}"
def array_key(case_id):
"""Return the app.conf key used for the array string setting of a case."""
return f"A_{case_id.upper()}"
@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())})
def app_conf(tmp_path_factory):
"""Run util.php saveSettings() once for all cases and return the generated app.conf source."""
root = tmp_path_factory.mktemp("app_conf")
config_dir = root / "config"
api_dir = root / "api"
session_dir = root / "session"
for folder in (config_dir, api_dir, session_dir):
folder.mkdir()
(config_dir / "app.conf").write_text(SEED_APP_CONF)
# UI_WAIT_FOR_SETTINGS=True keeps getReloadWaitRequired() from reading the (absent) API files.
settings = [["General", "UI_WAIT_FOR_SETTINGS", "boolean", True]]
for case_id, typed in CASES.items():
settings.append(["Test", scalar_key(case_id), "string", typed])
settings.append(["Test", array_key(case_id), "array", ["plain", typed]])
env = dict(os.environ, NETALERTX_CONFIG=str(config_dir), NETALERTX_API=str(api_dir))
result = subprocess.run(
[PHP_BIN, "-r", PHP_RUNNER],
input=payload,
[PHP_BIN, "-d", f"session.save_path={session_dir}", "-d", "display_errors=stderr", "-r", PHP_RUNNER],
input=json.dumps({"front": str(FRONT_DIR), "settings": settings}),
capture_output=True,
text=True,
env=env,
timeout=60,
check=True,
)
return dict(zip(CASES.keys(), json.loads(result.stdout)))
assert json.loads(result.stdout)["success"] is True, result.stdout + result.stderr
return (config_dir / "app.conf").read_text()
def setting_line(source, key):
"""Return the single app.conf line that assigns key."""
lines = [line for line in source.splitlines() if line.startswith(f"{key}=")]
assert len(lines) == 1, f"expected one {key}= line, got {lines}"
return lines[0]
def parse_app_conf(source):
@@ -73,24 +115,32 @@ def test_warning_check_rejects_unescaped_backslash():
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}")
def test_generated_app_conf_compiles(app_conf):
"""The whole app.conf written by saveSettings() compiles without warnings."""
parse_app_conf(app_conf)
@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_scalar_string_round_trip(app_conf, case_id):
"""A scalar string setting written by saveSettings() compiles cleanly and parses back to the typed value."""
key = scalar_key(case_id)
conf = parse_app_conf(setting_line(app_conf, key))
assert conf[key] == CASES[case_id].replace("'", "{s-quote}")
def test_doc_regex_exact_source(encoded):
@pytest.mark.parametrize("case_id", CASES.keys())
def test_array_string_round_trip(app_conf, case_id):
"""An array string setting written by saveSettings() compiles cleanly and parses back to the typed values."""
key = array_key(case_id)
conf = parse_app_conf(setting_line(app_conf, key))
assert conf[key] == ["plain", CASES[case_id].replace("'", "{s-quote}")]
def test_doc_regex_exact_source(app_conf):
"""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\..*"
scalar = setting_line(app_conf, scalar_key("doc_regex"))
array = setting_line(app_conf, array_key("doc_regex"))
assert scalar == r"S_DOC_REGEX='192\\.0\\.2\\..*'"
assert array == r"A_DOC_REGEX=['plain','192\\.0\\.2\\..*']"
assert parse_app_conf(scalar)["S_DOC_REGEX"] == r"192\.0\.2\..*"
assert parse_app_conf(array)["A_DOC_REGEX"] == ["plain", r"192\.0\.2\..*"]