Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/174-no-templating-config-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Security

- A declared config value is never run as an Ansible template: `cloudfall-engine inventory render` and the overlay `cloudfall observe` writes mark every string unsafe (`{"__ansible_unsafe": ...}`), so a value holding `{{ ... }}` reaches the unit file, the release command and the health check as written. Before, a `service.command`, `healthCheck.command` or `release.command` argument such as `{{ lookup('pipe', '...') }}` ran on the management host during `cloudfall health` or `cloudfall deploy`, and an honest one such as `docker inspect --format {{.State.Health.Status}} app` failed with a template syntax error. A declared value that relied on Ansible evaluating `{{ ... }}` is now literal, and the rendered inventory's strings read as `{"__ansible_unsafe": "..."}` in its JSON and in `ansible-inventory --list` (#174)
17 changes: 14 additions & 3 deletions engine/src/cloudfall_engine/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Self, cast

from cloudfall.ansible_data import as_ansible_data, unmarked
from cloudfall.commands import snake_case_keys
from cloudfall.domain import (
ARTIFACT_CLONE_TIMEOUT_SECONDS,
Expand Down Expand Up @@ -223,8 +224,14 @@ class RenderedInventory:
],
)
def render(args: RenderArgs, _ctx: Ctx, project: Project) -> RenderedInventory:
"""Render the inventory, and write it when ``--output`` names a file."""
rendered = render_ansible_inventory(project.inventory)
"""Render the inventory, and write it when ``--output`` names a file.

Every declared string is marked unsafe, so Ansible never templates it.
"""
rendered = cast(
"dict[str, object]",
as_ansible_data(render_ansible_inventory(project.inventory)),
)
if args.output is None:
return RenderedInventory(rendered)
output = project.path(args.output)
Expand Down Expand Up @@ -595,8 +602,12 @@ def _rendered_owned_databases(
if not inventory_file.is_file():
return ()
try:
# The rendered strings are marked unsafe for Ansible (#174).
document = cast(
"object", json.loads(inventory_file.read_text(encoding="utf-8"))
"object",
json.loads(
inventory_file.read_text(encoding="utf-8"), object_hook=unmarked
),
)
except (UnicodeDecodeError, json.JSONDecodeError):
return ()
Expand Down
2 changes: 1 addition & 1 deletion engine/tests/golden/examples/ansible-inventory.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions engine/tests/test_ansible_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,10 +359,10 @@ def test_engine_cli_emits_ansible_json(
assert document["ok"] is True
assert document["data"]["output"] is None
inventory = document["data"]["inventory"]
assert (
inventory["all"]["children"]["cloudfall_servers"]["hosts"]["h1"]["ansible_user"]
== "cloudfall"
)
h1 = inventory["all"]["children"]["cloudfall_servers"]["hosts"]["h1"]
# Declared strings are marked unsafe so Ansible never templates them (#174).
assert h1["ansible_user"] == {"__ansible_unsafe": "cloudfall"}
assert h1["ansible_port"] == 22
assert (
"component_crm_backend"
in inventory["all"]["children"]["cloudfall_components"]["children"]
Expand Down
7 changes: 6 additions & 1 deletion engine/tests/test_examples_render_unchanged.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, cast

from cloudfall.ansible_data import unmarked
from cloudfall_engine.cli import main
from jinja2 import Environment, FileSystemLoader, StrictUndefined

Expand Down Expand Up @@ -58,7 +59,11 @@ def render_examples(output: Path) -> dict[str, str]:
assert exit_code == 0
inventory_text = inventory_path.read_text(encoding="utf-8")
rendered = {"ansible-inventory.json": inventory_text}
document = cast("dict[str, dict[str, object]]", json.loads(inventory_text))
# Read the values back as Ansible's loader does: an unsafe string is text.
document = cast(
"dict[str, dict[str, object]]",
json.loads(inventory_text, object_hook=unmarked),
)
servers = cast(
"dict[str, dict[str, dict[str, dict[str, object]]]]",
document["all"]["children"],
Expand Down
13 changes: 10 additions & 3 deletions engine/tests/test_services_passwords.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
import sys
import tempfile
from pathlib import Path
from typing import Any
from typing import Any, cast

import pytest
import yaml
from cloudfall.ansible_data import as_ansible_data
from cloudfall.inventory import PlatformInventory
from cloudfall.validation import validate_config
from cloudfall_engine.ansible_inventory import render_ansible_inventory
Expand Down Expand Up @@ -67,8 +68,14 @@ def _inventory(tmp_path: Path) -> Path:
(project / "services" / "postgresql-billing.yaml").write_text(
yaml.safe_dump(service), encoding="utf-8"
)
rendered: dict[str, Any] = render_ansible_inventory(
PlatformInventory.from_state(validate_config(project, SCHEMAS))
# Marked as `cloudfall-engine inventory render` writes it (#174).
rendered = cast(
"dict[str, Any]",
as_ansible_data(
render_ansible_inventory(
PlatformInventory.from_state(validate_config(project, SCHEMAS))
)
),
)
host = rendered["all"]["children"]["cloudfall_servers"]["hosts"]["h1"]
host.update(
Expand Down
Loading
Loading