nikolas.sapa
August 11, 2026

A check at the entry point is not a policy

Grip is a browser SDK for AI agents. It has a NavigationPolicy with an allow_private=False flag, which exists for one reason: an agent following instructions from a web page should not be able to fetch http://169.254.169.254/ and hand your cloud credentials to whoever wrote that page.

The flag was enforced in Browser.open().

That's it. That's the bug.

What that means in practice

Page.goto() had no check. It's the most ordinary call in the library — open a browser once, navigate many times. Every navigation after the first one skipped the policy entirely.

Redirects were never re-checked either. A public URL that 302s to 127.0.0.1 passed, because the only thing that ever got inspected was the string you handed to open().

So the flag people set specifically to prevent SSRF was bypassable by using the library normally. Not by an exotic attack. By the second line of the quickstart.

The other half: what counts as localhost

The check that did run wasn't right either. It canonicalized the host with Python's ipaddress module and refused anything private. Reasonable, except Chrome and Python disagree about what an IP address is.

All four of these reach 127.0.0.1 in Chrome:

2130706433      # the address as a 32-bit integer
0177.0.0.1      # octal first octet
0x7f000001      # hex
127.1           # Chrome fills in the middle

Python's ipaddress rejects all four as invalid, so my code shrugged and let them through as "some hostname, not an IP." The blocklist was checking a different address space than the browser was resolving.

This is the general shape of parser-mismatch bugs: two components agree they're looking at the same string and disagree about what it means. The one that decides is never the one doing the checking.

The fix

Enforcement moved to CDP Fetch interception at request stage, armed for the page lifetime instead of re-applied per navigation. A refused request now fails before Chrome resolves DNS or opens a connection — not after the response comes back and something decides whether to be upset about it.

All four spellings are canonicalized before the check.

Popup targets get closed on attach under a restrictive policy, because a new CDP target inherits none of the parent's interception — window.open() was a clean way out of the room. That's a breaking change: under a restrictive policy, which is the default, window.open() now fails.

Two limits stated rather than buried: WebSocket handshakes aren't intercepted, because CDP's Fetch domain doesn't cover them. DNS rebinding is out of scope.

The lesson I'd actually keep

A check at the entry point is a suggestion. A policy is something enforced at the layer where the thing happens.

I put the check where it was easy to write — the path where the URL first arrives, one function, one line. Enforcement belongs where requests are made, which meant a different API, more work, and a fix that couldn't be a one-liner.

Worth asking about your own code: for every flag that turns a protection on, how many code paths reach the protected operation, and does the flag guard all of them, or just the one you were looking at when you wrote it?

Shipped in grip 0.6.0.