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.
This commit is contained in:
Security Fix
2026-10-02 02:24:29 -05:00
committed by naturallaw777
parent 3694ea6489
commit b79a6fd7f2
3 changed files with 73 additions and 1 deletions
+64
View File
@@ -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()