The NGI0 Entrust Fund, of which Liminix is a beneficiary, also offers technical services and expertise, and one of the services on offer is a security audit.

Between October 7, 2024 and November 7, 2024, Radically Open Security B.V. carried out a penetration test for Liminix, and they’ve also kindly agreed to remove the “Confidential” label from the report so I can share it.

The “Findings” were two cases of path traversal vulns, both caused by string concatenation. One was in the TFTP service, the other in watch-ssh-keys.fnl, and both were fixed back in November.

The “Non-findings”, though, included a review of the default firewall rules and some very useful advice on how to tighten them up. This has led to some extended comments in the code :-) and also some improvements in how it actually works.

Zones in firewall rules

Technically not a RoS finding at all, but I realised while looking at the firewall rules that the network interface names I’d originally hardcoded as int and ppp0, intending to go back and fix them later, were still hardcoded as int and ppp0.

For the particular config in modules/profiles/gateway.nix that’s not so bad as they are most of the time the correct network interface names, but even in that context not always. Sometimes when PPP wedges itself in weird ways and the service restarts you end up talking to ppp1 instead of ppp0. Or even ppp27 if that kind of thing goes on for a while. And for other more complicated configs with pluggable devices, this simply wasn’t going to help at all.

What we’ve done is to extend the firewall module with a zones parameter, which maps zone names to interface services

  services.firewall = svc.firewall.build {
    zones = {
      wan = [ services.pppoe ];
      lan = [ services.lan4 ];
    };
  };

For each zone we create an nftables set with the zone name, allowing us to write the firewall rules referencing sets instead of hardcoding interfaces

-      (accept "oifname \"int\" iifname \"ppp0\" meta l4proto udp ct state established,related")
-      (accept "iifname \"int\" oifname \"ppp0\" meta l4proto udp")
+      (accept "oifname @lan iifname @wan meta l4proto udp ct state established,related")
+      (accept "iifname @lan oifname @wan meta l4proto udp")

and then we use the secrets subscriber service (which turns out to be good for more than just secrets, and so could use a better name) to watch for interfaces appearing/disappearing and add them to the appropriate set.

DNS rule tightening

We no longer allow incoming packets on port 53 unless part of an established connection. This rule was added to enable DNS replies but it was never needed as conntrack already takes care of those.

ICMP rate limiting

ICMP is important to the correct functioning of a network (and in IPv6, even more so) so one does not simply turn it off. But that doesn’t mean we have to leave ourselves open to being ping flooded.

In theory it should have been easy to add rate limits for ICMP, but in practice, to calculate the cap at 5% of available bandwidth you need to know what the available bandwidth is - and the only person who knows that is the site administrator. We can’t identify the speed limit of the upstream just by looking at whether it’s DSL or fibre or ethernet or carrier pigeon, nor can we query the limit except by testing it, which would be somewhat invasive.

What we’ve come up with, which may with hindsight now I try to explain it be over-engineered, is a new concept of “service properties”, which can be added to any longrun service. Properties are similar to outputs except that they’re static: a service property is set at configuration time and exists whether the service is up or down. So we now have a bandwidth property on interface services, and the firewall service checks it whenever it gets notified of an interface change and uses it to write a new rule:

# modules/ppp/common.nix
  service = longrun {
    inherit name;
    run = ''
      # ...
    notification-fd = 10;
    properties.bandwidth = bandwidth;

What’s next?

Liminix 1.0 is next! I’ve been dogfooding Liminix for a year or more, and it’s been reliable for all but the first three weeks of that, so it’s time to say “good enough that you can use it” - and also, just as importantly, “we’ll tell you first if we’re going to radically break things”.

On my pre-release task list:

  • update the device documentation so that the reader knows which devices are usable and which are experimental

  • impose a consistent look and feel across the web site, so that the docs match the rest of it

  • make another video

Not on my pre-release task list, despite that I am sorely tempted to: completely redesigning the firewall config. I will resist. That can be in 1.1.