From b79a6fd7f263aa707bac9778ce58a65f5734bf4c Mon Sep 17 00:00:00 2001 From: Security Fix Date: Fri, 2 Oct 2026 07:12:19 +0000 Subject: [PATCH] ssh: don't open port 22 for the loopback-only sshd sshd-localhost.nix turns sshd on for every role so that "ssh root@localhost" works, and binds it to 127.0.0.1 only ("zero network exposure", as its own comment says, and as sshd.nix expects: the sshd feature "extends this to 0.0.0.0 and opens port 22 on the firewall when the user enables remote SSH"). It never turned off services.openssh.openFirewall, which NixOS defaults to true and applies whether or not sshd listens on the ports. So port 22 was open in the firewall on every role, Desktop Only included, with nothing behind it. No listener means no live exposure today; it does mean the firewall was not saying what the documentation says, and the day something does bind 22 on a wider address (a listenAddresses change, a second daemon) it would be reachable without anyone having opened it. openFirewall is now mkDefault false there. Nothing that wants SSH published loses it: sshd.nix (feature sshd) and remote-deploy.nix both add 22 explicitly, and only when enabled. Found by evaluating the real module set; grepping for allowedTCPPorts does not see a NixOS default. Evaluated with nix eval, TCP firewall ports: before after Server + Desktop 22 80 443 3051 8937 80 443 3051 8937 Bitcoin Node Only 22 3051 8937 60847 3051 8937 60847 Desktop Only 22 (none) Desktop + features.sshd 22 22 Desktop + deploy.enable 22 3389 22 3389 and sshd's listen addresses are unchanged: 127.0.0.1 by default, 127.0.0.1 and 0.0.0.0 with the sshd feature. Desktop Only now opens no TCP port at all; UDP 5353 (mDNS) is the only port open there, and SECURITY.md says so. Add tests/test_ssh_exposure.py. --- SECURITY.md | 3 +- modules/core/sshd-localhost.nix | 7 ++++ tests/test_ssh_exposure.py | 64 +++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tests/test_ssh_exposure.py diff --git a/SECURITY.md b/SECURITY.md index 71fa342..2b2ad77 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,7 +45,8 @@ sites. On Desktop Only the Hub is not published at all. It is reachable only from the machine itself, through the desktop application window on localhost. Desktop Only is the role most likely to be used away from home, and a root-capable admin -UI has no business listening on a coffee-shop network. +UI has no business listening on a coffee-shop network. The firewall there opens +no TCP port at all; the only port open is UDP 5353, for mDNS. `sovran_systemsOS.hub.directPort = true` in `custom.nix` opens port 8937 if you do want to reach a Desktop Only Hub from another device. diff --git a/modules/core/sshd-localhost.nix b/modules/core/sshd-localhost.nix index 76be80f..9d4841a 100644 --- a/modules/core/sshd-localhost.nix +++ b/modules/core/sshd-localhost.nix @@ -9,6 +9,13 @@ services.openssh = { enable = true; + # sshd listens on 127.0.0.1 only here, so there is nothing for the firewall + # to let in. NixOS opens sshd's ports by default (openFirewall = true) + # whether or not sshd listens on them, which left port 22 open on every + # role, Desktop Only included. The roles that do publish SSH open it + # themselves: the sshd feature (sshd.nix) and remote deploy + # (remote-deploy.nix) both add 22 explicitly. + openFirewall = lib.mkDefault false; listenAddresses = lib.mkDefault [ { addr = "127.0.0.1"; port = 22; } ]; diff --git a/tests/test_ssh_exposure.py b/tests/test_ssh_exposure.py new file mode 100644 index 0000000..fc915c1 --- /dev/null +++ b/tests/test_ssh_exposure.py @@ -0,0 +1,64 @@ +"""Guards for when port 22 is open in the firewall. + +sshd-localhost.nix gives every role "ssh root@localhost" by listening on +127.0.0.1 only. NixOS opens sshd's ports in the firewall by default whether or +not sshd listens on them, which left port 22 open on every role, Desktop Only +included, with nothing behind it. The roles that really publish SSH open it +explicitly, so the default has to stay off. + +Like the other nix-file checks these read the modules as text: nothing is run +and nothing touches the network. +""" + +import os +import re +import unittest + +_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) + + +def _read(*parts): + with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f: + return f.read() + + +def _without_comments(src): + return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#")) + + +class LocalhostSshdDoesNotOpenThePort(unittest.TestCase): + + def test_the_firewall_is_not_opened_by_default(self): + code = _without_comments(_read("modules", "core", "sshd-localhost.nix")) + self.assertRegex(code, r"openFirewall\s*=\s*lib\.mkDefault\s+false\s*;") + + def test_it_still_listens_on_loopback_only(self): + code = _without_comments(_read("modules", "core", "sshd-localhost.nix")) + self.assertRegex(code, r'addr\s*=\s*"127\.0\.0\.1"') + self.assertNotIn("0.0.0.0", code) + + +class PublishedSshOpensItsOwnPort(unittest.TestCase): + """Turning the default off must not close the roles that want SSH open.""" + + def test_the_sshd_feature_opens_22_and_only_when_enabled(self): + src = _without_comments(_read("modules", "sshd.nix")) + self.assertRegex(src, r"lib\.mkIf\s+config\.sovran_systemsOS\.features\.sshd") + self.assertRegex(src, r"networking\.firewall\.allowedTCPPorts\s*=\s*\[\s*22\s*\]") + + def test_remote_deploy_opens_22_and_only_when_enabled(self): + src = _without_comments(_read("modules", "core", "remote-deploy.nix")) + self.assertRegex(src, r"lib\.mkIf\s+cfg\.enable") + self.assertRegex(src, r"networking\.firewall\.allowedTCPPorts\s*=\s*\[\s*22\s*\]") + + +class DesktopOnlyDocumentsWhatItOpens(unittest.TestCase): + + def test_security_policy_says_desktop_opens_no_tcp_port(self): + text = " ".join(_read("SECURITY.md").split()) # the file is line-wrapped + self.assertIn("opens no TCP port", text) + self.assertIn("UDP 5353", text) + + +if __name__ == "__main__": + unittest.main()