Fundamentals

PAC files and WPAD

How FindProxyForURL works, every helper function, return-string syntax and fallback, WPAD discovery via DHCP 252 and DNS, and why dnsResolve leaks.

· 14 min read · How we verify this

Key points

  • A PAC file is JavaScript exposing one entry point, FindProxyForURL(url, host), which returns a semicolon-separated list of proxies the client tries in order.
  • DIRECT and PROXY host:port are universal; SOCKS5 and HTTPS are browser-specific and are ignored or rejected by several system HTTP stacks.
  • WPAD tries DHCP option 252 first, then DNS devolution for wpad.<domain>, and both paths are attacker-reachable on an untrusted network.
  • Every dnsResolve() or isInNet() call blocks proxy selection on a DNS lookup and leaks the hostname to the client's resolver, even for URLs that will be proxied.

A proxy auto-config (PAC) file is a JavaScript file served over HTTP that exports exactly one function, FindProxyForURL(url, host), which the client calls before every request to decide whether to go direct, through a proxy, or through a list of proxies to try in order. WPAD is the discovery mechanism that tells a client where that PAC file lives, using DHCP option 252 first and a wpad.<domain> DNS lookup second. Both are old, both are still ubiquitous in enterprise networks, and both have security and latency properties that are easy to get wrong.

Test any PAC logic you are about to deploy against real URLs with the PAC file tester before you push it to a fleet: a PAC file that throws an exception is not a soft failure, it typically drops the client to DIRECT or to no connectivity at all, for every request.

The FindProxyForURL contract#

javascript
function FindProxyForURL(url, host) {
  return "PROXY proxy.example.com:8080; DIRECT";
}
  • url is the full request URL as the client chose to expose it. It is not guaranteed to include the path. Chrome 52 and later strip the path and query from https:// URLs before passing them to the PAC, and Firefox exposes network.proxy.autoconfig_url.include_path for the same reason. Do not write policy that depends on the path unless you have verified it survives on every client in the fleet.
  • host is the hostname component only, with no port, lowercased by most clients.
  • The return value is a string. Returning undefined, null or throwing is a failure, and clients differ on whether that means direct connection or hard failure.
  • The function must be synchronous and side-effect free. There is no XMLHttpRequest, no fetch, no setTimeout and no DOM. The only I/O available is the DNS-backed helper functions.

Windows clients may instead call FindProxyForURLEx(url, host) if the PAC defines it, which allows the IPv6-aware Ex helpers to be used.

Return string syntax and client support#

Entries are separated by semicolons. Whitespace around them is tolerated. The client tries them left to right.

Return tokenMeaningSupport
DIRECTNo proxy, connect to the originUniversal
PROXY host:portHTTP proxy (plain HTTP to the proxy)Universal
SOCKS host:portSOCKS4Widely supported, rarely useful now
SOCKS4 host:portSOCKS4, explicitChromium, Firefox
SOCKS5 host:portSOCKS5Chromium, Firefox; not honoured by several OS stacks
HTTPS host:portProxy reached over TLSChromium; Firefox in current releases; not honoured by classic Windows WinINET/WinHTTP
QUIC host:portProxy reached over QUICChromium only

The practical consequence: HTTPS and SOCKS5 returns are a browser feature, not a platform feature. A PAC file that returns HTTPS proxy.example.com:443 will work in Chrome and silently fail or fall through in a .NET or Java client on the same machine, which is exactly the class of bug described in corporate proxies and developer tooling. If your fleet includes non-browser clients, keep PROXY in the list as a fallback:

javascript
return "HTTPS secure-proxy.example.com:443; PROXY proxy.example.com:8080; DIRECT";

Ports are mandatory for every entry except DIRECT. Omitting the port is one of the most common PAC authoring errors and clients do not agree on a default.

Fallback semantics#

When the first entry fails, the client moves to the next. Two details decide how that behaves in production:

  1. What counts as a failure is transport-level, not HTTP-level. A refused TCP connection, a connect timeout or a DNS failure for the proxy triggers fallback. An HTTP 403, 407 or 502 from the proxy does not: the proxy answered, so the client considers it working and surfaces the error to the application.
  2. Failed proxies are remembered. Chromium maintains a bad-proxy list with a backoff, so once a proxy in the list has failed it is skipped on subsequent requests until the backoff expires, rather than being retried on every request. This is why a proxy that flaps produces long tails of traffic pinned to the second entry even after it recovers.

