diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 8619dc8..b968dff 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -43,7 +43,7 @@ jobs: strategy: fail-fast: false # fuzz every target even if one finds a crash, so all findings surface matrix: - target: [ game_status, firewall, config, fail2ban, console ] + target: [ game_status, firewall, config, fail2ban, console, cron ] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/tests/fuzz/corpus/cron/ansi_error b/tests/fuzz/corpus/cron/ansi_error new file mode 100644 index 0000000..63df31b --- /dev/null +++ b/tests/fuzz/corpus/cron/ansi_error @@ -0,0 +1 @@ +G1szMW1FUlJPUhtbMG06IHVwZGF0ZSBmYWlsZWQNCnJldHJ5DQo= \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/at_reboot b/tests/fuzz/corpus/cron/at_reboot new file mode 100644 index 0000000..42f5ccf --- /dev/null +++ b/tests/fuzz/corpus/cron/at_reboot @@ -0,0 +1 @@ +@reboot /home/gmserver/gmserver start \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/b64_broken b/tests/fuzz/corpus/cron/b64_broken new file mode 100644 index 0000000..459643e --- /dev/null +++ b/tests/fuzz/corpus/cron/b64_broken @@ -0,0 +1 @@ +not valid base64 !!! \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/b64_status b/tests/fuzz/corpus/cron/b64_status new file mode 100644 index 0000000..d8c67f5 --- /dev/null +++ b/tests/fuzz/corpus/cron/b64_status @@ -0,0 +1 @@ +ZmV0Y2hpbmcgWyB4LmNzdiBdY3VybDogKDIyKSA0MDQKRVJST1IK \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/comment b/tests/fuzz/corpus/cron/comment new file mode 100644 index 0000000..f2ab94b --- /dev/null +++ b/tests/fuzz/corpus/cron/comment @@ -0,0 +1,3 @@ +# a comment line + +*/5 * * * * echo hi \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/daily_restart b/tests/fuzz/corpus/cron/daily_restart new file mode 100644 index 0000000..844fc5e --- /dev/null +++ b/tests/fuzz/corpus/cron/daily_restart @@ -0,0 +1 @@ +0 5 * * * touch /home/gmserver/.restart-pending \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/many_fields b/tests/fuzz/corpus/cron/many_fields new file mode 100644 index 0000000..9f308a8 --- /dev/null +++ b/tests/fuzz/corpus/cron/many_fields @@ -0,0 +1 @@ +* * * * * * * * * * /home/gm/gmserver monitor \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/monitor b/tests/fuzz/corpus/cron/monitor new file mode 100644 index 0000000..82cabbe --- /dev/null +++ b/tests/fuzz/corpus/cron/monitor @@ -0,0 +1 @@ +*/5 * * * * /home/gmserver/gmserver monitor \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/names b/tests/fuzz/corpus/cron/names new file mode 100644 index 0000000..109aed9 --- /dev/null +++ b/tests/fuzz/corpus/cron/names @@ -0,0 +1 @@ +0 5 * * mon /home/gm/gmserver backup \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/no_schedule b/tests/fuzz/corpus/cron/no_schedule new file mode 100644 index 0000000..aa22e0d --- /dev/null +++ b/tests/fuzz/corpus/cron/no_schedule @@ -0,0 +1 @@ +just some text with no cron fields at all \ No newline at end of file diff --git a/tests/fuzz/corpus/cron/wrapped b/tests/fuzz/corpus/cron/wrapped new file mode 100644 index 0000000..c4cd2ae --- /dev/null +++ b/tests/fuzz/corpus/cron/wrapped @@ -0,0 +1 @@ +*/5 * * * * /usr/bin/flock -n /tmp/x /home/gm/.lgsm-cron-rec 3f2a /home/gm/gmserver update \ No newline at end of file diff --git a/tests/fuzz/fuzz_cron.py b/tests/fuzz/fuzz_cron.py new file mode 100644 index 0000000..ecfa9af --- /dev/null +++ b/tests/fuzz/fuzz_cron.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Atheris fuzz harness: the crontab / cron-status parsers (ssh_manager). + +Everything these read comes off a REMOTE host: the game user's crontab, and the base64-framed +status blob the panel's own cron wrapper writes after each run. Both are outside the panel's +control — a crontab can be edited by hand on the box, and the status blob carries whatever a +LinuxGSM command printed to stderr, which for an update means text fetched off the internet. + +The Scheduled Tasks page renders all of it, and the panel's cron-status path has already produced +one real bug this way (a `tr` that missed \\r truncated every record at the transport, so errors +silently read as empty). These parsers are best-effort and must never raise: a crash here takes +out the whole page rather than one row. + +Run locally (from anywhere): + pip install atheris + python tests/fuzz/fuzz_cron.py -max_total_time=60 tests/fuzz/corpus/cron +""" +import importlib +import os +import sys + +import atheris + +# Running `python tests/fuzz/fuzz_x.py` puts tests/fuzz (not the project root) on sys.path. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +# Pre-load ssh_manager's heavy dependencies UNINSTRUMENTED so instrument_imports() covers only the +# parsers (faster, and the coverage signal stays on target). +for _dep in ("paramiko", "eventlet.tpool", "config"): + importlib.import_module(_dep) + +with atheris.instrument_imports(): + import ssh_manager + + +def TestOneInput(data): + fdp = atheris.FuzzedDataProvider(data) + text = fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes()) + + ssh_manager._split_cron_line(text) # "*/5 * * * * cmd" -> (schedule, command) + ssh_manager._unwrap_cron_command(text) # strips the panel's recorder wrapper + ssh_manager._cron_role(text, "gm", "gmserver") # autostart / daily-restart label + ssh_manager._cron_line_managed(text, "gm", "gmserver") + ssh_manager._cron_log_text(text) # lenient base64 decode of a status blob + ssh_manager._clean_cron_error(text) # terminal-render + trim for display + + +def main(): + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/tests/unit_test.py b/tests/unit_test.py index 45cd70e..f2c190a 100644 --- a/tests/unit_test.py +++ b/tests/unit_test.py @@ -3289,6 +3289,40 @@ def _fake_post(url, data, headers): finally: N._cfg = _sv_cfg2 +# ── Tailscale address recognition ────────────────────────────────────────────────────────────── +# is_tailscale_ip decides whether the panel treats a host as being on the tailnet, which changes +# how it connects and what it exempts from fail2ban/UFW. A false positive would exempt a PUBLIC +# address from the security rules, so the near-misses matter as much as the hits. +import tailscale_integration as TS + +for _h in ("100.64.0.1", "100.115.92.7", "fd7a:115c:a1e0::1", + "ns106051.taile87e07.ts.net", "box.tail1234.ts.net", "host.taile87e07.example"): + check("tailnet: %r is recognised" % _h, TS.is_tailscale_ip(_h) is True) +for _h in ("10.0.0.1", "192.168.1.5", "1.100.0.1", "203.0.113.9", "example.ts.net.evil.com", + "fd7b:115c::1", "", None, "100abc.example.com"): + check("tailnet: %r is NOT taken for a tailnet address" % (_h,), TS.is_tailscale_ip(_h) is False) + +# get_magic_url: the port is omitted for the standard ones and appended otherwise, and there is no +# URL at all when the node has no MagicDNS name (rather than a broken "https://None"). +_sv_info = TS.get_tailscale_info +try: + class _Info(object): + def __init__(self, dns): self.dns_name, self.tailscale_ips, self.peers = dns, [], [] + TS.get_tailscale_info = lambda *a, **k: _Info("box.taile87e07.ts.net") + eq("magic url: no port for https", TS.get_magic_url(), "https://box.taile87e07.ts.net") + eq("magic url: 443 is left off", TS.get_magic_url(443), "https://box.taile87e07.ts.net") + eq("magic url: 80 is left off too", TS.get_magic_url(80), "https://box.taile87e07.ts.net") + eq("magic url: any other port is appended", TS.get_magic_url(5000), + "https://box.taile87e07.ts.net:5000") + eq("magic url: the protocol is honoured", TS.get_magic_url(8080, "http"), + "http://box.taile87e07.ts.net:8080") + TS.get_tailscale_info = lambda *a, **k: _Info("") + eq("magic url: no MagicDNS name means no URL, not a broken one", TS.get_magic_url(), None) +finally: + TS.get_tailscale_info = _sv_info + +check("tailnet: the panel can name the OS user it runs as", bool(TS._current_os_user())) + passed = sum(1 for ok, _, _ in results if ok) for ok, name, detail in results: line = ("PASS" if ok else "FAIL") + " " + name