feat(ansible): runs stay inside their source; controller-side vars are admin-only (#5034)
#5034 "Playbook runs: path and inventory checks in trigger_run, connection vars admin-only, confirm enforced server-side, web actions audited". Ansible runs inside the Steward container, so an operator who could name any playbook path, set ansible_connection=local or ProxyCommand ssh args, or put {{ lookup('pipe', …) }} in a value could run commands on Steward. - steward/ansible/guards.py: check_playbook (the source must list it), check_inventory_scope (steward:* or an inventory file the source has), check_vars_for_role (ansible_* beyond host/port/user/password/become, and any Jinja in a value, are admin-only), changed_vars (an edit is held to the role only for what it changes). - trigger_run applies the path checks, so every run path — web, MCP, alerts, schedules, plugins — gets them. - Variables are checked by role on the web run form, host-page runs, schedule create/update, inventory group/target create/update, and MCP run_playbook / create_schedule / update_schedule. - A steward:confirm playbook needs the confirmation ticked on the server side for a real (non-check) web run, as MCP already required. - Audited: runs, cancel, schedule create/update/enable/disable/delete/ run-now, playbook create/update/delete, inventory group and target create/update/delete (variable names only, never values). One audit_request helper serves the web routes. - The CSRF form guard and the route guard now each test that they fail on a synthetic broken input (rule: a guard must be able to fail). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+18
-8
@@ -14,17 +14,27 @@ _FORM_TAG = re.compile(r"<form\b[^>]*>", re.S | re.I)
|
||||
_POST = re.compile(r"""\bmethod\s*=\s*["']?post\b""", re.I)
|
||||
|
||||
|
||||
def forms_missing_token(text: str) -> list[int]:
|
||||
"""Line numbers of POST forms whose first child is not {{ csrf_field() }}."""
|
||||
return [text.count("\n", 0, m.start()) + 1 for m in _FORM_TAG.finditer(text)
|
||||
if _POST.search(m.group(0))
|
||||
and not text[m.end():].lstrip().startswith("{{ csrf_field() }}")]
|
||||
|
||||
|
||||
def test_the_form_guard_can_fail():
|
||||
broken = '<form\n method="post" action="/x">\n <input name="a">\n</form>'
|
||||
fixed = '<form method="POST" action="/x">{{ csrf_field() }}<input name="a"></form>'
|
||||
assert forms_missing_token(broken) == [1]
|
||||
assert forms_missing_token(fixed) == []
|
||||
assert forms_missing_token('<form method="get">') == []
|
||||
|
||||
|
||||
def test_every_post_form_carries_the_token():
|
||||
files = sorted((_REPO / "steward" / "templates").rglob("*.html")) + \
|
||||
sorted((_REPO / "plugins").rglob("templates/**/*.html"))
|
||||
missing = []
|
||||
for f in files:
|
||||
text = f.read_text()
|
||||
for m in _FORM_TAG.finditer(text):
|
||||
if _POST.search(m.group(0)) and \
|
||||
not text[m.end():].lstrip().startswith("{{ csrf_field() }}"):
|
||||
line = text.count("\n", 0, m.start()) + 1
|
||||
missing.append(f"{f.relative_to(_REPO)}:{line}")
|
||||
assert files
|
||||
missing = [f"{f.relative_to(_REPO)}:{line}" for f in files
|
||||
for line in forms_missing_token(f.read_text())]
|
||||
assert not missing, "POST forms without {{ csrf_field() }}: " + ", ".join(missing)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user