域名里出现中文、ñ、é时,DNS到底看到了什么
用户浏览器地址栏里输入español.com或中文域名.com,看起来是Unicode字符。但DNS协议本身只认ASCII。两者之间必须有一次编码转换,这个转换算法就是Punycode。
Punycode把Unicode标签编码成以xn--开头的ASCII形式。比如:
Unicode 形式:español.com
ASCII 形式: xn--espaol-zwa.com
浏览器和Node.js在解析URL时会自动完成这一步。真正发给DNS查询的,是xn--espaol-zwa.com。理解这一点,才能明白为什么服务端拿到的hostname有时是Unicode,有时是Punycode,以及为什么不能拿两者做简单字符串比较。
需要先明确一个前提:Node.js内置的node:punycode模块已经标记为废弃。官方给出的替代方案是node:url模块中的domainToASCII()和domainToUnicode()。新项目不应再直接require('node:punycode'),旧项目迁移时也应优先替换为URL API。
domainToASCII:把Unicode域名转成ASCII
url.domainToASCII(domain)接收一个域名字符串,返回它的ASCII兼容形式。如果域名无效,返回空字符串。
const { domainToASCII } = require('node:url');
const result = domainToASCII('代码号学习编程.com');
console.log(result);
输出类似:
xn--fiqs8s2j6a1b9c0d.com
这里的xn--前缀是Punycode编码标签的标志。一个域名里可能只有部分标签被编码,比如www.español.com只有español这一节变成xn--espaol-zwa,www和com保持原样。
一个常被忽略的点:domainToASCII只处理域名,不处理协议、路径、端口。传进去的应该是hostname,而不是完整URL。传完整URL会得到空字符串,因为它不是合法域名。
const { domainToASCII } = require('node:url');
console.log(domainToASCII('https://español.com'));
输出:
(空字符串)
项目经验:我在做域名白名单校验时踩过这个坑。前端传过来的host字段偶尔带了端口,比如español.com:8080,直接丢给domainToASCII会返回空,被误判为非法域名。后来统一先用newURL()解析,取url.hostname再转换,才稳定下来。
domainToUnicode:把Punycode还原成可读形式
url.domainToUnicode(domain)做反向转换,把xn--形式的域名还原成Unicode。
const { domainToUnicode } = require('node:url');
const result = domainToUnicode('xn--espaol-zwa.com');
console.log(result);
输出:
español.com
这个方法的典型用途是日志展示和用户界面。数据库里存的通常是ASCII形式,因为索引和比较更稳定;但展示给用户时,Unicode更易读。
个人建议:持久化存储用Punycode形式,展示层再转Unicode。原因是Punycode是规范化后的ASCII,大小写、等价字符的处理更确定。存Unicode会面临不同Unicode规范化形式(NFC/NFD)导致“看起来一样却比较不相等”的问题。比如é可以是单码点,也可以是e加组合重音符号,两者视觉相同,字节不同。
WHATWGURLAPI的自动转换
用newURL()解析含Unicodehostname的地址时,Node.js会自动把hostname转成Punycode。这是最省心的入口。
const url = new URL('https://代码号学习编程.com/path?q=1');
console.log(url.hostname);
console.log(url.href);
输出类似:
xn--fiqs8s2j6a1b9c0d.com
https://xn--fiqs8s2j6a1b9c0d.com/path?q=1
注意:url.hostname给的是Punycode,url.href里的hostname也已被规范化。如果业务需要展示原始Unicode,需要额外调用domainToUnicode(url.hostname)。
这里有一个实际差异值得注意。WHATWGURL会对整个URL做规范化,包括路径中的百分号编码、默认端口移除等。如果只是想单独转一个域名,用domainToASCII更直接,副作用更小。
Punycode与URL编码不是一回事
这两个概念经常被混在一起,但解决的问题不同。
Punycode作用于域名标签,产物以xn--开头,目的是让IDN能进入DNS。URL编码作用于URL的路径、查询参数等部分,产物是%加十六进制,目的是让特殊字符能安全传输。
一个URL里可能同时出现两者:
https://xn--espaol-zwa.com/搜索?q=%E4%BB%A3%E7%A0%81%E5%8F%B7
hostname部分是Punycode,query里是URL编码。混淆两者会导致错误的校验逻辑。比如用decodeURIComponent去还原hostname,对Punycode没有任何作用;反过来用domainToUnicode去处理query参数,也不会得到预期结果。
无效域名的判断与错误处理
domainToASCII和domainToUnicode遇到无效输入不会抛异常,而是返回空字符串。这意味着不能靠try/catch来判断失败,必须检查返回值。
const { domainToASCII } = require('node:url');
function normalizeHost(host) {
const ascii = domainToASCII(host);
if (!ascii) {
return null;
}
return ascii;
}
console.log(normalizeHost('invalid domain'));
console.log(normalizeHost('español.com'));
输出:
null
xn--espaol-zwa.com
哪些情况会返回空字符串?包含空格的字符串、含非法字符的标签、超长标签、空标签等。注意domainToASCII对空字符串也返回空字符串,所以校验时要区分“输入为空”和“转换失败”,否则错误提示会不准确。
另一个细节:domainToASCII返回的ASCII形式通常是小写的。如果业务对大小写敏感,需要自行确认。DNS本身大小写不敏感,所以统一小写是合理做法。
安全考量:同形异义字与欺骗性域名
Unicode里存在大量视觉相似的字符。西里尔字母а和拉丁字母a看起来几乎一样,但属于不同码点。攻击者可以注册一个用西里尔字母拼出的域名,伪装成知名品牌域名。
这类攻击叫同形异义字攻击(homographattack)。Punycode转换只改变表示形式,不会判断域名是否可信。把аpple.com(首字母为西里尔а)转成Punycode会得到xn--pple-43d.com,转换本身成功,但域名的可信度是另一回事。
防护思路有几种。一是对用户输入的高风险域名做Punycode展示,让用户看到xn--前缀时产生警觉。二是维护品牌域名的白名单,对相似度高的域名做人工审核或拦截。三是使用成熟的IDN同形异义字检测库,而不是自己写规则。
个人见解:很多团队在域名校验上只做了格式合法性检查,忽略了同形异义字。格式检查能挡住明显非法的输入,但挡不住精心构造的欺骗域名。如果产品涉及用户生成链接、邮件跳转、支付回调,这一层值得单独投入。
现在框架与工程中的惯用写法
在Express或Koa中,req.hostname通常已经由底层HTTP解析器处理过。但不同版本、不同代理配置下行为可能不一致。稳妥做法是在中间件里统一规范化:
const { domainToUnicode } = require('node:url');
function hostMiddleware(req, res, next) {
const rawHost = req.hostname;
const asciiHost = domainToASCII(rawHost);
if (!asciiHost) {
return res.status(400).send('Invalid host');
}
req.normalizedHost = asciiHost;
req.displayHost = domainToUnicode(asciiHost);
next();
}
在爬虫或URL抓取场景中,去重是关键。同一个站点可能以Unicode和Punycode两种形式出现,如果不去重会重复抓取。惯用做法是把hostname统一转成ASCII后再做哈希或入库。
TypeScript项目里,可以给转换函数加返回类型约束:
function toAsciiHost(host: string): string | null {
const result = domainToASCII(host);
return result || null;
}
用string|null而不是string,强制调用方处理失败分支。这类小约束在域名处理这种边界条件多的场景里能减少不少遗漏。
本节课程知识要点
-
node:punycode已废弃,域名转换应使用url.domainToASCII()和url.domainToUnicode()。 -
domainToASCII输入应是hostname,不要传完整URL或带端口的字符串。 -
两个方法失败时返回空字符串,不会抛异常,必须检查返回值。
-
WHATWGURLAPI会自动把Unicodehostname转成Punycode,取
url.hostname得到的是ASCII形式。 -
Punycode与URL编码作用范围不同,不要混用解码方式。
-
存储用Punycode,展示用Unicode,可以减少Unicode规范化带来的比较问题。
-
Punycode转换不判断域名可信度,同形异义字攻击需要额外防护。
练习与思考
-
用
domainToASCII转换中文.我国,再用domainToUnicode还原,观察两次结果是否与原始输入一致。 -
写一个函数,接收完整URL字符串,返回规范化后的ASCIIhostname;对非法输入返回
null。测试带端口、带协议、含空格的输入。 -
对比
newURL('https://español.com').hostname与domainToASCII('español.com')的结果,说明两者在什么情况下会不同。 -
查找一个使用西里尔字母伪装拉丁字母的域名示例,说明它转成Punycode后的形式,并设计一条检测规则。
国际化域名在中文、西班牙语、阿拉伯语等场景下是刚需,但它的处理链路比表面看起来长:浏览器输入、URL解析、DNS查询、服务端校验、日志存储、前端展示,每一步的表示形式可能不同。Node.js提供的domainToASCII和domainToUnicode把转换这件事收敛到两个函数上,剩下的判断——什么时候转、转到哪一层、怎么防欺骗——仍然需要开发者自己拿捏。