mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
fix: emit real micron, and validate values interpolated into daemon configs
render_micron was a one-line alias to the Gemtext renderer, so every .mu file AMPG produced was Gemtext. A Nomad Network browser renders those markers as raw punctuation. Micron is a different markup: headings are > by depth, links are inline as `[label`url] rather than standalone lines, emphasis is a toggle pair, and literal blocks are fenced with `=. Content is now escaped as well. Micron's control character is the backtick, and >, <, #, and - are structural at column zero, so a vendor named ">Free Beer" would have become a section heading on the published page. The line-start check applies to the emitted line rather than each text chunk, since whether a character is structural depends on it landing at column zero. Separately, torrc and tunnels.conf are line-oriented with no quoting or escape mechanism, so a newline in an interpolated value injects an arbitrary directive: a tunnel_name of "web\nhost = attacker.example" silently repoints the tunnel. tunnel_name and keys_file are now validated at the point of interpolation. _validate_site_id covers the init path only, so a config loaded from disk never passed through it. There is nothing to escape with here, so these reject rather than sanitize. Generated systemd units had no User= and no hardening, so an operator following the install steps as root got a root-run daemon. Units now carry User=/Group= when a service account is configured, a visible warning when not, and a baseline of hardening directives chosen to not break a daemon whose state directory may live anywhere. Two tests asserted Gemtext markers inside a .mu file and so had encoded the bug.
This commit is contained in:
@@ -116,8 +116,12 @@ max_asset_bytes = 1024
|
||||
self.assertFalse((root / "out/reticulum/app.js").exists())
|
||||
self.assertFalse((root / "out/reticulum/styles.css").exists())
|
||||
micron = (root / "out/reticulum/index.mu").read_text(encoding="utf-8")
|
||||
self.assertIn("# Home", micron)
|
||||
self.assertIn("=> about.mu About", micron)
|
||||
# A .mu file must contain micron, not Gemtext. Nomad Network renders
|
||||
# Gemtext markers as raw punctuation.
|
||||
self.assertIn(">Home", micron)
|
||||
self.assertNotIn("# Home", micron)
|
||||
self.assertIn("`[About`about.mu]", micron)
|
||||
self.assertNotIn("=> about.mu", micron)
|
||||
self.assertNotIn("bad", micron)
|
||||
|
||||
|
||||
|
||||
@@ -47,11 +47,39 @@ class GemtextRendererTest(unittest.TestCase):
|
||||
rewrite_link=lambda href: href.replace(".html", ".mu"),
|
||||
)
|
||||
|
||||
self.assertIn("# Hello", micron)
|
||||
self.assertIn("Read the about page.", micron)
|
||||
self.assertIn("=> about.mu about page", micron)
|
||||
# Micron, not Gemtext: headings are `>` by depth and links are inline
|
||||
# as `[label`url], not standalone "=> url label" lines.
|
||||
self.assertIn(">Hello", micron)
|
||||
self.assertNotIn("# Hello", micron)
|
||||
self.assertIn("`[about page`about.mu]", micron)
|
||||
self.assertNotIn("=> about.mu", micron)
|
||||
self.assertNotIn("ignored", micron)
|
||||
|
||||
def test_micron_escapes_content_that_would_become_markup(self):
|
||||
html = (
|
||||
"<p>>Free Beer</p>"
|
||||
"<p>price is 3 `BTC`</p>"
|
||||
"<h2>Sub</h2>"
|
||||
"<pre>literal `backticks` stay</pre>"
|
||||
)
|
||||
|
||||
micron = render_micron(html)
|
||||
|
||||
# A line starting with a structural character must not become a heading.
|
||||
self.assertIn("\\>Free Beer", micron)
|
||||
# Backticks in content must not open a formatting run.
|
||||
self.assertIn("3 \\`BTC\\`", micron)
|
||||
# Headings and literal blocks still render as real micron.
|
||||
self.assertIn(">>Sub", micron)
|
||||
self.assertIn("`=", micron)
|
||||
self.assertIn("literal `backticks` stay", micron)
|
||||
|
||||
def test_micron_emphasis_uses_toggle_pairs(self):
|
||||
micron = render_micron("<p>a <strong>b</strong> c <em>d</em></p>")
|
||||
|
||||
self.assertIn("`!b`!", micron)
|
||||
self.assertIn("`*d`*", micron)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -461,5 +461,97 @@ daemon_policy = "auto"
|
||||
self.assertIn("ExecStart=/bin/sh -lc 'exec tor", service.content)
|
||||
|
||||
|
||||
class DaemonConfigInjectionTests(unittest.TestCase):
|
||||
"""torrc and tunnels.conf are line-oriented with no escape mechanism, so an
|
||||
interpolated newline injects an arbitrary directive."""
|
||||
|
||||
def test_rejects_newline_in_tunnel_name(self):
|
||||
from ampg.install_plan import _validate_config_token
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_config_token("web\nhost = attacker.example", "tunnel_name")
|
||||
|
||||
def test_rejects_separator_characters(self):
|
||||
from ampg.install_plan import _validate_config_token
|
||||
|
||||
for value in ["a b", "../../etc/tor", "name=value", "a\rb", ""]:
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_config_token(value, "keys_file")
|
||||
|
||||
def test_accepts_ordinary_identifiers(self):
|
||||
from ampg.install_plan import _validate_config_token
|
||||
|
||||
self.assertEqual(_validate_config_token("smirk-web.dat", "keys_file"), "smirk-web.dat")
|
||||
|
||||
def test_rejects_line_break_in_state_path(self):
|
||||
from ampg.install_plan import _validate_config_path
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_config_path(Path("/var/lib/x\nDataDirectory /tmp/evil"), "state directory")
|
||||
|
||||
|
||||
class SystemdHardeningTests(unittest.TestCase):
|
||||
"""A unit with no User= runs as whoever installs it, normally root."""
|
||||
|
||||
def _unit(self, **options) -> str:
|
||||
import dataclasses
|
||||
|
||||
from ampg.install_plan import _supervisor_content
|
||||
|
||||
config = load_config(
|
||||
_write_config(
|
||||
Path(tempfile.mkdtemp()),
|
||||
"""
|
||||
[site.protocols.tor]
|
||||
enabled = true
|
||||
renderer = "gemtext"
|
||||
daemon = "tor"
|
||||
daemon_policy = "auto"
|
||||
""",
|
||||
)
|
||||
)
|
||||
site = config.sites[0]
|
||||
base = site.protocols["tor"]
|
||||
protocol = dataclasses.replace(base, options={**base.options, **options})
|
||||
return _supervisor_content(
|
||||
config,
|
||||
site,
|
||||
protocol,
|
||||
platform_by_name("linux-systemd"),
|
||||
service_suffix="daemon",
|
||||
command="exec tor -f /etc/tor/torrc",
|
||||
)
|
||||
|
||||
def test_unit_warns_loudly_when_no_service_user_configured(self):
|
||||
unit = self._unit()
|
||||
|
||||
self.assertIn("WARNING: no service_user configured", unit)
|
||||
self.assertNotIn("\nUser=", unit)
|
||||
|
||||
def test_unit_sets_account_when_service_user_configured(self):
|
||||
unit = self._unit(service_user="ampg-tor")
|
||||
|
||||
self.assertIn("User=ampg-tor", unit)
|
||||
self.assertIn("Group=ampg-tor", unit)
|
||||
self.assertNotIn("WARNING: no service_user", unit)
|
||||
|
||||
def test_unit_carries_baseline_hardening(self):
|
||||
unit = self._unit(service_user="ampg-tor")
|
||||
|
||||
for directive in (
|
||||
"NoNewPrivileges=true",
|
||||
"PrivateTmp=true",
|
||||
"RestrictSUIDSGID=true",
|
||||
"CapabilityBoundingSet=",
|
||||
):
|
||||
with self.subTest(directive=directive):
|
||||
self.assertIn(directive, unit)
|
||||
|
||||
def test_unit_rejects_injected_service_user(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self._unit(service_user="root\nExecStartPre=/bin/evil")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user