iOS VPN ondemand probes and caching

In our company we push out a VPN configuration to iOS devices that contains on demand rules to only connect to the VPN when not in the company network, based on various criteria.

TL;DR: unexpected caching behaviour of NEOnDemandRuleDisconnect.probeURL

We discovered that iOS (12.3.1) appears to aggressively cache the response of the probeURL when using NEOnDemandRuleDisconnect on demand rules in a VPN configuration.

The response is cached in certain situations, causing the rule to match when it shouldn’t and not to match when it should. This cache was only reset on rebooting the device and not when switching networks. We did not even see new DNS requests for the probeURL when switching between networks, which was even more problematic as we use Split-DNS for the resource that was probed.

The VPN on demand rules were evaluated every time the network changed, but the probe wasn’t sent and the rules still matched on the cached response.

  • When returning a 200 OK, make sure to add Cache-Control headers!!
  • Do not return 301 Moved Permanently!!

Checking for a HTTP resource

The “probe” resource was only available from within the company network, controlled by Split-DNS.

  // disconnect if "probe" returns 200 OK
  let rule1 = NEOnDemandRuleDisconnect()
  rule1.interfaceTypeMatch = .wiFi
  rule1.probeURL = URL(string: "http://company.com/probe")
  rules.append(rule1)

  // otherwise connect
  let rule2 = NEOnDemandRuleConnect()
  rule2.interfaceTypeMatch = .wiFi
  rules.append(rule2)

The probe returned a 200 OK when queried within the company network. Publicly the DNS entry was pointing to a S3 bucket which did not contain the probed resource.

NEOnDemandRuleConnect and probeURL

We use the NEOnDemandRuleConnect classes that are attached to a VPN configuration. The documentation of the probeURL attribute is fairly sparse:

var probeURL: URL?

An HTTP or HTTPS URL. If a request sent to this URL results in a HTTP 200 OK response and all of the other conditions in the rule match, then then rule matches. If this property is nil (the default), then an HTTP request does not factor into the rule match.

https://developer.apple.com/documentation/networkextension/neondemandrule/1405981-probeurl

Apple’s Configuration Profile reference adds:

A URL to probe. If this URL is successfully fetched (returning a 200 HTTP status code) without redirection, this rule matches.

Cache rules

With extensive testing and sniffing we discovered the following cache behaviour of iOS for probeURL:

  • 301 Moved Permanently is cached for ever
  • 200 OK is cached for ever, if it contains content, ETag and Last-Modified headers and no Cache-Control.

Once a response like this has been seen, iOS will not send another DNS or HTTP query and will use the result until the device is rebooted.

In the case of the 301, the rule will always not match (as iOS will not follow redirects). In the case of the 200 response, the rue will always match, no matter what network the device is currently on.

Unexpected caching behaviour

Obviously the result of an on-demand probe should never be cached beyond the network boundaries, as it defeats the probe pointless.

Further we were expected to see at least DNS requests for the resource when switching networks as we use Split-DNS. Assuming that a response is still valid when DNS returns a different IP address is surprising.

When a resource returns ETag and Last-Modified headers and no further Cache-Control headers, we expected clients to validate whether the resource has been changed (304 Not Modified) rather than it being cached without validation.

Cache-Control headers

Adding the following header to our 200 OK response, caused iOS to always request the resource when processing the on demand probes:

Cache-Control: no-cache, no-store, must-revalidate, max-age=0