前言

经常「滥用」Microsoft 365 和 Cloudflare 的朋友应该都知道, 要用 SharePoint 或者 OneDrive 直接当源储存是非常不稳定的, 有些时候下载速度还非常慢. 于是有人就想到了我们的「赛博菩萨」Cloudflare, 如果再反代源储存一层是不是就更好一点呢? 哎, 还真有一点效果, 但是不多, 起码能够缓解一下 Microsoft Graph API 的压力.

但是根据 Cloudflare 的 2024 年安全报告:

机器人方面,我们观察到的所有流量中,大约三分之一是自动化的,其中绝大多数(93%)不是来自 Cloudflare 已验证清单中的机器人,可能存在恶意。

—— 应用安全报告:2024 年更新

这其中就有家「臭名昭著」的公司, 主业就是为自己的客户利用自动化程序扫描互联网上涉嫌仿冒自家客户站点的站点 —— Netcraft. 从客户角度来说, 这家公司实力强劲, 每天都能解决掉大量涉嫌欺诈的网站, 能够保护自己的顾客不被这些仿冒站点钓鱼攻击, 进而影响自己的业务声誉. 于是恰好 Microsoft 就是 Netcraft 的客户之一, 很多朋友在经过网络上的一些教程建立了自己的 SharePoint 反代之后几乎 100% 会被 Netcraft 照顾(如果没有, 那只是时间问题). 轻则 Cloudflare 把 Worker 端点标记成欺诈, 重则 Cloudflare 账号被封禁.

Cloudflare 反代导致账户被暂停 - V2EX

虽然鄙人很早就知道了这种事情, 并在反代的时候给 Worker 自定义域名端点上了鉴权, 但是今天却突然收到了 Cloudflare 转发来的来自 Netcraft 的举报:

关于我我用 Cloudflare Worker 反代 SharePoint ...

一失足成千古恨 (然而并没有)

看到被举报的端点是默认端点我就懂了, 想都不用想一定是我当时认为万事俱备之后结果忘记关掉默认端点的访问了(草).

计算一下时间, 这个反代端点建立于五个月前, 现在 Netcraft 来光顾其实也算得上是一种「尽职」了.

所以有哪些办法避免自己的反代端点被 Netcraft 扫描然后发 abuse 呢?

端点鉴权

鉴权是最基本的方法, 可以直接在 Worker 项目的代码里面做, 也可以在 WAF 里面做, 当然也可以两个一起结合用. 主要的原理就是禁止空 referer 和不合法的 referer 标头.

建议创建 Worker 项目之后参照下面的 WAF 章节立刻把默认路由禁用再修改代码.

因为按照经验, Netcraft 的 abuse 会在你还在 Worker 在线编辑器里面调试项目代码的时候就发来给你, 所以这很可能这是 Microsoft 对请求自己的 referer 标头直接让 Netcraft 进行了扫描.

关于写这篇文章的时候建演示 Worker 实例还在调试就收到了举报邮件这件事

WAF

要用 Cloudflare WAF 首先就必须有自己的域名已经添加到了账号中, 把 Worker 路由到自己的域名之下, 第一步就是禁用默认的 worker.dev 路由:

在默认路由菜单可以关闭默认路由的访问

第二步就要精确指定自己要反代的路径, 类似这样的:

1
https://sharepoint-example.cx.ms/sites/shared/_layouts/*

准确指定反代路径

一般来说, 通过这两步就差不多够了, 但为了保险一点你还可以加上第三步 referer 标头鉴权.

在自定义路由对应的域中添加自定义 WAF 规则:

1
(http.host eq "sharepoint-example.cx.ms" and http.referer eq "") or (http.host eq "sharepoint-example.cx.ms" and not http.referer contains "example.com") or (http.host eq "sharepoint-example.cx.ms" and not http.referer contains "example.org")

阻止空 referrer 以及不来自自己网关的 referrer

Worker

当然如果要说「我就是不用自己的域名」, 也是可以的, 毕竟仅仅是要 Worker 反代就要搞定一个域名是有点多此一举了.

主要还是用 referer 标头在具体的 Worker 入口中做判断, 如果请求不包含你的网关请求来源的 referer 标头, 或者为空, 则直接拒绝响应, 返回 http:403:

1
2
3
4
5
6
7
8
9
const allow_referrer = ['example.com', 'example.org']

const referrer = request.headers.get('referer');

