CloudFlare Workers可以实现很多需求,通过它来代理访问非公开、未设置 CORS 头的 API 也在官方的示例项目内。不过由于免费 plan 的访问量限制以及性能限制等,一般用来给自己的实验性项目做一下简单代理。这里以Bangumi 的 API为例演示完整的实现。
目标需求
Bangumi 的 API 的基础 URL 为https://api.bgm.tv。
Workers 前期设置
Workers 可以直接使用 CloudFlare 提供的域名,不过一般还是绑定自己的域名使用,这种情况下需要一个通过 CloudFlare 提供 DNS 解析的域名。演示中我们以worker.example.org为例 (假设example.org是我托管在 CF 的域名)。将worker.example.org解析到任意页面 (演示中解析到了不使用的dsrkafuu.github.io) 并通过 CloudFlare 代理流量即可。
完成之后创建一个新的 worker,并开始准备代码。Workers 的完整文档见CloudFlare Docs。
工具函数
首先我们需要限制访问请求的来源,防止有限的 worker 访问次数被他人简单刷空,因此定义一个检查来源的函数
// 允许的 CORS 来源
const ALLOWED_ORIGIN = [/^https?:\/\/.*dsrkafuu\.su$/, /^https?:\/\/localhost/];
// 是否拒绝所有无 Origin 请求
const ALLOW_NO_ORIGIN = false;
/**
* 验证 Origin
* @param {Request} req
* @return {boolean}
*/
function validateOrigin(req) {
const origin = req.headers.get('Origin');
if (origin) {
for (let i = 0; i < ALLOWED_ORIGIN.length; i++) {
if (ALLOWED_ORIGIN[i].exec(origin)) {
return true;
}
}
}
return ALLOW_NO_ORIGIN; // 是否拒绝所有无 Origin 请求
}
由于我们所有的 worker 都在同一个worker.example.org域名下部署,因此指定worker.example.org/bgm作为这次代理的根 URL,接下来我们就需要提取出正确的 pathname 来发送给被代理的 API
// 代理子路径
const PROXY_PATH = /^\/bgm(\/?.*)/;
/**
* 解析 API 请求路径
* @param {Request} req
* @return {string|null}
*/
function validatePath(req) {
const url = new URL(req.url);
const path = url.pathname;
const exp = PROXY_PATH.exec(path);
// `api.bgm.tv/data` => `workers.example.org/bgm/data`
// `api.bgm.tv/` => `workers.example.org/bgm`
if (exp && exp.length > 1) {
return exp[1] || '';
}
return null;
}
响应 CORS 预检请求
对于所有 CORS 非简单请求的OPTIONS预检请求,单独写一个处理函数:
/**
* 响应 CORS OPTIONS 请求
* @param {Request} req 源请求
* @return {Response}
*/
function handleOptions(req) {
const rawOrigin = req.headers.get('Origin');
const rawMethod = req.headers.get('Access-Control-Request-Method');
const rawHeaders = req.headers.get('Access-Control-Request-Headers');
const res = new Response(null, { status: 200 });
res.headers.set('Access-Control-Allow-Origin', rawOrigin);
rawMethod && res.headers.set('Access-Control-Allow-Methods', rawMethod);
rawHeaders && res.headers.set('Access-Control-Allow-Headers', rawHeaders);
res.headers.set('Access-Control-Max-Age', 86400);
// 设置 Vary 头使浏览器正确进行缓存
res.headers.append('Vary', 'Accept-Encoding');
res.headers.append('Vary', 'Origin');
return res;
}
关于 Vary 头的设置
我们有两种返回 CORS 响应的方法,第一种是无条件型 CORS 响应,即Access-Control-Allow-Origin固定返回*(允许任意网站访问) 或者返回特定的一个源网址。在这种情况下一般是不会有问题的。
第二种情况就是条件型 CORS 响应:如果请求没有Origin头,那么响应就不包含Access-Control-Allow-Origin;如果请求有Origin头但不在我们的允许范围内,那么也不包含Access-Control-Allow-Origin来拒绝跨域请求;如果请求有被允许Origin头,那我们就返回这个Origin头作为对应的Access-Control-Allow-Origin。
默认情况下浏览器以 URL 为 key 进行缓存。假设我们允许所有*.example.org下对资源api.org/data的访问。我们在a.example.org访问了这个资源,请求带的Origin头自然就是a.example.org,返回的Access-Control-Allow-Origin自然也就是a.example.org;但当我们又通过b.example.org访问这个资源,请求带的Origin头自然就是b.example.org,但由于 URL 没变,浏览器直接返回缓存的资源,其中的Access-Control-Allow-Origin是之前的a.example.org,跨域错误就出现了。
因此我们将对资源api.bgm.tv/data的访问响应头中的Vary设置为Origin,这样除去 URL 之外,还会以Origin头的信息来选择是否使用缓存。
同样的,这个头也可以用Vary: User-Agent来防止移动客户端误用桌面缓存,具体见:MDN。
我们这里拒绝了所有无Origin的请求,并且返回响应为条件型 CORS 响应,因此是需要注意Vary的设置的。
响应 CORS 请求
我们准备一个函数,负责给源 API 返回的 response 添加 CORS 头。
我们需要用新的 Request 对象覆盖源请求来让它指向真正的 API,而不是我们的worker.example.org/bgm。在构建新对象时,同时这也让这个 Request 对象变为了可修改 (mutable) 状态,这样我们就能手动设置其 header 中的Origin,让 Bangumi 的服务器认为这不是跨域请求而放行。同样的,我们也需要对 Bangumi 服务器返回的 Response 对象进行覆盖来修改 header。
// 无 trail 的 URL
const API_BASE = 'https://api.bgm.tv';
// 伪造请求头
const FAKE_ORIGIN = 'https://bgm.tv';
const FAKE_REFERRER = 'https://bgm.tv/calendar';
// 缓存控制
const CACHE_CONTROL = 'public, no-cache, must-revalidate';
/**
* 响应 CORS 请求 + 请求伪装
* @param {Request} req 源请求
* @param {string} path 解析后的 API 请求路径 (非 null)
* @return {Response}
*/
async function handleRequest(req, path) {
const rawURL = new URL(req.url);
const rawOrigin = req.headers.get('Origin');
const rawQuerys = rawURL.searchParams;
// 迁移路径
const proxyURL = new URL(API_BASE);
proxyURL.pathname = (proxyURL.pathname + path).replace('//', '/'); // path 由 `/` 开头或为 ``
// 迁移 query
for (const [key, value] of rawQuerys) {
proxyURL.searchParams.append(key, value);
}
// 发起代理请求
req = new Request(proxyURL, req); // 覆盖源请求使其 mutable
// 伪装 Origin
req.headers.delete('Origin');
FAKE_ORIGIN && req.headers.set('Origin', FAKE_ORIGIN);
// 伪装 Referer
req.headers.delete('Referer');
FAKE_REFERRER && req.headers.set('Referer', FAKE_REFERRER);
// 获取响应
let res = await fetch(req);
res = new Response(res.body, res); // 覆盖响应 response 使其 mutable
res.headers.set('Access-Control-Allow-Origin', rawOrigin);
res.headers.set('Cache-Control', CACHE_CONTROL);
// 设置 Vary 头使浏览器正确进行缓存
res.headers.append('Vary', 'Accept-Encoding');
res.headers.append('Vary', 'Origin');
return res;
}
完成代理
最后添加一个拒绝请求的处理函数,通过 Workers 运行环境提供的 Event 进行相应设置即可:
/**
* 拒绝请求
* @return {Response}
*/
function handleReject() {
return new Response('[CloudFlare Workers] REQUEST NOT ALLOWED', {
status: 403,
});
}
addEventListener('fetch', (event) => {
// 获取请求的信息
const req = event.request;
// 验证和解析
const validOrigin = validateOrigin(req);
const validPath = validatePath(req);
if (validOrigin && typeof validPath === 'string') {
if (req.method === 'OPTIONS') {
event.respondWith(handleOptions(req));
} else {
event.respondWith(handleRequest(req, validPath));
}
} else {
event.respondWith(handleReject());
}
});
完整代码
/*! cloudflare-workers-cors-anywhere | DSRKafuU (https://dsrkafuu.net) | Copyright (c) MIT License */
// 无 trail 的 URL
const API_BASE = 'https://api.bgm.tv';
// 代理子路径
const PROXY_PATH = /^\/bgm(\/?.*)/;
// 伪造请求头
const FAKE_ORIGIN = 'https://bgm.tv';
const FAKE_REFERRER = 'https://bgm.tv/calendar';
// 允许的 CORS 来源
const ALLOWED_ORIGIN = [/^https?:\/\/.*dsrkafuu\.su$/, /^https?:\/\/localhost/];
// 是否拒绝所有无 Origin 请求
const ALLOW_NO_ORIGIN = false;
// 缓存控制
const CACHE_CONTROL = 'public, no-cache, must-revalidate';
/**
* 验证 Origin
* @param {Request} req
* @return {boolean}
*/
function validateOrigin(req) {
const origin = req.headers.get('Origin');
if (origin) {
for (let i = 0; i < ALLOWED_ORIGIN.length; i++) {
if (ALLOWED_ORIGIN[i].exec(origin)) {
return true;
}
}
}
return ALLOW_NO_ORIGIN; // 是否拒绝所有无 Origin 请求
}
/**
* 解析 API 请求路径
* @param {Request} req
* @return {string|null}
*/
function validatePath(req) {
const url = new URL(req.url);
const path = url.pathname;
const exp = PROXY_PATH.exec(path);
// `api.bgm.tv/data` => `workers.example.org/bgm/data`
// `api.bgm.tv/` => `workers.example.org/bgm`
if (exp && exp.length > 1) {
return exp[1] || '';
}
return null;
}
/**
* 响应 CORS OPTIONS 请求
* @param {Request} req 源请求
* @return {Response}
*/
function handleOptions(req) {
const rawOrigin = req.headers.get('Origin');
const rawMethod = req.headers.get('Access-Control-Request-Method');
const rawHeaders = req.headers.get('Access-Control-Request-Headers');
const res = new Response(null, { status: 200 });
res.headers.set('Access-Control-Allow-Origin', rawOrigin);
rawMethod && res.headers.set('Access-Control-Allow-Methods', rawMethod);
rawHeaders && res.headers.set('Access-Control-Allow-Headers', rawHeaders);
res.headers.set('Access-Control-Max-Age', 86400);
// 设置 Vary 头使浏览器正确进行缓存
res.headers.append('Vary', 'Accept-Encoding');
res.headers.append('Vary', 'Origin');
return res;
}
/**
* 响应 CORS 请求 + 请求伪装
* @param {Request} req 源请求
* @param {string} path 解析后的 API 请求路径 (非 null)
* @return {Response}
*/
async function handleRequest(req, path) {
const rawURL = new URL(req.url);
const rawOrigin = req.headers.get('Origin');
const rawQuerys = rawURL.searchParams;
// 迁移路径
const proxyURL = new URL(API_BASE);
proxyURL.pathname = (proxyURL.pathname + path).replace('//', '/'); // path 由 `/` 开头或为 ``
// 迁移 query
for (const [key, value] of rawQuerys) {
proxyURL.searchParams.append(key, value);
}
// 发起代理请求
req = new Request(proxyURL, req); // 覆盖源请求使其 mutable
// 伪装 Origin
req.headers.delete('Origin');
FAKE_ORIGIN && req.headers.set('Origin', FAKE_ORIGIN);
// 伪装 Referer
req.headers.delete('Referer');
FAKE_REFERRER && req.headers.set('Referer', FAKE_REFERRER);
// 获取响应
let res = await fetch(req);
res = new Response(res.body, res); // 覆盖响应 response 使其 mutable
res.headers.set('Access-Control-Allow-Origin', rawOrigin);
res.headers.set('Cache-Control', CACHE_CONTROL);
// 设置 Vary 头使浏览器正确进行缓存
res.headers.append('Vary', 'Accept-Encoding');
res.headers.append('Vary', 'Origin');
return res;
}
/**
* 拒绝请求
* @return {Response}
*/
function handleReject() {
return new Response('[CloudFlare Workers] REQUEST NOT ALLOWED', {
status: 403,
});
}
addEventListener('fetch', (event) => {
// 获取请求的信息
const req = event.request;
// 验证和解析
const validOrigin = validateOrigin(req);
const validPath = validatePath(req);
if (validOrigin && typeof validPath === 'string') {
if (req.method === 'OPTIONS') {
event.respondWith(handleOptions(req));
} else {
event.respondWith(handleRequest(req, validPath));
}
} else {
event.respondWith(handleReject());
}
});
扩展实现
在此完整代码的基础上还可以进行不少改动,例如将类似API_BASE这样的常量替换为从 URL 参数获取,就可以实现通用的 CORS Anywhere 了。类似的思路还可以用于代理类似 Google Custom Search API 等内容,这里放一个示例,也是我的个人站目前正在用的:
/ 无 trail 的 URL
const API_BASE = 'https://www.googleapis.com/customsearch/v1';
// 代理子路径
const PROXY_PATH = /^\/(\/?.*)/;
// 允许的请求来源
const ALLOWED_ORIGIN = [/^https?:\/\/.*dsrkafuu\.su$/, /^https?:\/\/localhost/];
// 是否拒绝所有无 Origin 请求
const ALLOW_NO_ORIGIN = false;
// secrets
const API_KEY = 'A**********************************k';
const API_CX = '3***************e';
const blockedRes = (text) =>
new Response(`[dsr-blog] forbidden: ${text}`, { status: 403 });
const timeoutRes = (text) =>
new Response(`[dsr-blog] tequest timeout: ${text}`, { status: 408 });
/**
* 验证 Origin
* @param {Request} req
* @return {boolean}
*/
function validateOrigin(req) {
const origin = req.headers.get('Origin');
if (origin) {
for (let i = 0; i < ALLOWED_ORIGIN.length; i++) {
if (ALLOWED_ORIGIN[i].exec(origin)) {
return true;
}
}
}
return ALLOW_NO_ORIGIN; // 是否拒绝所有无 Origin 请求
}
/**
* 解析 API 请求路径
* @param {Request} req
* @return {string}
*/
function validatePath(req) {
const url = new URL(req.url);
const pathname = url.pathname;
const valid = PROXY_PATH.exec(pathname);
if (valid) {
return true; // 放行
}
return false; // 拒绝
}
/**
* 请求搜索结果
* @param {string} searchQuerys
* @returns {Promise<Response>}
*/
async function fetchGoogleAPI(searchQuerys) {
const url = new URL(API_BASE); // 构建一个新的 URL 对象 `googleapis.com`
url.searchParams.set('cx', API_CX); // 设置 cx
url.searchParams.set('key', API_KEY); // 设置 key
url.searchParams.set('q', searchQuerys); // 设置 q
const res = await fetch(url);
return res;
}
/**
* 响应请求
* @param {Request} req
* @returns {Response}
*/
async function handleReq(req) {
const url = new URL(req.url);
const searchQuerys = url.searchParams.get('q');
if (searchQuerys) {
try {
let res = await fetchGoogleAPI(searchQuerys);
// CORS
res = new Response(res.body, res); // 覆盖响应 response
res.headers.set(
'Access-Control-Allow-Origin',
req.headers.get('Origin') || '*'
); // 设置 CORS 头
res.headers.append('Vary', 'Origin'); // 设置 Vary 头使浏览器正确进行缓存
return res;
} catch {
return timeoutRes('internal Google API error occurred');
}
}
return blockedRes('no search querys found'); // 拒绝
}
addEventListener('fetch', (event) => {
const req = event.request; // 获取请求的信息
// 验证身份
const validOrigin = validateOrigin(req);
const validPath = validatePath(req);
// 验证通过
if (validOrigin && validPath) {
event.respondWith(handleReq(req)); // 响应
} else {
event.respondWith(blockedRes('origin or pathname not allowed')); // 拒绝
}
});