前阵子我给 VPS 上一个自托管服务(当时是 memos,套在 Cloudflare 橙云后面,ufw 只放 22/80/443,公网流量全部先过 CF 再到 nginx 反代)写了一个健康巡检脚本,逻辑很朴素:Python 起 urllib.request,GET 一下站点首页,看状态码。结果本地跑一次就 403。更诡异的是同一时刻,浏览器开着完全正常,在 VPS 上 curl 那个 URL 也是 200。浏览器和 curl 全对、只有 Python 挂——这类「本地全对、线上 403」的落差我后来踩过不止一次,值得单独记一篇。
现象:浏览器 200,curl 200,urllib 403
巡检脚本的核心就三行:
import urllib.request
req = urllib.request.Request("https://your-app.example.com/")
with urllib.request.urlopen(req, timeout=15) as resp: # 这里直接抛
print(resp.status)
$ python3 healthcheck.py
urllib.error.HTTPError: HTTP Error 403: Forbidden
而同一时间:
$ curl -s -o /dev/null -w '%{http_code}\n' https://your-app.example.com/
200
$ curl -s -o /dev/null -w '%{http_code}\n' -A 'Mozilla/5.0' https://your-app.example.com/
200
浏览器正常,裸 curl 正常。我第一反应是鉴权问题:是不是 memos 需要登录态?但 curl 也是裸请求,没带任何 cookie 和 token,照样 200——把「鉴权」这条先钉死了,剩下来的唯一变量就是「客户端长什么样」。而 HTTP 层里能代表客户端身份的,首先就是 User-Agent。
排查:先 diff 请求头,别急着怀疑鉴权
urllib 不发 UA 吗?发。默认值是 Python-urllib/3.x(跟着解释器版本走);换成 requests 裸用就是 python-requests/2.x;Go 的 HTTP 客户端默认 Go-http-client/1.1。这类 UA 的特征库在 Cloudflare 的托管规则里烂熟于心。
我当时的做法是拿一个公开的回显服务(httpbin.org)把「脚本实际发了什么」原样打印出来:
$ curl https://httpbin.org/headers
{
"headers": {
"User-Agent": "curl/8.5.0"
}
}
import urllib.request
with urllib.request.urlopen("https://httpbin.org/user-agent") as r:
print(r.read().decode())
# {"user-agent": "Python-urllib/3.14"}
对照实验一做就清楚:同一个脚本,手动加一个浏览器样式的 UA,立刻 200;不加,稳定 403。拦截方是 CF 而不是源站——只要看 403 响应的头就知道:
$ curl -sI https://your-app.example.com/ -A 'Python-urllib/3.14'
HTTP/2 403
server: cloudflare
cf-ray: 8403a1c2de9f07b3-LAX
server: cloudflare 加一个 cf-ray,说明响应是 CF 边缘生成的,压根没回源。源站的 nginx 403 不会带这两个头(我自己还踩过另一种:nginx 目录缺 index.html 返回 403,而且 error_page 403 不写 =404 时会把 403 状态原样透出去——那是拦截发生在源站的形态,头里没有 cf-ray,两回事)。403 到底是谁给的,决定了后面所有排查方向,值得先看一眼再动手。
顺带一提,这里 urllib.error.HTTPError 的默认打印也有迷惑性:它只输出「HTTP Error 403: Forbidden」这一行短文案,把真实响应体盖住了。我在另一轮排障里吃过同款亏——查 Halo API 时前台 /archives/{slug} 明明 404,urllib 捕获 HTTPError 后读出来的却是一段误导性的短错误体,最后判定要看异常对象上的 code 才对上。结论是:写巡检脚本时,状态码和关键响应头要单独 print 出来,别依赖 HTTPError 的 str()。
解法:所有脚本请求头里带 UA
修复本身一行:
req = urllib.request.Request(
"https://your-app.example.com/",
headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 kb-digest/1.0"},
)
带浏览器样式 UA 的请求即通。UA 末尾我习惯再挂一段自己的标识(比如 kb-digest/1.0、halo2-publish/1.0),这样在源站 access log 里能一眼把自己的脚本流量和真实用户、和外部爬虫分开。后来我把整个工作区的脚本扫了一遍补 UA:发博客的脚本、sitemap 增量检查、GSC 待提交清单、IndexNow 推送、日报抓取脚本,每个 urllib.request 调用都显式设了 headers——修完一处,同类脚本全过一遍,这事才算完。
有几点边界我想说清楚,免得这篇文章被读成「教你绕过反爬」。这是我自己域名、自己源站的服务,监控探针因为 UA 被自家 CF 的安全策略误伤,加 UA 是在正确表达客户端身份,不是规避;Cloudflare 的托管规则也不是拦「所有带浏览器 UA 的请求」,而是拦已知库的默认 UA 特征。另外反爬是别人的正当权利:我给图床站抓壁纸缩略图时,对方就明确要求必须带浏览器 UA 加 Referer,不带就 403——那是人家的防盗链策略,照做就对了。
复盘要点
- 「本地全对、线上 403」的第一优先级是 diff 请求头,尤其 User-Agent,然后才轮到鉴权、cookie、IP。CF 后置的自建服务,任何默认 UA 的脚本(requests 裸用、wget 配置错误、监控探针)都会中招;如果巡检脚本「直连源站全对、走域名就挂」,那还暴露了第二个问题:测试路径和生产路径不一致,直连源站测到的是没有 CF 的那半套系统。
- 看响应头判断拦截方:有
server: cloudflare+cf-ray是边缘拦,没回源;nginx 自己给的 403 长得完全不一样,别混着排。 - urllib 的 HTTPError 打印会吞掉真实响应,脚本里把
e.code和响应头显式打出来再进日志。 - 出站 HTTP 请求一律显式设 UA,末尾带产品标识(
Mozilla/5.0 ... my-tool/1.0),新脚本第一天就写进去——这一行的成本,换来的是以后再也不用在浏览器和终端之间来回对照找不同。
评论