Replacing Packet Filter (pf) with Content Filter for VPN Firewall Use Case

Hi,

We're in the process of following Apple’s guidance on transitioning away from Packet Filter (pf) and migrating to a Network Extension-based solution that functions as a firewall. During this transition, we've encountered several limitations with the current Content Filter API and wanted to share our findings.

Our VPN client relies on firewall functionality to enforce strict adherence to split tunneling rules defined via the routing table. This ensures that no traffic leaks outside the VPN tunnel, which is critical for our users for a variety of reasons.

To enforce this, our product currently uses interface-scoped rules to block all non-VPN traffic outside the tunnel. Replicating this behavior with the Content Filter API (NEFilterDataProvider) appears to be infeasible today.

The key limitation we've encountered is that the current Content Filter API does not expose information about the network interface associated with a flow. As a workaround, we considered using the flow’s local endpoint IP to infer the interface, but this data is not available until after returning a verdict to peek into the flow’s data—at which point the connection has already been established. This can result in connection metadata leaking outside the tunnel, which may contain sensitive information depending on the connection.

What is the recommended approach for this use case?

  • NEFilterPacketProvider?
    This may work, but it has a negative impact on network performance.
  • Using a Packet Tunnel Provider and purely relying on enforceRoutes?
    Would this indeed ensure that no traffic can leak by targeting a specific interface or by using a second VPN extension?

And more broadly—especially if no such approach is currently feasible with the existing APIs—we're interpreting TN3165 as a signal that pf should be considered deprecated and may not be available in the next major macOS release. Is that a reasonable interpretation?

Answered by DTS Engineer in 839708022

My go-to reference for this sort of stuff is Routing your VPN network traffic [1]. This outlines two options for preventing ‘escapes’:

  • For a full tunnel, set includeAllNetworks and then set exceptions via the various excludeXYZ options.

  • For a split tunnel, set enforceRoutes. There’s some subtlety with this, as explained in the Enforce the inclusions and exclusions for a packet tunnel provider section.

That leaves this:

be able to block any traffic while the tunnel is not yet connected

Honestly, I’m not sure what the best path forward is for that requirement. I’m gonna research that and get back to you.

Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] I know you’ve seen this but I’m replying as if you haven’t so that our conversation makes sense to other folks reading this thread.

we're interpreting TN3165 as a signal that pf should be considered deprecated

Well, we can’t deprecate something that we’ve never officially supported as an API (-: But, yes, our position is that, if you’re building Mac networking products using legacy techniques then you should move to a Network Extension provider. That includes PF, but other things as well, including manually manipulating UTUN interfaces and modifying the routing table ‘behind the back’ of configd.

may not be available in the next major macOS release

We’ve made no announcement about timelines. However, our experience is that products using these legacy techniques tend to experience compatibility problems as we evolve the system, so it’d be better to move off them sooner rather than later.


Coming back to your content filter question, I don’t have an immediate answer for you. I’m gonna do some digging and get back to you.

Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

I’m gonna do some digging and get back to you.

Actually, scratch that. Before I start digging deeper, I’d like to get a better handle on the goals of your product.

When I first read your post I was focused on the content filter side of this, but there’s also a VPN side. You wrote:

This ensures that no traffic leaks outside the VPN tunnel

So it sounds like:

  • You’re actually building a VPN product.

  • Specifically, it operates at the IP packet layer.

  • Forwarding IP packets via a tunnel to a VPN server.

  • Historically it was hard to prevent ‘leaks’ from the tunnel.

  • So you did that using PF.

Is that right?

Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks for the quick responses so far!

Yes, the product basically provides an IP-Layer VPN to a remote server.

PF is indeed used to enforce all outgoing traffic complies with the configured split tunnel rules and traffic isn't leaked. These leaks would mainly be due to:

  • connections that ignore the routing table and enforce an interface (e.g. curl --interface en0 https://apple.com)
  • be able to block any traffic while the tunnel is not yet connected

My go-to reference for this sort of stuff is Routing your VPN network traffic [1]. This outlines two options for preventing ‘escapes’:

  • For a full tunnel, set includeAllNetworks and then set exceptions via the various excludeXYZ options.

  • For a split tunnel, set enforceRoutes. There’s some subtlety with this, as explained in the Enforce the inclusions and exclusions for a packet tunnel provider section.

That leaves this:

be able to block any traffic while the tunnel is not yet connected

Honestly, I’m not sure what the best path forward is for that requirement. I’m gonna research that and get back to you.

Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] I know you’ve seen this but I’m replying as if you haven’t so that our conversation makes sense to other folks reading this thread.

I’m gonna research that and get back to you.

My initial thought on this front was to create a packet tunnel provider that starts and stays started regardless of the state of the underlying tunnel. The system will then route traffic for the destination network to you, and you can just drop it if the tunnel is down.

The drawback to this approach is that there isn’t a good way to start the tunnel at boot. We actually discussed this on the forums recently — see this thread — and there are approaches that work but nothing that I’d consider to be ‘good’.

Oh, and the developer in that thread filed an ER, which is great. If you want to track its state, file your own bug and ask that it be dup’d to that one.

An alternative approach is to create an NE filter data provider and have it and your packet tunnel provider cooperate to implement this feature. That is, when a new flow arrives at the filter, check whether the tunnel is up and, if it isn’t, block the flow.

Note that, if you use sysex packaging, which you really should, both NE providers run in the same process and thus this sort of cooperation is easy. It can be something as simple as a global variable (with ån appropriate mutex, of course).

Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Following up on this one from the same team (I'm working on our pf migration with Andreas). We tried both suggestions and each one falls short in a way that would be helpful to get your take on.

Provider that stays started and drops traffic while the tunnel is down. This blocks correctly, but it breaks captive portals. Starting with the full included routes before the tunnel is up sends ordinary sockets into the utun, including the browser and our own captive-portal detection probes. Detection never sees the portal, and the user can't reach the login page. Binding the probes to the physical interface doesn't help either: with enforceRoutes off, a socket bound to en0 (IP_BOUND_IF or a source-address bind) still can't reach a destination covered by the tunnel's included routes while the tunnel is connecting. It fails immediately, and only excluded ranges answer.

Content filter cooperating with the provider. This mostly works while the tunnel is down. The gap is TCP: a blocked connect still puts its SYN on the wire before the verdict lands (the RST follows, with no payload). It also can't cover TunnelVision-style routes while the tunnel is connected. We'd like to run with enforceRoutes on but because of the socket kills in https://developer.apple.com/forums/thread/850873 we suspect customers will complain because of the disruptions. A more-specific route via the LAN gateway then sends tunnelled destinations out en0, and the filter never sees an unconnected UDP socket's local address, so it either lets those datagrams out en0 or blocks unconnected UDP into the tunnel too.

We need split tunnelling, so includeAllNetworks is out. Two questions:

  1. Is there a supported way to close those gaps short of NEFilterPacketProvider, or is a packet filter the intended answer for blocking at the packet level?
  2. Routing your VPN network traffic says captive-portal negotiation traffic is always excluded from the tunnel under includeAllNetworks. Does that exemption also apply when enforceRoutes is set? And does it cover only the system's probe and Captive Network Assistant sheet, or browser traffic to the portal as well?

If the answer is an enhancement request, I'm happy to file one.