PunyGuard:封鎖所有國際化網域名稱的 Chrome 擴充功能
PunyGuard: A Chrome Extension That Blocks Every Internationalized Domain
PunyGuard 是一款 Chrome 擴充功能,會封鎖所有國際化網域名稱(IDN),不作外觀相似度判斷,也不設例外。它透過 Chrome 的 declarativeNetRequest(DNR)註冊一條規則,檢查主機名稱是否有標籤以 xn-- 開頭,因此連合法的日本語.jp 也會被封鎖。規則適用於頂層頁面導覽及 iframe,但不影響圖片或腳本載入。
аррӏө.com and apple.com. Put them side by side: can you tell which one is real?
All five letters in the first one are Cyrillic. Even so, Chrome 154's address bar displayed it exactly as-is. It looks identical to apple.com (GIZMODO article).
After reading that article, I built a small Chrome extension called PunyGuard. It blocks access to internationalized domain names (IDNs), all of them, no exceptions. It doesn't judge whether a domain looks similar to anything. Legitimate Japanese domains get blocked too. It may look like a heavy-handed tradeoff, but that tradeoff is the core of the design.
Out of nowhere, a confession: I don't like Japanese domain names. Rather than getting sent to some Japanese-named site where I can't even tell who's behind it, I'd honestly prefer not to be able to reach it at all. That conviction is what the whole design rests on.
So instead of making the machine do fuzzy judgment calls, I gave it a rule it can't get wrong. The machine handles the routine work; I decide what to protect and what to give up. It's a small extension, but the way I think about working with computers and AI shows up directly in both its design decisions and how I built it.
https://github.com/sho7650/punyguard
ℹ️ If you use
unbound(I do too), riorist's post Preventing homograph attacks with Unbound (in Japanese) is worth a look too. Since it works regardless of which browser you use, it's arguably the more secure option.
Miss one character, and the look-alike detection falls apart
Chrome has a mechanism that displays domains containing confusable characters in Punycode. Punycode is a scheme for rewriting domain names that contain non-ASCII characters (ASCII being half-width letters, digits, and symbols), like Japanese or Cyrillic, into an ASCII string starting with xn--. аррӏө.com becomes xn--80a6aa68c8d.com. If a suspicious domain is shown as that jumble of symbols, at least it won't look like apple.com.
So what counts as "confusable"? The basis for that call is a list of look-alike characters. This time, ө (U+04E9) wasn't on the list. Because of that single missing character, five Cyrillic letters sat in the address bar looking exactly like the real thing.
I don't think this is really anyone's mistake so much as a limit of the approach itself. The world has an enormous number of characters, and how they look changes from font to font. Exhaustively listing every "similar-looking character" is a job that never ends. However carefully you grow the list, the next ө will be hiding somewhere.
Human eyes are the same. The assumption that you can tell them apart if you look carefully collapses instantly in the face of a one-character difference. So I decided to stop judging by appearance altogether.
A design that stops judging
All PunyGuard looks at is whether the hostname has a label starting with xn--. That's it. A label is each piece of www.example.com split by .. If even one label starts with xn--, the host is treated as an IDN host and blocked.
However natural an internationalized domain looks on screen in the browser, on the wire it always carries the xn-- prefix. A gatekeeper who judges by faces is vulnerable to look-alikes. A gatekeeper who checks name tags doesn't hesitate, no matter how similar the faces are. I built PunyGuard to be the second kind of gatekeeper.
Before settling on this policy, I considered other paths and left a decision record (ADR). One option was to draw the line by script: let kanji and kana through, block Cyrillic and so on. Its upside is that Japanese domains would get through. But even within the allowed scripts there are confusable characters, so it just moves the problem somewhere else. Another option was to judge by visual similarity, which is the same idea Chrome uses, and exactly the approach that let this one slip through. And as I said, I dislike Japanese domains.
What I agonized over most was an allowlist and a "proceed just this once" button. They'd be convenient. But as long as the warning screen has a button to proceed, there's room for someone to sweet-talk you into pressing it. Phishing is, after all, also the art of getting people to press buttons. I decided to leave them out of v1, and recorded them in the ADR as candidates to add if they ever become truly necessary.
The price I pay instead is clear. Legitimate Japanese domains like 日本語.jp get blocked too. Since I dislike Japanese domains myself, I judged that these false positives don't hurt me. For people who use Japanese domains, it's an inconvenient extension, and I said so near the top of the README. This is a tool for me, first and foremost. It's enough if the people who share my philosophy get it.
Just one blocking rule
The core of the implementation is Chrome's declarativeNetRequest (DNR from here on). An extension registers rules with Chrome along the lines of "treat requests matching this condition like this," and from then on Chrome applies them itself. The extension doesn't need to peek at every page the user visits.
PunyGuard registers just one rule, and its condition is this regex:
^https?://(?:[^/?#]*[.@])?xn--
Reading from the start of the URL, it matches if any label in the hostname starts with xn--. It also blocks when the top-level domain is an IDN (e.g. example.xn--q9jyb4c). It doesn't match hosts that merely have xn-- in the middle of a label (axn--b.com), or xn-- appearing in the path or query. It applies to two things, top-level page navigations and pages embedded in another page (iframes), and doesn't affect loading of resources like images or scripts.
DNR converts the hostname to Punycode before matching. So even if you type аррӏө.com into the address bar in Unicode, the same rule catches it. Clicking a link, typing in the address bar, a server-side redirect, a link opened from another app: whatever the path, the result is the same.
The requirement I first wrote down was "if a URL contains anything other than English ASCII, refuse the click." While working out the plan, I realized I couldn't build it as written, and revised it in two directions.
First, I narrowed the check to the hostname (FQDN) only. If paths were included, pages like ja.wikipedia.org/wiki/東京 would get blocked too, which would make it unusable. Besides, by the time a request goes out, Japanese in the path has already been converted into an alphanumeric sequence like %E6%9D%B1, so you can't even ask "does it contain non-ASCII?"
Second, I widened it to block paths other than clicks. Behind the wording of the requirement was the actual goal: "don't let me get to a fake site." Rather than building it to the letter, I chose to build it to that goal.
Why ask for one more permission?
After blocking, what do you show the user? This is where I made one big decision.
DNR has block, which simply stops the request, and redirect, which sends it somewhere else. With block, the permissions you request stay minimal. But what the user sees is Chrome's standard error screen, ERR_BLOCKED_BY_CLIENT. That gives no way to tell whether the network is acting up or an extension blocked it. Faced with an unexplained wall, some people would get confused and just remove the extension.
So I went with redirect, sending the user to a warning page bundled with the extension. The catch is that redirect requires access to all sites (<all_urls>). Minimal permissions versus telling the user why it was blocked: I weighed the two and chose the latter.
Access to all sites is on the larger end of extension permissions. That's exactly why I keep everything else doing as little as possible. There isn't a single program that touches the content of the page you're viewing (a content script). It makes no external requests and stores nothing. What it requests, what it doesn't do, and why are all written plainly in the README. An extension that can't explain why it needs such broad permissions deserves not to be trusted.
The redirect URL includes the extension's ID, and the ID varies by environment. So instead of hard-coding the rule in a file, the extension builds and registers it when it starts up. It re-registers every time: on install and update, and on browser startup. Dynamically registered rules are supposed to persist across restarts, but I decided not to rely on that "supposed to."
The warning page trusts nothing
The warning page shows the original URL, the hostname in both Unicode and Punycode, and a list of the non-ASCII characters. For аррӏө.com, you can see that ө is Cyrillic U+04E9. It's a screen that reveals, character by character, an identity you can't see by looking. The display language follows the browser: Japanese for Chrome in Japanese, English otherwise.
The original URL is passed in the fragment (the part after #) of the redirect URL. The thing I was careful about here was not trusting any input to this page at all. The warning page can be opened from anywhere on the web with any fragment attached. You have to build it assuming a malicious string will show up.
All output to the screen goes through textContent, so there's no path for a string to be interpreted as HTML. If a URL comes in that can't be parsed, it shows only generic text, with no host information. The original URL isn't turned into a link, and there's no button to copy it either. I decided from the start not to build anything you could press by accident.
On a smaller note, I tripped over the string :~:. Chrome strips the #:~:text= syntax, used to jump to a specific passage on a page, from the URL the page can read (location.hash). If the original URL happens to contain :~:, everything after it disappears. This was caught in a review by Codex, and I fixed it to read the URL from the navigation history, before the stripping happens.
There are two buttons. The primary one, "Close this tab," is always shown. It's the action that best fits the goal of getting away from a suspicious link. The secondary one, "Go back to the previous page," is shown only when there's history. When the warning page appears inside an iframe, neither is shown. Closing the parent page's tab from inside an iframe goes beyond what the user intended.
I thought about the buttons a lot. I wanted to get away with a single button, "go back if you can, close if you can't," but browsers have no way to accurately check whether there's cross-site history behind you. I also considered going back, waiting a bit, and closing the tab if the page was still there. But deciding based on a wait time risks closing the tab while it's still on its way back to a heavy page. Rather than break the tab with an inaccurate check, it's better to accept that there will be cases where it misses, and say so honestly. That's why I split it into two buttons.
I also planned for cases where scripts don't run. If the redirect lands in an iframe whose embedder has disabled scripts with the sandbox setting, the warning page's JavaScript won't run. So the HTML ships with a fixed warning message, in both Japanese and English, from the start. Even without the details, the one line "PunyGuard blocked navigation to this page" gets through.
One more thing: if the URL contains a password, as in https://user:password@..., it's removed both from the display and from the warning page's own address bar. Exposing a secret on screen as a result of blocking would defeat the purpose of a protective tool. That said, the other Shossan living in my head does feel like "you didn't have to go that far..."
The scariest defense is the one that fails silently
The thing I most want to avoid in a security tool is a state where it's broken and nobody notices. When it looks like it's working but actually isn't protecting anything. In developing PunyGuard, I spent a lot of time figuring out how to stamp out these "silent failures."
The first pitfall was regex dialects. DNR regexes aren't interpreted by JavaScript's regex engine but by a separate one called RE2. RE2 doesn't support some constructs, like lookahead, and Chrome also rejects regexes that are too large. Meanwhile, ordinary unit tests run on JavaScript regexes. Even if you use something RE2 can't handle, the tests pass. Ship it like that, and rule registration fails, and nothing gets blocked. And nothing at all happens on screen.
I considered testing with a package that lets you call RE2 from Node instead. But that package translates JavaScript-style syntax for RE2 before running it, so it even lets through syntax that Chrome would reject. No matter how much you check in a similar environment, you won't get the same answer as production.
What I ended up choosing was to have Chrome itself try it. In end-to-end tests (E2E tests) that actually drive a browser, I load the extension into a real Chromium. Then I pass the exact rule I'm going to register to Chrome's isRegexSupported and have it answer whether it can be used. In the end, only production knows the answer. So run it in production.
Even so, the chance of registration failing on a user's machine isn't zero. When that happens, a ! badge shows up on the toolbar icon.
There was one more easy-to-miss hole. On Chrome's extensions page, users can restrict site access to "On click" or to specific sites. When they do, rule registration still succeeds, but only the redirect stops working. No error shows up anywhere. So I added a check for access to all sites, and if it's missing, the same ! appears. You can see the cause in the tooltip that appears when you hover over the icon. To keep a stale check result from overwriting the badge when access is toggled quickly, the checks run one at a time, in order.
In a normal window, if there's no !, blocking is working. I wanted to make it unmistakable that the settings and state are correct.
Say what it can't do
PunyGuard has a few weaknesses I left in knowingly.
One is a known false positive. If xn-- appears in the username part of a URL, as in https://xn--a@example.com/, it gets blocked even though the host is plain ASCII. Fixing it properly would take a pretty complex regex within RE2's constraints. I've never seen anyone open a URL like this, and the only harm is the blocker being wrong, so for now it stays as specified behavior, and there's a test confirming it.
Another is the back button. If the warning page opens as the first page of a new tab, and you navigate elsewhere and then come back with the browser's back button, "Go back to the previous page" is shown, but pressing it does nothing. That's the price of "accepting cases where it misses" from earlier; even in this state, "Close this tab" works.
What I struggled with most was incognito windows. Chrome doesn't run extensions in incognito windows unless the user turns on "Allow in Incognito." If it isn't allowed, IDN hosts aren't blocked, and the badge doesn't warn you either. Showing ! forever to someone who has deliberately chosen not to allow it would be unkind.
Even when it is allowed, the bundled warning page can't be shown in incognito tabs. Navigation to IDN hosts is blocked, but the screen shows Chrome's ERR_BLOCKED_BY_CLIENT, and the address bar keeps the original URL, password and all. Switching the extension's operating mode might have made it possible to show the warning page. But that would mean reworking the foundation of the design, and all it would buy is the "show the reason" part, not the "block" part. Since blocking itself works, this time I only documented it in the README.
Hand repetitive work to machines
Once I actually built it, I spent more time on the machinery around PunyGuard than on PunyGuard itself.
The development environment is generated from copier-ts7, a template for TypeScript 7 that I made. The Node.js and pnpm versions are pinned with a tool called Nix, so the same versions run both on my machine and in CI, which runs automated tests on GitHub. A single pnpm run check runs, in order, type checking, linting, format checking, and tests that require at least 80% coverage.
Releases are handled by an automation tool called release-please. As long as commit messages follow a set format, every time a change lands on main, it automatically creates a PR with the next version and the changelog. Merging that PR creates a tag and a GitHub Release, and bumps the version in package.json and manifest.json. A workflow then builds the tagged commit and attaches punyguard-vX.Y.Z.zip to the Release. Handy. I always forget to bump version numbers, so most of the apps I publish manage their versions this way.
What the human doesn't hand over when building with AI
As has become my usual lately, I built PunyGuard together with Claude Code. Write a plan, write tests first, then implement, set up E2E tests, build the release pipeline. Most of the code writing and running was done by the AI. And for every PR, I had Codex review it. I've set up skills so all of this happens entirely within Claude Code.
This combination is very effective, and not just for this app. The :~: URL truncation, tests and the shipped build loading different files, the warning page not opening in incognito windows: all of these were first found in Codex reviews. Separating the writer from the reader makes it less likely that both fall into the same assumptions and the same unnecessary biases. The logic is the same as asking someone else to review your code on a human team.
On the other hand, there's one thing I didn't hand to the AI: making the decisions.
I'm the one who decided to block legitimate Japanese domains too. The AI's reaction was along the lines of "surely Japanese is fine...", but as I keep saying, I dislike Japanese domains. Narrowing the check to hostnames, and deciding to handle the incognito problem in the README only, were also all my calls. The AI lays out the options and the cost of each, carefully. But which cost to accept can only be decided by a human who knows the circumstances of the people using it. I can say the pain of false positives is small precisely because I'm someone who dislikes Japanese domains.
That's why I always write down what I decide. Decisions, their reasoning, and the options I considered and rejected go in ADRs. Definitions of terms go in a glossary (CONTEXT.md). The plan, progress, and deferred issues with their reasons go in the plan document (tasks/todo.md). When working with AI, conversation memory gets summarized mid-way or cut off across days. If I had to explain everything from scratch each time, the context wouldn't be enough, and neither would my patience. If it's written down, the AI can interpret it in the next session and start from the same premises. The same goes for me reading it back six months from now.
I also kept the final check as a human job. Links opened from external apps, switching Chrome's display language, the badge when site access is restricted, browser restarts, incognito windows. These are hard to verify with automated tests, so I checked each one myself in a real Chrome. You must not trust AI-written code on the strength of AI-written tests alone. Only after checking with my own eyes can I look anyone in the eye and say "it works."
AI lends you more hands and an extra pair of eyes. Deciding what to build, what to give up, and whether it really works is, for now, the human's role. Draw that line clearly, and you can hand work to AI with peace of mind.
I didn't choose the path of better detection
There was surely a path of making the detection smarter. Grow the list, refine the similarity calculation, keep adding exceptions. PunyGuard didn't take that path. I made one single decision, not to judge, and handed the job of upholding it to a machine that never gets tired.
Choose simplicity that never hesitates over competing on cleverness. That's the distance I want to keep when working with computers and AI.
I decided not to put PunyGuard on the Chrome Web Store. It's distributed only on GitHub. Grab punyguard-vX.Y.Z.zip from Releases, unzip it, turn on Developer mode at chrome://extensions, and load it, and it'll work. Please leave site access set to "On all sites." If you don't use Japanese domains and you're worried about phishing with look-alike domains, I'd be glad if you gave it a try.
Why not the Web Store? It's something I made for myself, and I just didn't want to start fielding "please make it do this!!" requests from other people. That said, if you share Shossan's philosophy, feel free to use it.
來源:DEV Community · dev.to