自己写的小脚本调自己站点的接口,一个不落全是 403;同样的 URL、同样的方法,换成 curl 立刻 200。折腾半天才发现,卡住我的既不是鉴权也不是编码,而是站点前面那层 CDN 的机器人规则——它按 User-Agent 的开头点名了
Python-urllib。
🚪 现象:403,但只有我的脚本会 403
一个自用的小脚本,用标准库 urllib.request 去 POST 自己站点的管理接口,返回一律是 403。把同一个请求原样搬到 curl 上,200。
403 这个状态码天然会把人往鉴权上带,我的排查顺序也很俗套:
- token 是不是过期了 / 少了个前缀?换成刚签发的,还是 403。
Content-Type是不是没带application/json?补上,还是 403。- body 编码?把
json.dumps的结果.encode("utf-8")前后都试了一遍,还是 403。 - 是不是 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-requests、Go-http-client、Wget、curl 全都畅通无阻,连 Mozilla/5.0 这种一眼假的 UA 也放行。被点名的只有 Python-urllib 和 libwww-perl。
换句话说,这是一份名单,不是一条启发式策略。
🤔 为什么偏偏是这两个(推测)
下面这段是我的猜测,没有拿到规则原文,仅供参考。
Python-urllib 和 libwww-perl 有个共同点:它们都是语言标准库/老牌基础库的默认 UA。用得上它们的场景,往往是「有人写了几行代码就直接发请求」,而不是「一个正经服务在调接口」。历史上大量的爬虫、扫描器、批量探测脚本都保留了这个默认值,久而久之它就成了裸客户端的招牌,被写进通用机器人规则的黑名单,性价比很高——命中的绝大多数确实不是人,误伤的成本又低。
反过来,python-requests 和 Go-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。
💬 评论
加载评论中...
发表评论