Your default url handler is a security issue
Taking ownership of macOS URL handling lets unknown links leave the work machine before they reach a privileged browser session.
- Published
- Updated
- Reading time
- 11 min read
- Series
- Dotfiles
I maintained a bunch of small things in my dotfiles in February 2025. I improved language-server support, added a helper for debugging Elixir releases, restored some Go setup, and adjusted how links open on my Mac.
That last change is the one I keep wondering about. Why is everyone who takes workstation hardening seriously not also taking ownership of URL handling?
Think about how many incursions begin on a work computer. A message arrives. It contains a link. The domain looks plausible, the sender appears familiar, or the subject creates just enough urgency. One click transfers an attacker-controlled string through the operating system into a large, networked application containing authenticated sessions, saved permissions, extensions, downloads, and access to local files.
MITRE ATT&CK calls this User Execution through a Malicious Link. A related path, Drive-by Compromise, begins when a browser visits an attacker-controlled or compromised website. CISA’s phishing guidance treats the link as a central initial-access mechanism.
For years, that path nagged me. In February, I finally dealt with it at the point before a browser opens: OS-level URL handling.
A click is a dispatch decision
On macOS, applications hand URLs to the operating system. Launch Services resolves the registered handler for a scheme such as http or https, then delivers the URL to that application. Normally, the handler is Safari, Firefox, Chrome, or another browser.
That default optimizes for convenience. It assumes that every link arriving from Mail, Messages, Slack, a terminal, a PDF reader, or a password manager should immediately enter the same browser environment.
My browser is not an empty document viewer. It carries work sessions. It can reach administration panels, source hosts, cloud consoles, invoicing, internal tools, and identity providers. Even with MFA, a live browser session represents authority. Sending every unknown URL directly into that context is an unnecessary default.
I consider the OS-level handoff a trust boundary. Before dispatch, the URL is data. After dispatch, a browser begins resolving, connecting, parsing, rendering, and possibly downloading. I want my own policy between those states instead of accepting one global browser choice as permanent.
One important limit: this boundary governs links handed to macOS. It does not intercept every navigation after I am already inside a browser. A malicious page opened from an allowlisted local page can still navigate elsewhere. URL brokering reduces one exposure path; it does not turn the browser into a safe viewer.
What can go wrong after default dispatch
A routing layer cannot know whether a page is benign. It can still reduce several kinds of risk.
Phishing meets an authenticated session
The most common danger is not a browser sandbox escape. It is a convincing login page, consent screen, OAuth flow, or support prompt opened in exactly the environment where I normally authenticate.
Separating unknown links from my work browser removes familiar cookies and active sessions from the first interaction. The isolation browser can still be phished, but it begins with less authority. A fake page also looks less natural when it appears in a deliberately untrusted environment.
Browsers process hostile input
A URL can lead to exploit code, a compromised legitimate site, malicious advertising, or a download crafted for the next stage of an attack. Modern browsers have strong sandboxes, and macOS adds quarantine, Gatekeeper, and notarization checks. None of these controls is a reason to place the browser next to every valuable host resource if I can avoid it.
Moving unknown browsing into a VM adds another containment boundary. A browser compromise must then escape both browser and guest—or abuse an integration channel—before reaching the work host.
Links can cross application boundaries
Web pages and messages may point to non-web schemes that launch other applications. URL-scheme handling has a long history of surprising behavior because the receiving app decides what the payload means.
A broker can reject schemes it does not expect and ensure that its fallback only forwards http and https. This does not secure every registered macOS handler; it narrows what this particular path accepts.
The URL itself leaks information
Tracking parameters can identify the recipient, campaign, account, or document. Some routing tools can remove known parameters or rewrite hosts before opening. That is primarily privacy hardening, but less unnecessary context reaching a remote server is also useful security hygiene.
Profiles collapse trust zones
Personal browsing, client systems, development tools, and privileged administration do not need the same browser profile. Routing known domains into deliberate profiles reduces accidental credential use and keeps extensions, cookies, and history from becoming one large trust zone.
What a URL broker can—and cannot—prevent
A broker can block an unexpected scheme, prompt before dispatch, rewrite a URL, route a known domain to a specific browser profile, or send everything unknown to an isolated machine. It replaces one global default with an explicit policy.
It cannot inspect a page and declare it safe. It cannot guarantee an allowlisted domain has not been compromised. It cannot stop a phishing link clicked from inside an already-open browser. It cannot compensate for an unpatched browser, weak identity controls, or a VM configured with broad host access.
The claim I am comfortable making is smaller: taking ownership of URL dispatch can reduce my exposure and the possible blast radius. More importantly for me, it turns “open every link here” from an ambient assumption into a decision I can inspect and change.
The macOS URL-router landscape
There are many browser pickers and routers. This is the credible landscape I found while revisiting the setup, not a claim that no small utility exists outside the list.
| Tool | Approach | Useful distinction |
|---|---|---|
| Finicky | JavaScript rules and rewrites | Open source and configuration-as-code; my choice |
| Choosy | Prompting and configurable rules | Mature commercial “smarter default browser” |
| Velja | Native picker and routing | Polished Mac utility with tracking-link conveniences |
| Browserosaurus | Prompt on each link | Open-source browser chooser; repository was archived when checked |
| OpenIn | Rules across browsers, profiles, mail, and apps | Broad GUI-managed routing |
| Bumpr | Click-time browser or mail-app choice | Simple, deliberate chooser |
| Switchbar | Browser, profile, and email-client picker | Supports macOS and Windows |
| Yojam | Rules for browsers, apps, and profiles | Newer open-source native option |
I also found smaller open-source implementations including Bowzer, multi_browse, Punt, LinkGate, and Usher. Their maturity, maintenance, and licensing vary. Browser routers are popular small projects; evaluate the code that will receive every external URL on your machine.
I could have stopped at a graphical picker if I only wanted to choose between browsers. I wanted the policy in my dotfiles, and I wanted an arbitrary command to be a valid destination. Finicky gave me the useful base without forcing the final shape of the solution.
My policy: known local, unknown elsewhere
I made Finicky the default http and https handler. Its rules send a small set of expected work destinations to local Safari. The default route does the opposite of a normal browser configuration: everything not explicitly matched goes to SafeBrowser.
A simplified version looks like this:
module.exports = {
defaultBrowser: {
name: "SafeBrowser",
appType: "command",
command: "/Users/me/.bin/safebrowser",
},
handlers: [
{
match: finicky.matchHostnames([
/.*\.?github\.com$/,
/.*\.?example-work-service\.com$/,
]),
browser: "Safari",
},
],
};
I care more about the default than the allowlist. New and misspelled domains should not silently inherit local trust just because I have not classified them yet. I prefer them to leave the host until I deliberately add a rule.
I do not trust loose hostname matching here. A substring test for github.com could also match github.com.attacker.example. Finicky parses the URL and provides hostname matchers, but I still anchor and review my patterns. Internationalized domains, redirects, URL shorteners, and compromised subdomains are why I never want a matching hostname to become synonymous with safety.
Do not run the untrusted browser on the work machine
I considered a separate local browser profile, and for many workflows that may be enough. It still shares the kernel, filesystem permissions, browser installation, and many host integrations. For unknown links, I prefer paying the extra operational cost of a VM in exchange for a stronger boundary.
My fallback browser runs on another macOS machine represented as a VM endpoint. SafeBrowser is a short AppleScript executable:
#!/usr/bin/osascript
on run argv
set remoteMachine to first item of ¬
(read (POSIX path of (path to home folder as string) & ¬
".config/vm1.local/eppc") using delimiter "\n")
tell application "Finder" of machine remoteMachine ¬
to open location (item 1 of argv)
end run
The script reads the Remote Apple Events address from a private file and asks Finder in the guest to open the URL. The address and credentials do not belong in dotfiles or logs.
There are other ways to deliver a URL to a browser VM:
- SSH with a forced guest command that validates one URL argument and calls
openorxdg-open; - an authenticated listener on a host-only network;
- a one-way queue consumed by the guest;
- a hypervisor guest-control command.
If I replaced Remote Apple Events today, I would probably begin with SSH because it is portable and already understood. I would not construct ssh guest "open $url" and let an attacker-controlled URL become shell text. I would pass encoded data or a separate argument into a narrowly constrained command, validate the scheme and length, and avoid a general-purpose remote shell key.
I am willing to sacrifice convenience to preserve this boundary. Shared clipboard, drag-and-drop, mounted host directories, saved work credentials, broad Remote Apple Events access, and guest-control tools all create paths back toward the host. I disable what the browser does not need, keep the guest patched, and prefer snapshots or disposable instances when practical. I treat the host-to-guest opener as security-sensitive software, not harmless desktop glue.
The practical hardening checklist
My implementation is small, but its policy is explicit:
- Register a broker—not a browser—as the macOS handler for
httpandhttps. - Keep local routing rules narrow and anchored to parsed hostnames.
- Make the unknown case the isolated case.
- Accept only intended schemes at the forwarding boundary.
- Pass URLs as arguments, never interpolated shell code.
- Keep router configuration and scripts versioned in dotfiles.
- Protect the router config, executable, and remote-control credentials from untrusted writes.
- Remove VM integrations that contradict the isolation goal.
- Do not place valuable authenticated sessions in the untrusted browser.
- Test known, unknown, malformed, shortened, and non-HTTP URLs.
There is still judgment in the allowlist. A locally opened service can link to the rest of the web. A trusted domain can be compromised. The configuration is a reduction of implicit trust, not a declaration that certain hostnames are permanently harmless.
Take ownership before the browser starts
The security industry spends enormous effort hardening browsers after they receive a URL. That work is essential. I had paid far less attention to the decision that sends a URL into a particular browser in the first place.
Making Finicky the OS handler gave me a policy point. Adding a script destination let that policy cross the machine boundary. Running unknown links in a VM kept routine convenience while moving hostile parsing and low-trust sessions away from my work environment.
A link is not harmless because it is small enough to fit in a message. It is an instruction to begin a network interaction in some security context. The operating system already asks which application should receive that instruction. I would rather answer with code than with one global default I stopped questioning years ago.