Quickly and easily adding pf (packet filter) firewall rules on macOS (OSX)

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. 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.

pf works on the principle of first blocking traffic, then allowing it. For example, to restrict access to AFP (TCP/548) on your Mac, you first create a rule to block all traffic to port 548, then create additional rules after the initial block to allow IP addresses, subnets, etc. access to port 548.

Note, my approach is far from perfect; I only recommend using this in a pinch (i.e. it is 4:30 PM on a Friday and you really want to get home and longboard, but you need to lock down some of your Macs first - you can tidy things up Monday). One of the major drawbacks of modifying pf.conf directly is that (as I understand it), macOS upgrades can revert that file to its default contents (removing your custom rules). 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 used Jamf Pro and a Policy with 2 payloads to: push out my custom pf.conf file (containing my rules) as a package and run a script containing the commands that load my PF config and activate packet filter; my Policy runs at Startup (Ongoing) so that in-scope Macs are always configured with my custom rules (after an initial reboot anyway).

The Process

1) Create a backup of the default pf.conf file (sudo cp -p /etc/pf.conf /etc/pf.conf.bak)

2) Add your own rules to /etc/pf.conf (appending them after the default Apple anchors) - see examples below

3) Load your custom rules (sudo pfctl -f /etc/pf.conf) - output should resemble the following:

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

4) (Re)Enable the packet filter firewall (sudo pfctl -E) - output should resemble the following:

No ALTQ support in kernel
ALTQ related functions disabled
pf enabled
Token : 13971906727590307623

Instead of performing steps 3, you can optionally reboot your Mac; pf will pick up your rules on restart. As noted, it must still be activated with pfctl -E though.

Below, I have shared a few examples of adding rules in this way. When adding your rules, ensure you leave pf.conf intact and append your custom rules to the end.

Example: Block all TCP traffic to a specific port (8080 in this case)

#
# 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 (as a temporary solution).
# If you really want no one to access port 8080, you should disable the
# Tomcat service on your Mac instead of just blocking the port.
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)

#
# 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
#

# 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)

#
# 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
#

# 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