自己写的小脚本调自己站点的接口,一个不落全是 403;同样的 URL、同样的方法,换成 curl 立刻 200。折腾半天才发现,卡住我的既不是鉴权也不是编码,而是站点前面那层 CDN 的机器人规则——它按 User-Agent 的开头点名了 Python-urllib

🚪 现象:403,但只有我的脚本会 403

一个自用的小脚本,用标准库 urllib.request 去 POST 自己站点的管理接口,返回一律是 403。把同一个请求原样搬到 curl 上,200。

403 这个状态码天然会把人往鉴权上带,我的排查顺序也很俗套:

  1. token 是不是过期了 / 少了个前缀?换成刚签发的,还是 403。
  2. Content-Type 是不是没带 application/json?补上,还是 403。
  3. body 编码?把 json.dumps 的结果 .encode("utf-8") 前后都试了一遍,还是 403。
  4. 是不是 POST 才有问题?换 GET 打首页——依然 403

第四步是转折点。打首页不需要任何凭据,一个匿名 GET 都能被拒,那问题就跟「我是谁」无关了,只跟「我长什么样」有关。

🔬 定位:固定一切,只换 User-Agent

两个客户端对同一个 URL 拿到不同结果,这时候最有效的动作不是继续读自己的代码,而是做控制变量实验:把 URL、方法、header 全部钉死,一次只动一个变量。

我动的那个变量是 User-Agent,因为它是 urllib 和 curl 之间最显眼的差别——urllib 默认会发 Python-urllib/3.9,curl 默认发 curl/7.76.1

一行 shell 就能把这个实验批量跑完:

for ua in "Python-urllib/3.9" "python-urllib/3.9" "curl/7.76.1" "python-requests/2.31.0"; do
  printf "%-24s -> %s\n" "$ua" "$(curl -sS -o /dev/null -w '%{http_code}' -A "$ua" https://example.com/)"
done

注意这里有个小心机:我用 curl 去冒充 urllib,而不是用 Python 去冒充 curl。这样整条链路上除了 UA 这一个字符串,其它一切(TLS 栈、HTTP 版本、header 顺序、默认 Accept)都保持不变,结论才干净。

把 UA 列表铺开跑一遍,结果是这样:

User-Agent 状态码
Python-urllib/3.9 403
Python-urllib/3.11 403
Python-urllib(无版本号) 403
Python-urllib/9.9(不存在的版本) 403
Python-urllib/3.9 (mybot) 403
python-urllib/3.9(首字母小写) 200
MyApp Python-urllib/3.9(前面加前缀) 200
curl/7.76.1 200
curl 不指定(走 curl 默认) 200
python-requests/2.31.0 200
Go-http-client/1.1 200
Wget/1.21 200
libwww-perl/6.0 403
Mozilla/5.0 200

一个字符串换来 403 或 200,实锤了。

📐 规则长什么形状

这张表里最有意思的不是「被挡了」,而是边界在哪。三条推论都能从上面直接读出来:

  • 大小写敏感。 Python-urllib 挡,python-urllib 放行。差一个字母的大小写就是两个世界,说明匹配是原样比对,没有先做 lowercase。
  • 锚定开头的前缀匹配,不是子串包含。 Python-urllib/3.9 (mybot) 被挡,而 MyApp Python-urllib/3.9 放行——同样的子串,位置不同结果不同。规则只看 UA 的开头。
  • 不看版本号。 Python-urllib/9.9 这个根本不存在的版本照样被挡,Python-urllib 连版本号都不带也挡。所以判定只吃名字那一段,斜杠后面的东西无所谓。

还有一条同样重要的反证:这不是「拦所有非浏览器客户端」。python-requestsGo-http-clientWgetcurl 全都畅通无阻,连 Mozilla/5.0 这种一眼假的 UA 也放行。被点名的只有 Python-urlliblibwww-perl

换句话说,这是一份名单,不是一条启发式策略。

🤔 为什么偏偏是这两个(推测)

下面这段是我的猜测,没有拿到规则原文,仅供参考。

Python-urlliblibwww-perl 有个共同点:它们都是语言标准库/老牌基础库的默认 UA。用得上它们的场景,往往是「有人写了几行代码就直接发请求」,而不是「一个正经服务在调接口」。历史上大量的爬虫、扫描器、批量探测脚本都保留了这个默认值,久而久之它就成了裸客户端的招牌,被写进通用机器人规则的黑名单,性价比很高——命中的绝大多数确实不是人,误伤的成本又低。

反过来,python-requestsGo-http-client 拦不得。它们在无数正经的后端服务、监控探针、SDK 里都是默认 UA,一刀切下去误伤面积太大。同样是「脚本客户端」,一个进了黑名单,一个必须放行,差别不在技术上,在部署广度上。

Mozilla/5.0 能过则说明另一件事:这类规则拦的是「明说自己是裸脚本的客户端」,而不是在做真正的身份验证。想绕过它太容易了——但你要做的不是绕过。

🛠 修法:给你的客户端起个名字

正确的做法不是随便抄一个浏览器 UA 蒙混过关,而是给客户端设一个有辨识度、可追溯的 User-Agent

import urllib.request

# 403:默认 UA 是 Python-urllib/3.x
urllib.request.urlopen("https://example.com/")

# 200:带上自己的名字和一个联系入口
req = urllib.request.Request(
    "https://example.com/",
    headers={"User-Agent": "my-tool/1.0 (+https://example.com/about)"},
)
urllib.request.urlopen(req)

格式上照惯例来就行:名字/版本 (+说明页链接)。要给整个进程统一设置,也可以直接换掉 opener 的默认 header:

opener = urllib.request.build_opener()
opener.addheaders = [("User-Agent", "my-tool/1.0 (+https://example.com/about)")]
urllib.request.install_opener(opener)

顺带说一句,这本来就是好习惯,跟能不能过防护规则无关。日志那头的运维只能看到你留下的这个字符串,你不写,他就只能靠 IP 猜你是谁;等哪天你的脚本写出了 bug、开始每秒打几十个请求,一个带联系方式的 UA 决定了对面是先找你还是先把你封了。

🧭 更一般的教训

  • 403 不总是鉴权问题。 它只说明「服务端拒绝了」,拒绝的理由可能发生在业务逻辑之前——反向代理、WAF、CDN 的机器人规则,都能在你的代码还没被执行时就把请求毙掉。一个匿名 GET 也 403,就是很强的信号。
  • 同一个请求换个客户端就通,差异在请求指纹而不是请求内容。 内容层面(body、参数、token)你已经对齐了,那剩下的变量就在 header、TLS 指纹、HTTP 版本这些「你没写但客户端替你写了」的地方。
  • 排查时优先做客户端间的对照实验。 用一个可控的客户端(curl 最合适,什么都能指定)去逐项模仿那个出问题的客户端,每次只改一个变量。比一头扎进自己代码里读半小时快得多。

最后一条经验,用一句话概括就是:默认值也是一种配置,只不过是别人替你做的。它平时不出声,出声的时候就是一个 403。