Ending every branch with ; DIRECT is therefore a policy decision, not a safety net. It means "if the proxy is down, let traffic egress uncontrolled", which many security teams explicitly forbid. Ending with only proxies means an outage is a hard outage. Pick deliberately and document which one you chose.

Helper function reference#

FunctionWhat it actually doesDNS?
isPlainHostName(host)True if host contains no dot, so intranet matches and intranet.example.com does notNo
dnsDomainIs(host, domain)True if host ends with domain; it is a plain suffix comparison, so "evilexample.com" matches ".example.com" only if you include the leading dotNo
localHostOrDomainIs(host, hostdom)True if host matches hostdom exactly, or host is the unqualified part of itNo
isResolvable(host)Performs a DNS lookup and returns true if it succeededYes
isInNet(host, pattern, mask)Resolves host if needed, then tests the address against pattern/mask (dotted-quad mask, not CIDR)Yes, unless given an IP literal
dnsResolve(host)Returns the first resolved IPv4 address as a string, or nullYes
myIpAddress()Returns one local IP address of the machineSometimes
dnsDomainLevels(host)Counts the dots in hostNo
shExpMatch(str, shexp)Shell-glob match with * and ?, not a regular expressionNo
weekdayRange(wd1, wd2, gmt)Day-of-week test, optional "GMT" third argumentNo
dateRange(...)Overloaded date test, day, month, year or combinationsNo
timeRange(...)Overloaded time-of-day testNo
alert(msg)Diagnostic output; goes to chrome://net-export or the browser console, or nowhere at allNo
isResolvableEx, isInNetEx, dnsResolveEx, myIpAddressEx, sortIpAddressListIPv6-aware Microsoft extensions, usable only from FindProxyForURLEx on Windows stacksYes for the resolving ones

Two traps are worth calling out. isInNet takes a dotted-quad mask, so isInNet(host, "10.0.0.0", "255.0.0.0") is correct and isInNet(host, "10.0.0.0/8") is not. And myIpAddress() returns a single address chosen by the OS, which on a multi-homed or VPN-connected laptop is frequently the wrong interface, and on some configurations returns 127.0.0.1, making every isInNet(myIpAddress(), ...) branch evaluate false.

An annotated PAC file#

javascript
function FindProxyForURL(url, host) {
  // 1. Cheap string tests first. No I/O, no blocking.
  if (isPlainHostName(host)) {
    return "DIRECT";                       // single-label intranet names
  }

  // 2. Suffix matches. Note the leading dot: without it,
  //    "notexample.com" would match "example.com".
  if (dnsDomainIs(host, ".corp.example.com") ||
      dnsDomainIs(host, ".internal.example.com")) {
    return "DIRECT";
  }

  // 3. Glob matches for exceptions that are not a whole domain.
  if (shExpMatch(host, "*.cdn-partner.net") ||
      shExpMatch(url,  "http://build.example.com/*")) {
    return "DIRECT";
  }

  // 4. Literal IP destinations: isInNet does not resolve when
  //    given an address, so this branch stays DNS-free.
  if (shExpMatch(host, "*.*.*.*")) {
    if (isInNet(host, "10.0.0.0",   "255.0.0.0")   ||
        isInNet(host, "172.16.0.0", "255.240.0.0") ||
        isInNet(host, "192.168.0.0","255.255.0.0") ||
        isInNet(host, "127.0.0.0",  "255.0.0.0")) {
      return "DIRECT";
    }
  }

  // 5. Protocol split: only these schemes have a proxy path.
  if (url.substring(0, 5) === "ftp:/") {
    return "PROXY ftp-proxy.example.com:8080; DIRECT";
  }

  // 6. Everything else, with an ordered failover list and no
  //    DIRECT at the end: an outage must not become open egress.
  return "PROXY proxy-a.example.com:8080; PROXY proxy-b.example.com:8080";
}

The ordering is the design. Branches 1 to 3 are pure string comparisons that cost microseconds. Branch 4 only runs isInNet after a glob has confirmed the host is already an IP literal, so it never triggers a lookup. No branch calls dnsResolve on a hostname at all.

