* * deny — ~/blog/pac-https-path-stripping

Why your PAC file's URL rules stopped working for HTTPS

the pac series — part 3 of 4 1 · mistakes that break user traffic · 2 · writing your first PAC file · 3 · https path stripping · 4 · watching it decide · the tester

Somewhere in a production PAC file near you, there's a rule that matches part of a URL — an /admin/ path, a keyword, a query string. It was tested when it was written. It worked. And at some point in the last several years it silently stopped doing anything for the vast majority of your traffic, without one error, one alert, or one changed line in the file.

This is the story of how browsers quietly took the URL away from your PAC file, why they were right to do it, and what to do about the rules that never got the memo.

what your PAC file actually sees now

You'd expect FindProxyForURL(url, host) to receive the URL the user requested. For http:// traffic, it still does. For https:// — the overwhelming majority of your traffic — modern browsers hand your PAC file this instead:

// what the user requested:
https://portal.corp.example/admin/console?session=8f3a...

// what FindProxyForURL(url, host) is actually given:
url  = "https://portal.corp.example/"
host = "portal.corp.example"

Path: gone. Query string: gone. Fragments and embedded credentials: also gone. Per Chromium's own proxy documentation, what survives is a scheme://host:port/ URL — so scheme, hostname, and port are the whole input surface (port rules still work; don't rip those out). Any rule that inspects url beyond that — shExpMatch(url, "*/admin/*"), url.indexOf("download"), a query-parameter check — is dead code for HTTPS traffic, and it fails the worst way possible: by never matching, so the request falls through to whatever rule sits below it.

why browsers did this

It was a deliberate privacy fix, and honestly, a correct one. A PAC file is remotely-fetched code that gets consulted for every request — and historically it received full URLs, including paths and query strings that carry session tokens, document names, and search queries. Combine that with WPAD auto-discovery (where a client on a hostile network can be handed a malicious PAC file automatically) and you have a URL-exfiltration machine. This wasn't theoretical — it was demonstrated on stage: "Crippling HTTPS with Unholy PAC" (Itzik Kotler & Amit Klein, Black Hat USA 2016), alongside the badWPAD research on WPAD-domain squatting. Browser vendors responded by cutting off what PAC scripts can see. And to kill a common misreading: the stripping applies to every PAC, including the one you explicitly configure — WPAD just made the urgency obvious.

The timeline, in Chrome's case (per MDN and Chromium's docs):

  • Chrome 52 (2016): stripping becomes the default, with an enterprise policy (PacHttpsUrlStrippingEnabled) or the --unsafe-pac-url flag to disable it.
  • Chrome 74: the policy is removed; only the flag remains.
  • Chrome 75 (2019): the flag is gone. There is no way to disable it.

Two scope details that matter: it's not just https:// — everything except http:// gets sanitized, including wss://, so secure-WebSocket routing rules are dead code too. And Firefox strips by default as well, though it still has an about:config escape hatch (network.proxy.autoconfig_url.include_path) that Chrome no longer offers. Chromium-based Edge inherits Chrome's behavior — with one enterprise-shaped exception worth testing yourself: IE mode resolves proxy through the old WinINet stack, which reportedly does not sanitize, meaning one Edge browser can feed your PAC stripped URLs from normal tabs and full URLs from IE-mode tabs.

Was it the right call? For privacy, yes — though it's also a case study in breaking a documented interface with no runtime signal, which is exactly why posts like this one have to exist. Note the asymmetry that confuses every test plan: http:// URLs still arrive intact — so a quick test against an http site "proves" the rule works right before it does nothing in production. Don't build new logic on http paths either; Chromium has signaled interest in aligning http eventually.

the symptoms in the wild

  • The path-based exception that stopped excepting. Routing */admin/* to an audit proxy, sending a /download/ path DIRECT for performance — for HTTPS destinations, those requests now take the same path as everything else on that host, and nobody notices until something depends on the difference.
  • "It works in the lab." The rule matches when tested against http:// URLs or in an old PAC debugger that passes full URLs — and silently no-ops in every modern browser.
  • Fleet inconsistency. A single Windows endpoint can run at least four PAC consumer classes: browsers (stripped), WinHTTP/WinINet services and .NET apps (their own engines), Edge IE-mode tabs (see above), and SSE client connectors that evaluate the PAC themselves — and what those pass is per-product, so verify before assuming. The same PAC file behaves differently per application, which is a miserable thing to discover mid-incident.

check if you're affected

Grep your PAC file for its first parameter — every place url is used in a condition, ask whether the match needs anything past scheme, host, or port. Or paste the file into the PAC File Tester: the linter flags URL-path rules as dead code for HTTPS, and the trace runs your test URL through both behaviors — modern (stripped) and legacy (full URL) — and warns you when the routing decision differs.

To see it live on a real browser: capture a chrome://net-export log and read the proxy-resolution events — they show the exact sanitized URL your PAC received (and any alert() output from the PAC lands in the same log). Firefox-side, run with MOZ_LOG=proxy:5. (Safari evaluates PAC through CFNetwork — I haven't verified its behavior; test before assuming either way.)

what to do instead

Match on host (and port), not path. Hostname and port still arrive intact — they're the stable identity a PAC decision can hang on. If two application areas need different routing, the real fix is giving them different hostnames (portal.corp.example vs admin.corp.example). Be honest about the cost — that's a DNS entry, a certificate, and an app-team conversation, not a PAC edit — but it's a fix that works, versus a rule that can't. (Path-splitting one HTTPS host across proxies was fragile even before stripping — HTTP/2 connection reuse never respected your rule boundaries.)

Do path-level policy at the proxy, not in the PAC. This one matters for SSE shops: your secure web gateway sits after TLS inspection and sees the full decrypted URL — path, query, everything. URL-filtering policy, path-based blocks, tenant restrictions — that logic belongs in the platform's policy engine, not in the PAC. One honest caveat: this only holds where inspection is actually on. For bypassed or cert-pinned categories, nobody sees the path — the real answer there is that path-level policy doesn't exist for those flows.

Scheme checks still work. The scheme survives sanitization, so url.substring(0, 6) === "https:" or a wss: scheme test is fine. (Retire the classic ftp:* example, though — browsers removed FTP support entirely back in 2021, so that rule is archaeology.)

Document the per-stack difference. If you must keep a URL-path rule for a legacy non-browser consumer, comment it loudly: this rule only fires for clients that pass full URLs — browsers will skip it. The next engineer will assume it works everywhere; the comment is cheaper than the incident.

the bigger lesson

The PAC file's input surface shrank and almost nobody re-read their rulebase afterward. That's the general shape of a whole class of outages: the platform underneath a config changes its contract, the config keeps parsing cleanly, and the behavior drifts without a single error. Files that "never changed" still rot.

Treat the PAC as what it is now — a coarse, host-level traffic director — and audit it like the firewall policy it secretly is. Top-down, first match wins, and every rule should answer one question: can this condition still see what it's matching on?