I thought a URL that parsed was a URL that was safe

I thought a URL that parsed was a URL that was safe

Martin Shein · · 5 min read

I ran a URL through new URL(). It parsed cleanly. It was a phishing domain.

That is not a bug in the parser. The parser did exactly what the spec says. The problem is that I had been treating "it parsed" as a proxy for "it is safe," and those are two completely different questions. Once I started checking them separately, I found three failure classes my agents had been walking straight past, and one of them was quietly costing money.

Here is what I found, and what I shipped to stop it.

1. A URL can parse perfectly and still be a spoof

Look at pаypal.com. It is a valid URL by every WHATWG parsing rule. Your parser will accept it, your logger will record it, your agent will store it.

The а is Cyrillic (U+0430), not Latin a (U+0061). In punycode it resolves to xn--pypal-4ve.com, a domain that has nothing to do with PayPal. A human reading it in a scraped dataset sees the real thing.

You cannot regex your way out of this. I tried. The defense is two Unicode standards doing specific jobs: UTS #46 maps Unicode domain labels to ASCII punycode, and UTS #39 defines confusable detection and the mixed-script restriction that is the actual homograph defense.

The rule is not "reject non-ASCII." That would break real customers. münchen.de is Latin plus German diacritics, one script, legitimate. 日本.jp is Han, one script, legitimate. Both must pass. What fails is a label mixing scripts without being on an allowlist: pаypal.com mixing Latin and Cyrillic, or аррӏе.com mixing Cyrillic and Cherokee to imitate a familiar brand.

verify-url.mjs makes that call and returns a verdict, so my agents stop guessing.

Three chalk cards: two international domains stamped PASS, a lookalike domain stamped FAIL under a magnifying glass

2. Double percent-encoding survives one decode pass

%2541 looks like a single percent-encoded byte. It is not.

%25 decodes to %, which leaves %41, which decodes to A. So https://example.com/%2541%2542 is a double-encoded AB. One naive decode leaves a residual encoded payload behind.

Two things break when that slips through. As a security signal, double-encoding is a classic filter-evasion technique, and you want to see it rather than have it quietly cleaned up. As a data problem, it wrecks deduplication: when SpiderIQ hands scraped URLs to FuzzIQ, two encodings of the same target must collapse to one canonical record. If they do not, the dedup layer counts them as separate leads and you pay twice for one prospect.

The design choice I made here matters more than the detection itself. The verifier does not silently decode and move on. It flags a residual %25 followed by a hex pair as DOUBLE_PERCENT_ENCODING and surfaces it as a FAIL. Detection over silent normalization. The agent has to see the double-encoding, not have it papered over on the way through.

A chalk pipeline: a tangled knot, one DECODE pass leaving a still-knotted value circled in gold, a second DECODE pass reaching a clean line

3. The most expensive URL mistake is the one that looks responsible

This is the one that cost real money, and the instinct behind it is a good instinct.

Google Ads auto-tagging appends gclid to every ad-click URL, plus gbraid and wbraid on iOS. That click ID is how GA4 imports cost data. An agent trained to "tag everything properly" sees a URL without UTM parameters, does the responsible thing, and adds utm_source=google&utm_medium=cpc.

Those manual UTMs override the gclid. Cost-per-click attribution in GA4 breaks. Nothing errors. The link works, the click registers, the campaign runs, and the channel carrying the most ad spend reports numbers that are wrong.

The correct behavior is counterintuitive enough that it needs to be enforced rather than explained. Registry item utm-template-google-paid-2026 encodes default_action: DO_NOT_MANUALLY_TAG, with a fallback UTM set used only when auto-tagging is off. lint-utm.mjs treats gclid co-present with manual utm_* on the same URL as a FAIL. An agent cannot tag a Google Ads URL "just to be safe" and get past the gate.

The neighboring trap is GA4's medium vocabulary. GA4 classifies traffic only against its fixed default-channel-grouping list. Invent something reasonable-looking like smm and every one of those sessions files into (Unassigned). social is Organic Social and paid_social is Paid Social, so picking the wrong one misfiles spend. Values are case-sensitive, which means LinkedIn, Linkedin, and linkedin become three separate source rows in your report. CXL Institute put standardized naming at +29% campaign-attribution accuracy in 2025. The linter enforces the allowlist and fails uppercase, so the creative spelling never ships.

Two chalk lanes: a gold coin flowing through an open pipe into a filled report stamped COST, and the same pipe blocked by a sticker leaving an empty report stamped LOST

What I actually changed about how I build skills

Version 0.1.0 of this skill shipped in May. It passed the validator, scored 100/100 on marketplace security, and had three clean methods. I tested it in real agent work and my own verdict was "not useful at all."

The method count was not the problem. The problem was shape. It was a 167-line manual that the agent re-read on every single call, then reasoned its way through hex codes and UTM conventions from scratch, arriving somewhere slightly different each time. Reading a manual is expensive and non-deterministic.

So v0.3.1 is not a manual. It is guidance plus assets you install once and scripts that return a verdict. Twenty-two registry items: eight UTM channel templates, six per-platform tracking-parameter sets, four CMS canonical patterns, plus fixtures and configs. Three verifiers that print PASS or FAIL with counts. No backend at all, which is the honest shape for a skill whose work is pure logic. Your agent's own runtime does the parsing. It just stops improvising the rules.

One rule sits above all of it as a hard gate: never strip a parameter someone has declared attribution-authoritative. Stripping tracking is easy to over-apply, and over-applying it destroys a client's campaign measurement silently. Run strip-tracking.mjs --mode=audit first. It reports what would be removed and changes nothing.

Live now as @di-atomic/url-utils on the OPVS marketplace. Ask your agent to clean a batch of URLs, or to check one before it trusts it.