How clients cache PAC files#

There is no single answer, which is why "I updated the PAC and nothing changed" is a standing helpdesk category.

  • The PAC file itself is fetched over HTTP and, in most stacks, cached according to ordinary HTTP cache headers. Serving it with Cache-Control: max-age=300 and a correct Content-Type: application/x-ns-proxy-autoconfig is the reliable way to bound propagation time. Serving it with no caching headers hands the decision to a heuristic you do not control.
  • Clients also cache the result of evaluation, not just the file, typically keyed by host. A per-host result cache means a PAC change may not take effect for a host the client has already decided about, even after the file is re-fetched.
  • Re-evaluation is usually triggered by a network change (interface up/down, VPN connect, DHCP renew) rather than by a timer.
  • Browser restart clears both caches. So does toggling the proxy setting off and on, which is faster to instruct a user to do than explaining cache semantics.

Deploy PAC changes with a short max-age, and always keep the previous version reachable at a distinct URL so you can roll back by changing a DHCP option rather than by editing JavaScript under pressure.

WPAD discovery order and its security problems#

A client configured for "automatically detect settings" performs, in order:

  1. DHCP. It asks for option 252, a string containing the PAC URL (option 252 is a site-local convention rather than a standards-track allocation in RFC 2132). Any host that can answer DHCP faster than the real server can set it.
  2. DNS devolution. Failing that, it queries wpad.<its own domain> and then strips labels from the left: a host in dept.eng.example.com tries wpad.dept.eng.example.com, then wpad.eng.example.com, then wpad.example.com. Historically some clients devolved all the way to wpad.com.
  3. Name-resolution fallbacks on Windows, historically including NetBIOS and LLMNR, which resolve WPAD from anything on the local segment that chooses to answer. MS16-077 addressed this class by removing NetBIOS-based WPAD discovery and hardening the process.

The resulting problem set:

  • Rogue DHCP. An attacker on the LAN answering DHCP with option 252 pointing at their own PAC file redirects every proxied request through a host they control. The client applies it without any authentication of the source.
  • Name resolution spoofing. LLMNR and NetBIOS responders answer WPAD on the local segment. This is the mechanism behind the well-known credential-relay tooling that targets Windows networks.
  • Devolution past your own zone. If devolution reaches a domain you do not control, the PAC file comes from a stranger. CISA alert TA16-144A documented the "WPAD name collision" problem created when internal-only namespaces (.corp, and similar) became delegable in the public DNS, so queries that used to fail and stop began resolving to registered public names.
  • PAC files are code. The file is JavaScript executed by the browser's proxy resolver on every request. Even sandboxed, it sees every URL you visit (minus paths on modern browsers), which is a comprehensive browsing history feed to whoever serves it.

Why isInNet and dnsResolve make PAC evaluation slow#

Proxy resolution happens before the connection is made, so a blocking DNS lookup inside FindProxyForURL sits directly on the critical path of every new host the user visits. A single dnsResolve call on a cold cache adds a full resolver round trip; a PAC with three sequential isInNet(dnsResolve(host), ...) branches can add three. Chromium runs PAC scripts with its own asynchronous resolver and caches results, but the first evaluation for a host still waits.

The leak is the second half. dnsResolve(host) performs a lookup from the client for every host the user visits, including hosts that the PAC is about to hand to a proxy that would have resolved them itself. On a network where DNS is monitored or where the proxy exists specifically to keep clients from talking to the internet, that is a policy violation produced by the proxy configuration itself. It is the same failure the socks5 versus socks5h distinction causes at the SOCKS layer.

Rules that hold up:

  • Order branches cheapest-first: isPlainHostName, dnsDomainIs, localHostOrDomainIs, shExpMatch, then anything DNS-backed.
  • Gate isInNet behind a shExpMatch(host, "*.*.*.*") literal-IP test so it never resolves.
  • Never call dnsResolve twice on the same host; assign it to a variable once.
  • Keep the file small. Long chains of shExpMatch are still faster than one DNS call, but they run on every request, so a thousand-line PAC is measurable.
  • Match the PAC's bypass rules against the no_proxy rules used by command-line tooling on the same machines, because they are separate systems that drift; the no_proxy tester shows how differently implementations read those lists, and the no_proxy environment variable covers the syntax mismatches.