if (!referrer || !allow_referrer.includes(referrer)) {
response = new Response('Access denied: Not allowed referrer.', {
status: 403
});
}

把上面的加进目前最流行的那份, 完整的代码就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
// 你的 SharePoint 域名
const upstream = '*-my.sharepoint.com'

// 你的 SharePoint 域名 (用于移动端)
const upstream_mobile = '*-my.sharepoint.com'

const upstream_path = '/'

// 禁止访问的国家代码 (https://en.wikipedia.org/wiki/ISO_3166-1#Codes)
const blocked_region = ['KP', 'SY', 'PK', 'CU']

// 禁止访问的 IP 地址
const blocked_ip_address = ['0.0.0.0', '127.0.0.1']

// 允许访问的 Referrer 标头
const allow_referrer = ['example.com', 'example.org']

const https = true

const disable_cache = false

const replace_dict = {
'$upstream': '$custom_domain',
'//sunpma.com': ''
}

addEventListener('fetch', event => {
event.respondWith(fetchAndApply(event.request));
})

async function fetchAndApply(request) {
const region = request.headers.get('cf-ipcountry').toUpperCase();
const ip_address = request.headers.get('cf-connecting-ip');
const user_agent = request.headers.get('user-agent');
const referrer = request.headers.get('referer');

let response = null;
let url = new URL(request.url);
let url_hostname = url.hostname;

if (https == true) {
url.protocol = 'https:';
} else {
url.protocol = 'http:';
}

if (await device_status(user_agent)) {
var upstream_domain = upstream;
} else {
var upstream_domain = upstream_mobile;
}

url.host = upstream_domain;
if (url.pathname == '/') {
url.pathname = upstream_path;
} else {
url.pathname = upstream_path + url.pathname;
}

if (blocked_region.includes(region)) {
response = new Response('Access denied: WorkersProxy is not available in your region yet.', {
status: 403
});
} else if (blocked_ip_address.includes(ip_address)) {
response = new Response('Access denied: Your IP address is blocked by WorkersProxy.', {
status: 403
});
} else if (!referrer || !allow_referrer.includes(referrer)) {
response = new Response('Access denied: Not allowed referrer.', {
status: 403
});
} else {
let method = request.method;
let request_headers = request.headers;
let new_request_headers = new Headers(request_headers);

new_request_headers.set('Host', upstream_domain);
new_request_headers.set('Referer', url.protocol + '//' + url_hostname);

let original_response = await fetch(url.href, {
method: method,
headers: new_request_headers
})

connection_upgrade = new_request_headers.get("Upgrade");
if (connection_upgrade && connection_upgrade.toLowerCase() == "websocket") {
return original_response;
}

let original_response_clone = original_response.clone();
let original_text = null;
let response_headers = original_response.headers;
let new_response_headers = new Headers(response_headers);
let status = original_response.status;

if (disable_cache) {
new_response_headers.set('Cache-Control', 'no-store');
}

new_response_headers.set('access-control-allow-origin', '*');
new_response_headers.set('access-control-allow-credentials', true);
new_response_headers.delete('content-security-policy');
new_response_headers.delete('content-security-policy-report-only');
new_response_headers.delete('clear-site-data');

if (new_response_headers.get("x-pjax-url")) {
new_response_headers.set("x-pjax-url", response_headers.get("x-pjax-url").replace("//" + upstream_domain, "//" + url_hostname));
}

const content_type = new_response_headers.get('content-type');
if (content_type != null && content_type.includes('text/html') && content_type.includes('UTF-8')) {
original_text = await replace_response_text(original_response_clone, upstream_domain, url_hostname);
} else {
original_text = original_response_clone.body
}

response = new Response(original_text, {
status,
headers: new_response_headers
})
}
return response;
}

async function replace_response_text(response, upstream_domain, host_name) {
let text = await response.text()

var i, j;
for (i in replace_dict) {
j = replace_dict[i]
if (i == '$upstream') {
i = upstream_domain
} else if (i == '$custom_domain') {
i = host_name
}

if (j == '$upstream') {
j = upstream_domain
} else if (j == '$custom_domain') {
j = host_name
}

let re = new RegExp(i, 'g')
text = text.replace(re, j);
}
return text;
}


async function device_status(user_agent_info) {
var agents = ["Android", "iPhone", "SymbianOS", "Windows Phone", "iPad", "iPod"];
var flag = true;
for (var v = 0; v < agents.length; v++) {
if (user_agent_info.indexOf(agents[v]) > 0) {
flag = false;
break;
}
}
return flag;
}

