All product names, logos, and brands used in this post are property of their respective owners.
I encountered a scenario recently where I needed to quickly restrict access to specific subnets and specific ports on a Mac Mini “server” running macOS High Sierra (also tested successfully on Mojave, Catalina, Big Sur, Monterey, and Ventura). I thought this would be an easy task but my lack of familiarity with BSD and packet filter (pf) made it a little more challenging than I expected. I am more familiar with CentOS/RHEL-based distributions, TCP wrappers, and iptables which may have contributed to my confusion.
With the help of an awesome post called A simple guild to the Mac PF Firewall by Marc Kerr, I was able to accomplish what I needed. I will share my findings in this post in the hopes that it helps someone in a similar situation. This post focuses on the filtering functionality of pf - specifically, block and pass. Packet filter is a very robust firewalling solution. It also supports translation, routing, queuing, traffic normalization, etc. The more advanced functionality of pf is outside my wheelhouse. When dealing with those scenarios, I opt for a load balancer (like ZEVENET) instead.
pf works on the principle of first blocking traffic, then allowing it. For example, to restrict access to SSH (TCP/22) on your Mac, you first create a rule to block all traffic to port 22, then create additional rules after the initial block to allow IP addresses, subnets, etc. access to port 22.
Note, this approach is not perfect; one of the drawbacks of modifying pf.conf directly is that macOS upgrades revert that file to its default contents (removing your custom rules). For example, in an upgrade from High Sierra (macOS 10.13.x) to Catalina (10.15.x), the following pf files were overwritten on my test Mac:
Custom anchors under /etc/pf.anchors/ were retained, but they were not especially useful since the references to them in pf.conf were overwritten!
Additionally, you must re-enable PF (pfctl -E) each time your Mac reboots; ideally, you should create a launchd job for this (see Pfctl launch daemon does not seem to process program arguments).
In my case, I worked around these concerns by using Jamf Pro (a device management / MDM tool) to apply a Policy with 2 payloads: one to push out my centrally managed, custom pf.conf file (containing my rules) as a Package and one to run a Script containing the commands that load the PF config and activate packet filter; my Policy runs at Startup (Ongoing) and on periodic check-in so that in-scope Macs are always configured with my custom rules.
Create a backup of the default pf.conf file (sudo cp -p /etc/pf.conf /etc/pf.conf.bak)
Add your own rules to /etc/pf.conf (appending them after the default Apple anchors) - see examples below
Load your custom rules (sudo pfctl -f /etc/pf.conf) - output should resemble the following if all is well:
pfctl: Use of -f option, could result in flushing of rules present in the main ruleset added by the system at startup. See /etc/pf.conf for further details. No ALTQ support in kernel ALTQ related functions disabled
If you receive errors, check the syntax of your rules in pf.conf.
- (Re)Enable the packet filter firewall (sudo pfctl -E) - output should resemble the following if all is well:
No ALTQ support in kernel ALTQ related functions disabled pf enabled Token : 13971906727590307623
If you receive errors, check the syntax of your rules in pf.conf.
Instead of performing step 3, you can optionally reboot your Mac; pf will pick up your rules on restart when it reads pf.conf. As noted, you may still need to (re)activate the firewall with pfctl -E though. You can also combine the commands from step 3 and 4 into one: sudo pfctl -Ef /etc/pf.conf
Below, I have shared a few examples of adding rules in this way. When adding your rules, ensure you leave the default pf.conf intact and append your custom rules to the end of the file (see the last example).
Example: Block all TCP traffic to a specific port (8080 in this case)
# Block all HTTP (Tomcat, port 8080) access block return in proto tcp from any to any port 8080
Example: Allow a single IP address (10.10.10.15) and a subnet (10.10.20.0/24) access to HTTPS on port 443 (TCP) (block all other HTTPS access)
# Restrict HTTPS (port 443) access block return in proto tcp from any to any port 443 pass in inet proto tcp from 10.10.10.15 to any port 443 no state pass in inet proto tcp from 10.10.20.0/24 to any port 443 no state
Example: Allow 3 specific subnets (10.10.20.0/24, 10.10.30.0/23, 10.10.50.0/24 SSH access on port 22 (TCP) (block all other SSH access)
# Restrict SSH (port 22) access block return in proto tcp from any to any port 22 pass in inet proto tcp from 10.10.20.0/24 to any port 22 no state pass in inet proto tcp from 10.10.30.0/23 to any port 22 no state pass in inet proto tcp from 10.10.50.0/24 to any port 22 no state
Example: Adding multiple, varying firewall rules (combining the examples above)
# # Default PF configuration file. # # This file contains the main ruleset, which gets automatically loaded # at startup. PF will not be automatically enabled, however. Instead, # each component which utilizes PF is responsible for enabling and disabling # PF via -E and -X as documented in pfctl(8). That will ensure that PF # is disabled only when the last enable reference is released. # # Care must be taken to ensure that the main ruleset does not get flushed, # as the nested anchors rely on the anchor point defined here. In addition, # to the anchors loaded by this file, some system services would dynamically # insert anchors into the main ruleset. These anchors will be added only when # the system service is used and would removed on termination of the service. # # See pf.conf(5) for syntax. # # # com.apple anchor point # pflog_logfile="/var/log/pflog" scrub-anchor "com.apple/*" nat-anchor "com.apple/*" rdr-anchor "com.apple/*" dummynet-anchor "com.apple/*" anchor "com.apple/*" load anchor "com.apple" from "/etc/pf.anchors/com.apple" # # Your own rules here # # Block all HTTP (Tomcat, port 8080) access block return in proto tcp from any to any port 8080 # Restrict HTTPS (port 443) access block return in proto tcp from any to any port 443 pass in inet proto tcp from 10.10.10.15 to any port 443 no state pass in inet proto tcp from 10.10.20.0/24 to any port 443 no state # Restrict SSH (port 22) access block return in proto tcp from any to any port 22 pass in inet proto tcp from 10.10.20.0/24 to any port 22 no state pass in inet proto tcp from 10.10.30.0/23 to any port 22 no state pass in inet proto tcp from 10.10.50.0/24 to any port 22 no state