Failure modes#

Everything goes direct after a PAC deploy. A syntax error or a runtime exception in FindProxyForURL. Chrome shows ERR_PAC_SCRIPT_FAILED; capture the detail with chrome://net-export/. Validate the file before shipping.

"The proxy server is refusing connections" on Firefox only. Usually an HTTPS or SOCKS5 return token that this client version does not honour, or a missing port. Add a PROXY fallback entry.

Works in the browser, fails in curl, npm, pip and Docker. None of them read PAC files. They read HTTP_PROXY, HTTPS_PROXY and NO_PROXY. A PAC-only environment always needs a parallel environment-variable configuration for command-line tooling.

A PAC change takes effect for some users and not others. Result caching keyed by host, plus HTTP caching of the file itself. Shorten max-age, and note that a browser restart is the only reliable client-side flush.

Intermittent DIRECT on VPN-connected laptops. myIpAddress() returning the wrong interface, or isInNet(dnsResolve(host), ...) reading the split-horizon answer from the wrong resolver.

Authentication prompts appear only for some sites. The PAC is routing some hosts direct and others through a proxy that demands credentials. That is a PAC policy question, not an authentication bug; see proxy authentication for what the 407 path actually requires.

Frequently asked questions#

What is a PAC file?#

A PAC file is a JavaScript file containing a FindProxyForURL(url, host) function that a client calls before each request to decide which proxy to use. It returns a string such as "PROXY proxy.example.com:8080; DIRECT", listing candidate proxies in the order the client should try them. It is served over HTTP with the content type application/x-ns-proxy-autoconfig.

What is the difference between PAC and WPAD?#

PAC is the file format and evaluation contract. WPAD is the discovery protocol that finds the PAC file's URL automatically, first via DHCP option 252 and then via DNS lookups for wpad.<domain>. You can use a PAC file without WPAD by configuring its URL explicitly, which is the safer deployment.

What does FindProxyForURL return?#

A semicolon-separated list of directives. DIRECT means connect without a proxy, PROXY host:port means use an HTTP proxy, and SOCKS, SOCKS4, SOCKS5, HTTPS and QUIC name other proxy types with varying client support. The client tries entries left to right and falls through on transport-level failure only.

Why is my PAC file slow?#

Almost always DNS. dnsResolve, isResolvable and isInNet on a hostname each perform a blocking lookup on the proxy-selection path, before any connection is opened. Reorder so that string-only predicates run first, and only call isInNet on values that are already IP literals.

Is WPAD a security risk?#

Yes, on any network you do not control. Both discovery paths are unauthenticated: a rogue DHCP server can supply option 252, and local name resolution can answer for the wpad label. The PAC file it delivers is executed by the client and sees the URLs the user visits. Disable auto-detection and configure the PAC URL explicitly.

Does curl support PAC files?#

No. curl has no PAC or WPAD support and uses the http_proxy, https_proxy, all_proxy and no_proxy environment variables plus its own command-line options. Environments standardised on PAC must maintain an equivalent environment-variable configuration for command-line tools, and the two sets of rules drift apart over time.

How do I test a PAC file without deploying it?#

Evaluate FindProxyForURL against a list of representative URLs, including internal names, external names, IP literals and each scheme you care about. The PAC file tester runs the function and shows the returned directive per URL, which catches missing ports, inverted dnsDomainIs suffix tests and exceptions before they reach a fleet.

Can a PAC file return an HTTPS proxy?#

HTTPS host:port is supported by Chromium and by current Firefox releases, and it means the connection to the proxy itself is TLS-protected. It is not honoured by the classic Windows HTTP stacks or by many non-browser clients, so include a PROXY entry after it if anything other than browsers reads your PAC.

Primary sources#

Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.

  1. MDN Proxy Auto-Configuration (PAC) file
  2. Microsoft WinHTTP AutoProxy Support (WPAD)
  3. RFC 2132 DHCP Options and BOOTP Vendor Extensions
  4. CISA alert TA16-144A, WPAD Name Collision Vulnerability
  5. Microsoft Security Bulletin MS16-077, WPAD elevation of privilege
  6. Chromium proxy settings and PAC support
  7. Mozilla network.proxy preferences

Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.

More in proxy fundamentals#