Files
haproxy-openmanager/backend/tests/test_acl_pattern_file_allow.py
taylanbakircioglu c79391cd13 feat(acl): accept HAProxy -f pattern-file references with advisory warnings (v1.8.9, Issue #38)
The manual Frontend editor, wizard and visual ACL builder hard-rejected the ACL
`-f <file>` flag while bulk import accepted it. Worse, a frontend imported with
an `-f` ACL could not be edited at all (422) until the ACL was dropped.

The original guard predated the fail-safe apply flow: the agent runs `haproxy -c`
before every reload, so a missing pattern file is rejected safely and the previous
config keeps running. Pattern files are operator-managed host files — the same
policy adopted for SPOE filter configs in v1.8.8.

- models: remove the 5 `-f` hard rejects (frontend acl/redirect/use_backend
  validators + wizard string/dict-redirect guards); `$(`/backtick and X!X
  contradiction guards unchanged
- routers/frontend: `_pattern_file_warnings` helper; non-blocking warning on
  create + update responses listing referenced pattern files (empty when no
  rule uses `-f` — zero noise)
- routers/config: bulk-import preview advisory listing pattern files per
  frontend (cluster config-dir aware, next to the SPOE advisories)
- React: remove the FrontendManagement submit gate and SiteWizard step gate;
  ACLRuleBuilder renders informational notes instead of errors and re-adds
  `-f (pattern file on host)` to the flag dropdown; create path now renders
  server warnings like update
- tests: 4 reject-pins inverted to accept-pins; new test_acl_pattern_file_allow.py
  (accept/guards-kept/zero-noise/advisory); full suite green (1094 passed)
2026-07-14 00:27:11 +03:00

191 lines
8.7 KiB
Python

"""Issue #38 follow-up — ACL `-f <file>` pattern-file support (v1.8.9).
The Bulgu #12 hard rejects were removed: pattern files are
operator-managed host files (same policy as the SPOE
`filter ... config <path>` reference preserved since v1.8.8), bulk
import always accepted `-f`, and the agent runs `haproxy -c` before
every reload so a missing file fails safely. These tests pin:
1. ACCEPT — the manual FrontendConfig model and the wizard models
accept `-f` in every rule field (string + dict shapes).
2. GUARDS KEPT — `$(`/backtick shell-substitution rejects and the
`X !X` contradiction machinery are unchanged.
3. WARNINGS — `_pattern_file_warnings` emits exactly one advisory
listing the referenced files, and NOTHING for `-f`-free rules
(zero-noise: existing users see no new output).
4. ADVISORY — the bulk-import preview advisory block scans
acl/use_backend rules (and only those fields).
"""
import re
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from models.frontend import FrontendConfig # noqa: E402
from routers.frontend import _pattern_file_warnings # noqa: E402
ACL_F = "blacklisted src -f /etc/haproxy/blacklist.lst"
UB_F = "be-secure if { src -f /etc/haproxy/allowlist.lst }"
REDIR_F = "location /blocked if { src -f /etc/haproxy/blacklist.lst }"
# ──────────────────────────────────────────────────────────────────────
# 1. ACCEPT — manual FrontendConfig model
# ──────────────────────────────────────────────────────────────────────
def test_frontend_config_accepts_acl_file_flag():
fe = FrontendConfig(name="fe1", bind_port=80, mode="http", acl_rules=[ACL_F])
assert fe.acl_rules == [ACL_F]
def test_frontend_config_accepts_use_backend_file_flag():
fe = FrontendConfig(
name="fe1", bind_port=80, mode="http", use_backend_rules=[UB_F])
assert fe.use_backend_rules == [UB_F]
def test_frontend_config_accepts_redirect_string_file_flag():
fe = FrontendConfig(
name="fe1", bind_port=80, mode="http", redirect_rules=[REDIR_F])
assert fe.redirect_rules == [REDIR_F]
def test_frontend_config_accepts_redirect_dict_file_flag():
rule = {"type": "scheme", "scheme": "https",
"condition": "if { src -f /etc/haproxy/blacklist.lst }"}
fe = FrontendConfig(
name="fe1", bind_port=80, mode="http", redirect_rules=[rule])
assert fe.redirect_rules == [rule]
# ──────────────────────────────────────────────────────────────────────
# 2. GUARDS KEPT — dangerous-content rejects unchanged
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("bad_rule", [
"acl1 path $(rm -rf /)",
"acl1 path `id`",
])
def test_acl_shell_substitution_still_rejected(bad_rule):
from pydantic import ValidationError
with pytest.raises(ValidationError):
FrontendConfig(name="fe1", bind_port=80, mode="http", acl_rules=[bad_rule])
@pytest.mark.parametrize("bad_rule", [
"be1 if $(whoami)",
"be1 if `id`",
])
def test_use_backend_shell_substitution_still_rejected(bad_rule):
from pydantic import ValidationError
with pytest.raises(ValidationError):
FrontendConfig(
name="fe1", bind_port=80, mode="http", use_backend_rules=[bad_rule])
def test_contradiction_detection_still_works_on_file_flag_rules():
"""Interaction guard: a `-f` rule with an `X !X` contradiction is
still caught by the handler-level contradiction machinery — the
`-f` relaxation must not weaken that gate."""
from models.frontend import _frontend_has_acl_contradiction
assert _frontend_has_acl_contradiction(
"be1 if blacklisted !blacklisted") is True
# And a normal -f rule is NOT a contradiction.
assert _frontend_has_acl_contradiction(UB_F) is False
# ──────────────────────────────────────────────────────────────────────
# 3. WARNINGS — _pattern_file_warnings (zero-noise contract)
# ──────────────────────────────────────────────────────────────────────
def test_pattern_file_warnings_lists_unique_paths():
warnings = _pattern_file_warnings(
acl_rules=[ACL_F, "other src -f /etc/haproxy/blacklist.lst"],
use_backend_rules=[UB_F],
redirect_rules=[{"condition": "if { src -f /etc/haproxy/geo.lst }"}],
)
assert len(warnings) == 1
w = warnings[0]
assert "/etc/haproxy/blacklist.lst" in w
assert "/etc/haproxy/allowlist.lst" in w
assert "/etc/haproxy/geo.lst" in w
# Duplicate path listed once.
assert w.count("/etc/haproxy/blacklist.lst") == 1
# Non-blocking framing: mentions fail-safe haproxy -c.
assert "haproxy -c" in w
def test_pattern_file_warnings_empty_without_file_flag():
"""Zero-noise: operators who don't use `-f` must see NO warning."""
assert _pattern_file_warnings(
acl_rules=["is_api path_beg /api", "is_admin src 10.0.0.0/24"],
use_backend_rules=["be-api if is_api"],
redirect_rules=[{"type": "scheme", "scheme": "https",
"condition": "if !{ ssl_fc }"}],
) == []
assert _pattern_file_warnings() == []
def test_pattern_file_warnings_ignores_dash_f_substrings():
"""`-file`/`-foo` substrings must not trigger the advisory."""
assert _pattern_file_warnings(
acl_rules=["is_self path_beg /self-config-file",
"is_foo path_beg /foo -m beg"],
) == []
# ──────────────────────────────────────────────────────────────────────
# 4. Wizard models accept `-f` (string + dict) — parity
# ──────────────────────────────────────────────────────────────────────
def test_wizard_models_accept_file_flag():
from models.site_wizard import FrontendStep
fe = FrontendStep(
name="fe1", mode="http", bind_address="*", bind_port=80,
acl_rules=[ACL_F],
use_backend_rules=["be-x if blacklisted"],
redirect_rules=[{"type": "scheme", "target": "https",
"condition": "if { src -f /etc/haproxy/x.lst }"}],
)
assert fe.acl_rules == [ACL_F]
assert fe.redirect_rules[0]["condition"] == "if { src -f /etc/haproxy/x.lst }"
# ──────────────────────────────────────────────────────────────────────
# 5. Bulk-import preview advisory — source-level pin
# ──────────────────────────────────────────────────────────────────────
def test_parse_bulk_advisory_scans_only_structured_rule_fields():
"""The preview advisory scans acl_rules/use_backend_rules but NOT
request_headers/tcp_request_rules (always-free-form fields —
warning there would add new noise for existing users)."""
src = Path(__file__).resolve().parents[1] / "routers" / "config.py"
text = src.read_text()
block_start = text.index("pattern-file advisory")
block = text[block_start:block_start + 1200]
assert 'acl_rules' in block
assert 'use_backend_rules' in block
assert 'request_headers' not in block.split("_pattern_paths")[1], (
"advisory must not scan request_headers")
def test_no_dash_f_reject_left_in_models():
"""No model file may still hard-reject the `-f` flag."""
for rel in ("models/frontend.py", "models/site_wizard.py"):
text = (Path(__file__).resolve().parents[1] / rel).read_text()
for m in re.finditer(r"-f\(\\s\|\$\)", text):
ctx = text[max(0, m.start() - 400):m.start() + 400]
assert "raise ValueError" not in ctx, (
f"{rel}: a `-f` reject regex still sits next to a raise")