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
2 changes: 1 addition & 1 deletion .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions tests/fuzz/corpus/cron/ansi_error
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
G1szMW1FUlJPUhtbMG06IHVwZGF0ZSBmYWlsZWQNCnJldHJ5DQo=
1 change: 1 addition & 0 deletions tests/fuzz/corpus/cron/at_reboot
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@reboot /home/gmserver/gmserver start
1 change: 1 addition & 0 deletions tests/fuzz/corpus/cron/b64_broken
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
not valid base64 !!!
1 change: 1 addition & 0 deletions tests/fuzz/corpus/cron/b64_status
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ZmV0Y2hpbmcgWyB4LmNzdiBdY3VybDogKDIyKSA0MDQKRVJST1IK
3 changes: 3 additions & 0 deletions tests/fuzz/corpus/cron/comment
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# a comment line

*/5 * * * * echo hi
1 change: 1 addition & 0 deletions tests/fuzz/corpus/cron/daily_restart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0 5 * * * touch /home/gmserver/.restart-pending
1 change: 1 addition & 0 deletions tests/fuzz/corpus/cron/many_fields
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* * * * * * * * * * /home/gm/gmserver monitor
1 change: 1 addition & 0 deletions tests/fuzz/corpus/cron/monitor
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*/5 * * * * /home/gmserver/gmserver monitor
1 change: 1 addition & 0 deletions tests/fuzz/corpus/cron/names
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0 5 * * mon /home/gm/gmserver backup
1 change: 1 addition & 0 deletions tests/fuzz/corpus/cron/no_schedule
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
just some text with no cron fields at all
1 change: 1 addition & 0 deletions tests/fuzz/corpus/cron/wrapped
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*/5 * * * * /usr/bin/flock -n /tmp/x /home/gm/.lgsm-cron-rec 3f2a /home/gm/gmserver update
54 changes: 54 additions & 0 deletions tests/fuzz/fuzz_cron.py
Original file line number Diff line number Diff line change
@@ -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()
34 changes: 34 additions & 0 deletions tests/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down