SimpleURLFilter makes use of the NEURLFilterManager to demonstrate managing URL filtering configurations.
URL filtering is achieved as a two step process in which request URLs are rapidly checked against local bloom filter data, and in the case of a potential match, a configured Private Information Retrieval (PIR) server is subsequently queried. PIR server data is cached locally, and is refreshed on a regular schedule. This results in a very fast filtering implementation, preserving user privacy, which is robust and continually refreshed with current filtering criteria. This mechanism is very similar to the Live Caller ID Lookup capabilities, so many of the concepts and technology overlap.
The bloom filter pre-filter is provided to the system through the use of an app extension protocol defined by NEURLFilterControlProvider.
NEURLFilterManager provides the means to manage the URL filtering configuration, including the PIR server URL, cache management, and refresh timing. Filtering status and configuration updates are available through async sequences.
This SimpleURLFilter sample application utilizes the interface presented by NEURLFilterManager to configure and manage URL filtering capabilities, and includes an implementation of the NEURLFilterControlProvider protocol in an embedded application extension. The extension implementation delivers bundled bloom filter data to the system.
Open the sample code project in Xcode. Before building it, do the following:
-
Set the developer team for all targets to your team so Xcode automatically manages the provisioning profile. For more information, see Assign a project to a team.
-
Optionally build and run the PIR server sample (or have your own server ready for use).
A sample PIR Server service and configuration is included in the "PIR Server" directory. Please refer to the README.md.
To see the sample app in action, use Xcode to build and run the app on your iOS device.
Running the sample project on device will allow you to configure and apply a URL filter.
The main view of the application shows status information about the currently installed filter, if there is one, and transient status messages from the application as various actions are performed.
Two main butons are prominent. The "Enable"/"Disable" button is a quick way to enable or disable the current filter configuration and is equivalent to saving the entire configuration after changing the isEnabled flag on the manager. The "Configure" button presents an interface to view and change the filter properties, and then apply them.
Within the configure interface are fields to enable/disable the configuration, specify a URL for the PIR server, optionally specify a URL for the Privacy Pass server, the Authentication token used with the PIR server, a picker to choose the pre-filter fetch frequency interval, and a toggle treat failures in the system as allowing or disallowing the requested URL by default.
The Privacy Pass server URL is optional in this context and if not specified, the system will assume the configured PIR server URL handles this responsibility as well (which is true of the sample server).
For example, if you decide to use the sample PIR server and have it running, you would:
- Toggle the "Enabled" switch to the on state.
- Set the "PIR Server URL" to
http://localhost:8080 - Leave the "PIR Privacy Pass Issuer URL" empty.
- Populate the "Authentication Token" with
AAAA(as indicated in theservice-config.jsonfile). - Leave the "Pre-filter Fetch Frequency" at the default setting of "45 minutes"
- Toggle the "Fail Closed" switch to the on state.
Then use the "Apply" button to save these configuration changes and start the filter.
At this point you should be prompted to allow the filter, and if you choose to proceed you will enter the device password, and be taken into Settings to verify. You can then return to the application to check on the filter status and perform other actions.
The main interface to the NEURLFilterManager for this sample is the ConfigurationModel class in the ConfigurationModel.swift file. This is @Observable so changes here can allow the user interface to react.
The NEURLFilterManager is addressed through a Singleton presented via the static shared property, and maintains state internally for the filter configuration.
The configuration state of the NEURLFilterManager must be loaded from the system by calling loadFromPreferences() and the system needs to be informed of any changes made to the configuration state by calling saveToPreferences(). Additionally, the configuration can be removed from the system by calling the removeFromPreferences() function.
The setConfiguration(pirServerURL:pirPrivacyPassIssuerURL:pirAuthenticationToken:controlProviderBundleIdentifier:) function is used to set the required configuration properties, but you likely will also want to set values for the prefilterFetchInterval, shouldFailClosed, and isEnabled properties. Once configured as desired, be sure to call saveToPreferences() to inform the system of the changes.
Once an enabled configuration is set and saved the system will attempt to place it in a running state. You can directly query the filter state through the NEURLFilterManager status property, or use the handleStatusChange() async sequence API to be informed of status updates. Should the state be in an unexpected error state you can query the lastDisconnectError property for the most recent error, keeping in mind this error represents the most recently available error and may be a remnant from a previous issue, if nothing has caused a more recent error to overwrite it.
This implementation includes fixes for critical bugs in the original sample code:
-
murmurSeed Overflow: The original code read
murmurSeedasInt, causing overflow for large unsigned values. Fixed by reading asUInt32usingNSNumber.uint32Value. -
Unsafe Optional Handling: The original code used optional chaining (
bitVectorData?.write()), which fails silently ifbitVectorDataisnil. Fixed by usingguard letfor safe unwrapping.
These fixes are in SimpleURLFilterExtension/URLFilterControlProvider.swift and are essential for the extension to function correctly.
The PIR server must be configured with your app's bundle identifier plus the .url.filtering suffix:
- Open
PIR Server/service-config.json - Update the
namefield in theusecasesarray:"name": "com.your.bundleid.url.filtering"
- Rebuild the PIR server (see below)
The bloom filter must contain bare domains (without schemes). iOS normalizes URLs internally before checking the filter.
Prerequisites:
- Install the swift-bloom tool:
git clone https://github.com/adamleozar/swift-bloom && cd swift-bloom && swift build -c release
Steps:
-
Create a text file with one bare domain per line:
example.com www.example.com badsite.com -
Generate the bloom filter:
.build/release/BloomFilterBuilder build \ --input-path domains.txt \ --false-positive-tolerance 0.01 \ --output-path SimpleURLFilter/SimpleURLFilterExtension/bloom_filter_v3.plist
-
Update
URLFilterControlProvider.swiftif you change the filename:let filterPlistFileName = "bloom_filter_v3"
-
Rebuild the iOS app in Xcode
Important: The bloom filter and PIR database must contain the same domains. iOS extracts the domain from full URLs (e.g., https://example.com → example.com) before checking.
After modifying PIR Server/data/input.txtpb, rebuild the Docker container:
cd "PIR Server"
docker compose down
docker compose build --no-cache
docker compose up -dThe --no-cache flag ensures the new database is processed.
Database Format:
rows: [{
keyword: "example.com",
value: "1"
}]
Use bare domains only (no https:// prefix).
The app includes a synthetic test to verify URL filtering behavior:
- Run the app on your iOS device
- Open the menu (wrench icon in top-right)
- Select "Test URL Verdicts"
- View results in the alert and in Console.app logs
Expected behavior:
- First query to each unique domain: ~100-250ms (PIR query)
- Subsequent queries: ~1-3ms (cached)
- URLs matching bloom filter domains: DENY
- URLs not in filter: ALLOW
Debugging:
- Filter logs with
SimpleURLFilterin Console.app - Check PIR server logs:
docker compose logs -f pir-server
Error 14 (Extension Won't Start):
- Ensure
murmurSeedandbitVectorDatafixes are in place - Clean build (
Cmd+Shift+Kin Xcode) - Delete app from device and reinstall
- Clear Xcode Derived Data
URLs Not Blocking:
- Verify bloom filter and PIR database contain the same domains
- Use bare domains (not full URLs)
- Check PIR server is running:
docker ps - Check PIR server logs for "usecase not found" errors
- Reset PIR cache in app (wrench menu → "Reset PIR Cache")
- Toggle filter OFF → ON
PIR Server "Usecase not found":
- Verify bundle ID in
service-config.jsonmatches your app +.url.filtering - Rebuild PIR server with
--no-cache
State Corruption: Perform a "nuclear cleanup":
- Delete the configuration in the app
- Delete the app from device
- Restart the iPhone
- Clear Xcode Derived Data
- Rebuild and reinstall
iOS performs automatic URL normalization for bloom filter checks:
- Input:
https://example.com/path - Normalized:
example.com - Bloom filter lookup:
example.com - PIR query:
example.com
This means:
- Bloom filter should contain:
example.com - PIR database should contain:
example.com - iOS handles the rest automatically