# --host-resolver-rules: the first match wins

> Chrome's --host-resolver-rules tries MAP rules in order and stops at the first match. EXCLUDE is not ordered. A wildcard put first ate our extension's API call.

Source: https://mosaicbrowser.com/blog/host-resolver-rules-first-match-wins

## --host-resolver-rules: the first match wins

Chrome's --host-resolver-rules tries MAP rules in order and stops at the first match. EXCLUDE is not ordered. A wildcard put first ate our extension's API call.

![MAP: The name that has to survive goes first](https://mosaicbrowser.com/covers/host-resolver-rules-first-match-wins.webp)

Chrome's `--host-resolver-rules` switch takes a comma-separated list of `MAP` and `EXCLUDE` rules. Chromium reads the `MAP` rules top to bottom and stops at the first pattern that matches the hostname, so their order is the whole behaviour. `EXCLUDE` has no position. Chromium consults every one of them each time a `MAP` matches. We found out about the first half from our own extension, whose API call died behind a `MAP * ~NOTFOUND` we had put ahead of its name.

## The two rule types

The grammar is short. In Chromium's `net/base/host_mapping_rules.cc`:

```
MAP <hostname pattern> <replacement host>[:<port>]
EXCLUDE <hostname pattern>

```

Patterns take `*` as a wildcard. A replacement of `~NOTFOUND` means the name does not exist, so the request fails with `ERR_NAME_NOT_RESOLVED`. Rules are joined with commas, spaces around them are allowed, and the keywords are case-insensitive.

`MAP * ~NOTFOUND` is the rule people come for. Chromium's SOCKS proxy documentation gives it as the structural fix for DNS leaks, with an `EXCLUDE` for the proxy's own host, and our [post on DNS prefetch leaks](https://mosaicbrowser.com/blog/dns-prefetch-and-proxies) explains why: a name lookup has no way through an HTTP or SOCKS proxy, so the only safe local lookup is one that fails. Page loads are unaffected, because Chrome already hands the hostname to the proxy, SOCKS5 included ([socks5 and socks5h in Chrome](https://mosaicbrowser.com/blog/socks5-and-socks5h-in-chrome) has the difference from curl).

## How Chromium evaluates MAP and EXCLUDE

`RewriteHost` is about twenty lines, and reading it settles every ordering question:

1. Walk the `MAP` rules in the order given.
2. Skip a rule unless its pattern matches the hostname, or `host:port`.
3. On the first one that matches, check every `EXCLUDE`. If any matches the hostname, stop and leave the name alone.
4. Otherwise rewrite to that rule's replacement and stop. No later `MAP` is looked at.

Three things follow. A specific `MAP` has to come before a wildcard, or it never runs. An `EXCLUDE` can go anywhere, and the common `MAP * x, EXCLUDE y` works for that reason. And an `EXCLUDE` pattern is compared with the hostname only, never with a port.

Mosaic is a desktop app

Email yourself the link and open it on your computer.

[How it works: Its own Chromium](https://mosaicbrowser.com/features/engine)

## The call that came back "Failed to fetch"

Every Mosaic profile carries a companion extension. Its popup shows the profile's notes, logins and two-factor codes, and it gets them by calling Mosaic's local API from inside the profile's browser. That call goes out under the same network switches as any page, and those switches exist to keep pages away from the machine.

The proxy caught it first. We launch with `--proxy-bypass-list=<-loopback>`, which sends even `127.0.0.1` through the proxy so that a page cannot reach the operator's computer. A residential gateway will not carry a request to loopback, and this one answered with a status of its own making:

```
464  Connection to the specified target is not permitted due to security policies.

```

464 is not a standard status and nothing in our code emits it, so searching our repository for it found nothing. The fix was a random `*.localhost` name per launch for the companion, named in the bypass list. RFC 6761 makes `.localhost` loopback, and Chromium treats it as potentially trustworthy, which an arbitrary `http://` host is not.

The popup still failed. This time it said `TypeError: Failed to fetch`, with no log line anywhere, and it looked as if the first fix had not worked. It had. The second wall was the resolver. With "Resolve DNS through the proxy only" on, the engine adds `MAP * ~NOTFOUND` so nothing resolves behind the relay's back. The wildcard matched the companion's name first, and that was the end of it.

## What we launch with now

Today the companion's host gets its own `MAP` to `127.0.0.1`, written before the wildcard:

```
--host-resolver-rules=MAP <companion>.localhost 127.0.0.1 , MAP * ~NOTFOUND , EXCLUDE 127.0.0.1

```

It is built in `src/main/engine/args.ts`, and the comment beside it says why it is a `MAP` and not an assumption. RFC 6761 says the name is loopback and Chromium implements that, but a rule that states it costs one switch and cannot be wrong. An `EXCLUDE` of the companion host would also have got the name past the wildcard, and would then have relied on the resolver doing the right thing with `.localhost`. We would rather the rule say so.

Three settings decide whether a request leaves the browser, and the name appears in all of them. The bypass list carries it when a profile has a proxy. The PAC script carries it as its first rule when a profile has none. The resolver rules carry it ahead of the wildcard. `npm run check:companion-reach` runs in our build and fails if the three spell different names, or if a launch without the companion exempts anything at all. Before trusting it we put the proxy half of the fault back, then the resolver half, and saw it fail on each.

After both fixes the popup's `GET /profiles/<id>` came back 200 on a profile with a residential proxy and DNS through the proxy, and 200 on a profile with no proxy at all. Then we tried the same doors from an ordinary page on `https://example.com`. With no proxy, `127.0.0.1`, the LAN and the companion's host all stayed silent. With the proxy, `127.0.0.1` got the gateway's refusal back in 270 ms and nothing more.

## One more place the switch shows up

Chromium treats `--host-resolver-rules` as an unsupported flag and says so in a yellow band: "You are using an unsupported command-line flag", followed by the switch itself. Ours printed the companion's mapping, random hostname and all, across the top of every window. We had always passed `--disable-infobars`. In Chromium's `DisableInfoBars()` that switch only counts alongside `--headless`, unless the build is branded Chrome for Testing, so in a headed profile it was read and ignored. Patch 0140 in our series removes the headless condition. Bars with a button still show, because Chromium only ever suppresses the buttonless kind.

For your own launches, the order is specific `MAP` rules, then `MAP * ~NOTFOUND`, then any `EXCLUDE`. Test the name that must survive from a page inside the browser. `curl` in a terminal resolves through the operating system and never sees the browser's switches, so it will report success for a rule the browser applies differently. And start watching before the browser starts: Chrome opens connections during launch, which is the subject of [Chrome preconnects before you measure](https://mosaicbrowser.com/blog/chrome-preconnects-before-you-measure).

## Questions

### What is the syntax of --host-resolver-rules?

A comma-separated list of rules. MAP <pattern> <replacement> rewrites matching hostnames, with an optional port, and ~NOTFOUND as the replacement makes the name fail to resolve. EXCLUDE <pattern> stops a hostname being rewritten by any MAP.

### Does the order of rules in --host-resolver-rules matter?

For MAP rules, yes. Chromium tries them in the order given and uses the first one whose pattern matches, so a wildcard placed before a specific rule wins. EXCLUDE rules are checked whenever a MAP matches, wherever they sit in the list.

### What does MAP * ~NOTFOUND do?

It makes every hostname fail to resolve through Chrome's own resolver, with ERR_NAME_NOT_RESOLVED. Behind a proxy that resolves names at the exit, page loads still work and any local lookup fails instead of leaking.

### Does EXCLUDE match a port?

No. In Chromium's source an EXCLUDE pattern is compared with the hostname only. A MAP pattern can also match host:port.

## Related

[DNS prefetching, the lookup that skips your proxy](https://mosaicbrowser.com/blog/dns-prefetch-and-proxies): Chrome's DNS prefetcher resolves names through your own resolver even with a proxy set. Mapping every name to NOTFOUND makes the leak fail closed.

[Chrome opens connections before your test starts](https://mosaicbrowser.com/blog/chrome-preconnects-before-you-measure): We counted 1, 0 and 0 CONNECTs in three launches and thought Chrome was bypassing the proxy for HTTPS. Our counter was reset after Chrome had started.

[socks5 and socks5h: where Chrome resolves the name](https://mosaicbrowser.com/blog/socks5-and-socks5h-in-chrome): In curl, socks5h sends the hostname to the proxy and socks5 resolves it locally. Chrome's socks5 already sends the name; the leak is elsewhere.

[What your proxy provider can and cannot see](https://mosaicbrowser.com/blog/what-a-proxy-provider-can-see): Over HTTPS a proxy provider sees each hostname, port, time and byte count, never the pages or passwords. Over plain HTTP it sees everything you send.









## Mosaic, on your own computer

The browser this blog is about: its own Chromium, fingerprints from real machines and a proxy exit per profile. Free holds 3 profiles. Pro is $49 a month for any number.

Mosaic is a desktop app

Email yourself the link and open it on your computer.
