使用 CloudFlare Workers 实现 CORS Anywhere

  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')); // 拒绝
  }
});