纯 WAF 屏蔽 Netcraft

实际并不推荐仅依靠这种办法, 后文会讲到.

这种办法你只能用自定义域名路由 Worker 后才能使用. 一般来说, 扫描网络上的公共站点的机器人都会带有一个 User-Agent 表明自己的身份, 以便于站点管理员识别这部分流量. 实在不济, 机器人也都是用的固定 IP 或固定 ASN 的 IP, 一般就是对应代表了机器人扫描活动的那家公司, 所以一般也只需要在 WAF 中针对 ASN, IP 和 User-Agent 标头进行屏蔽就行了.

屏蔽违法扫描黑产:Netcraft,导致cf封号元凶

Netcraft 作为一个「臭名昭著」的网络安全服务供应商, 自然是有可以查询的 ASN, IP 和部分 User-Agent:

  • ASN: AS212329

  • IP: IP address block list from PhishKit.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    #  NETCRAFT IP RANGES

    194.52.68.0-194.52.68.255
    194.72.238.0-194.72.238.255
    83.138.182.72-83.138.182.79
    83.138.189.96-83.138.189.103
    81.91.240.0-81.91.255.255
    89.36.24.0-89.36.31.255
    83.222.232.216-83.222.232.218
    184.172.0.0-184.173.255.255
  • User-Agent: Netcraft Ltd. | UserAgents.io

    1
    2
    3
    4
    5
    6
    Mozilla/5.0 (compatible; NetcraftSurveyAgent/1.0; [email protected])    
    Mozilla/4.0 (compatible; Netcraft Web Server Survey)
    Mozilla/5.0 (compatible; NetcraftSurveyAgent/1.0/cc-prepass-https; [email protected])
    Netcraft SSL Server Survey - contact [email protected]
    NETCRAFT
    Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; Netcraft SSL Server Survey - contact [email protected])

对应写成 Cloudflare 的 WAF 规则就是:

1
(ip.geoip.asnum eq 212329) or (ip.src in {194.52.68.0/24 194.72.238.0/24 83.138.182.72/29 83.138.189.96/29 81.91.240.0/24 89.36.24.0/24 83.222.232.216/30 184.172.0.0/16}) or (http.user_agent eq "Mozilla/5.0 (compatible; NetcraftSurveyAgent/1.0; [email protected])") or (http.user_agent eq "Mozilla/4.0 (compatible; Netcraft Web Server Survey)") or (http.user_agent eq "Mozilla/5.0 (compatible; NetcraftSurveyAgent/1.0/cc-prepass-https; [email protected])") or (http.user_agent eq "Netcraft SSL Server Survey - contact [email protected]") or (http.user_agent eq "NETCRAFT") or (http.user_agent eq "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; Netcraft SSL Server Survey - contact [email protected])")

WAF 规则


看起来全站屏蔽了就完事了? 但是, 我要说但是了.

Netcraft 虽然是自己会主动用自家的 IP 去扫描站点, "也许" 会给你带上一眼明白的 User-Agent 给你知道这就是 Netcraft 的流量. 但 Netcraft 也是接受众包的一家网络安全服务提供商, 也就是任何人都可以在 Netcraft 举报滥用, 然后 Netcraft 直接发给自己的客户或者网络服务商.

这些众包成员有大部分都是其他机构, 大大小小的都有:

Stats - Top Reporters Leaderboard

前几天我在我的其他站的防火墙里面就发现了其中的一家众包成员(甚至还是非盈利项目):

About ducks.party Internet Scanner Bot

除掉这些众包成员, 我们也没办法排除掉人工去检查然后报告给类似 Netcraft 的机构的人的流量, 所以我的建议依旧是做好最基本的鉴权才是硬道理. 毕竟换个位置思考一下, 如果你是站长, 而你的站突然被人莫名其妙反代了, 甚至可能被用来钓鱼你站内的用户, 你会怎么办?

虽然我们只是反代一个自己的 SharePoint, 一开始就没有打算拿来做钓鱼这类完全欺诈的真滥用, 但是在没有做鉴权之前, 允许反代了整个 SharePoint 根路径直接暴露在公网上, 我们也办法就这样声明自己不是拿来做欺诈用途的, 毕竟在第三方看来, 这就和欺诈没什么两样.