From e16d807e6286df2d34fdd323cc7a2c836cef12f2 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 14 Oct 2026 00:21:24 +0200 Subject: [PATCH] fix(cache): refuse a tor deploy whose node onion is empty get_merged_domains gated the onion injection on node_onion being set AND svc-net-tor being in group_names. When the node was empty it answered the clearnet map with no error and no log line, and because group_names is part of the cache key, a clearnet map and an onion map coexist in one process under separate keys. One render then serves onion while another freezes a clearnet asset URL into a stylesheet. That URL is unrecoverable at request time: body_filter.lua returns a CSS response before onionize(), so the in the head is rewritten while the url(...) inside the stylesheet body is not. The symptom is a single toHaveCSS mismatch and nothing else. An empty node under a deployed svc-net-tor now raises. A scope carrying no group_names at all stays silent, because that is indistinguishable from a legitimate non-tor deploy. Co-Authored-By: Claude Opus 5 (1M context) --- tests/unit/python/utils/cache/test_domains.py | 40 +++++++++++++++++++ utils/cache/domains.py | 9 ++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/unit/python/utils/cache/test_domains.py b/tests/unit/python/utils/cache/test_domains.py index c884e16db2..a683cae05b 100644 --- a/tests/unit/python/utils/cache/test_domains.py +++ b/tests/unit/python/utils/cache/test_domains.py @@ -403,5 +403,45 @@ class TestOnionFlagsAreRendered(unittest.TestCase): ) +class TestOnionNodeIsRequiredWhenTorIsDeployed(unittest.TestCase): + """A deploy carrying `svc-net-tor` without a provisioned node used to + answer the clearnet map and cache it under its own key, because + `group_names` is part of that key. One scope then served onion while + another froze a clearnet asset URL into a stylesheet, which + `body_filter.lua`'s `onionize` returns before rewriting. + """ + + ONION = TestOnionDomainInjection.ONION + + def setUp(self) -> None: + _reset_cache_for_tests() + + def _merge(self, node, group_names): + with tempfile.TemporaryDirectory() as tmp: + roles = _seed_minimal_role(Path(tmp)) + with patch( + "utils.cache.applications.get_merged_applications", + return_value={"svc-net-tor": {"services": {"tor": {"node": node}}}}, + ): + return cache_domains.get_merged_domains( + variables={ + "DOMAIN_PRIMARY": "infinito.test", + "group_names": group_names, + }, + roles_dir=roles, + templar=None, + ) + + def test_an_empty_node_is_an_error(self): + with self.assertRaisesRegex(ValueError, "services.tor.node"): + self._merge("", ["svc-net-tor", "web-app-x"]) + + def test_a_provisioned_node_is_accepted(self): + self.assertIsInstance(self._merge(self.ONION, ["svc-net-tor"]), dict) + + def test_an_empty_node_is_fine_without_tor_in_the_groups(self): + self.assertIsInstance(self._merge("", ["web-app-x"]), dict) + + if __name__ == "__main__": unittest.main() diff --git a/utils/cache/domains.py b/utils/cache/domains.py index ee4bd569a4..cad30db441 100644 --- a/utils/cache/domains.py +++ b/utils/cache/domains.py @@ -269,7 +269,14 @@ def get_merged_domains( ).get("node") node_onion = str(_render_raw(node_raw or "", "services.tor.node") or "").strip() group_names = variables.get("group_names") or [] - if node_onion and "svc-net-tor" in group_names: + if "svc-net-tor" in group_names: + if not node_onion: + raise ValueError( + "get_merged_domains: 'svc-net-tor' is deployed but " + "applications.svc-net-tor.services.tor.node is empty. " + "Skipping the onion injection would serve every exclusive " + "app on clearnet without saying so." + ) merged = _inject_onion_domains( merged, apps,