为什么Ghost会无限301 Redirect?

2023年1月11日 · 1 year ago

前阵子我们在用的Newsletter服务Revue宣布要关闭了:

我们只能寻找其他替代品。在平台还是自建两者之间,最后还是选择了自建,我曾经试过用Wordpress,但确实这套古老的系统多年没有特别大的进化,后来Newsletter编辑部的伙伴提议用Ghost,看上去确实不错。

Ghost项目是由前Wordpress UI团队负责人John O\'Nolan离职后创办的。2012年他在启动项目的Blog上说道:

WordPress is so much more than just a blogging platform

我只能说Can\'t agree no more.

Ghost作为一个平台使用起来非常简单,设计感非常棒,当然官方host的价格也很喜人。好在Ghost是开源的项目,愿意动手折腾的也可以鼓捣起来。

周末鼓捣Ghost的时候就发现,它在setup完https证书之后就会无限301 redirect loop,最终问题是解决了但我想搞清楚到底是怎么回事。

1. 怀疑是Nginx开启了无限循环

先从Nginx配置下手。一个完整的Nginx Conf实例可以参考官网这里,理论上我们配置多个不同的server时,listen在80端口(http)和443端口(https),然后对80的server配置做host判断,直接全量转发到https。

server {
    if ($host = example.com) {
        return 301 https://$host$request_uri;
    } # managed by Certbot

    listen 80;
    listen [::]:80;
    server_name example.com;

    return 404; # managed by Certbot
}

现在有certbot配置起来很方便。我看了半天没发现Nginx有错配的情况。

最后看这篇文章提到需要增加这个配置:

proxy_set_header X-Forwarded-Proto https;

文章还提到Cloudflare的SSL规则也有可能导致无限301但我那时候域名已经从Cloudflare DNS转出了,所以跟Cloudflare无关。

Ghost使用NodeJS写的,默认运行端口是2368,我们在Nginx接入之后肯定要把请求转发的,官方的Ghost-CLI 在setup后却没有这一条:

location / {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme; #自己补上
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Host $http_host;
    proxy_pass http://127.0.0.1:2368;
}

参考Nginx官方文档相当于我们对入站的请求重定义了HTTP HEADER,其中 X-Forwarded-Proto 也是一个“事实标准”Header,可见Mozilla文档。文档称该Header主要用于客户端告知用于连接到代理的协议是用HTTP还是HTTPS。真正的标准详见Forwarded字段。

2. 为什么必须带X-Forwarded-Proto Header?

既然不是Nginx导致的301 redirect,那就是Ghost咯?

让我们翻一下Ghost的代码看看。原来Ghost用的ExpressJS做Server,其中 ghost/core/core/shared/express.js 的代码有这样一段:

// Make sure 'req.secure' is valid for proxied requests
// (X-Forwarded-Proto header will be checked, if present)
app.enable('trust proxy');

好的现在压力给到了Express.js这边,我们看看express的代码,这个'trust proxy'都干了些啥。

lib/application.js里面,app.set这个方法针对trust proxy写入配置:

  switch (setting) {
    case 'etag':
      this.set('etag fn', compileETag(val));
      break;
    case 'query parser':
      this.set('query parser fn', compileQueryParser(val));
      break;
    case 'trust proxy':
      this.set('trust proxy fn', compileTrust(val));

      // trust proxy inherit back-compat
      Object.defineProperty(this.settings, trustProxyDefaultSymbol, {
        configurable: true,
        value: false
      });

      break;
  }

其中compileTrust()方法里面又是通过proxy-addr完成的,项目在这里

这一步是把IP地址描述进行翻译,比如:

app.set('trust proxy', 'loopback') // 一个子网描述
app.set('trust proxy', 'loopback, 123.123.123.123') // 一个子网加一个IP
app.set('trust proxy', 'loopback, linklocal, uniquelocal') // 多个子网
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']) // 多个子网

后面这部分参数就会被compile()函数处理。Ghost的代码里:

app.enable('trust proxy');
// 等价于如下代码
app.set('trust proxy', true);

所以后面的参数为空,说明 trust everything。因为express是被代理过来的,所以后续获得客户端请求的真实IP之类的信息还得靠proxyaddr来解析。

所以不仅需要X-Forwarded-Proto,事实上,Ghost-Cli的Nginx配置模板是配齐了几个关键字段的:

location  {
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Host $http_host;
    proxy_pass http://127.0.0.1:;
    proxy_redirect off;
}

3. Ghost是怎么把http请求转发给https的?

Ghost的配置文件使用nconf管理,当我们在Ghost目录下配置http协议的URL时不会有无限redirect的问题,但是https的会。

即:如果你配置了网站url为https://example.com,但是访问了http://example.com,Ghost会自动帮你redirect到https。

具体实现在ghost/core/core/server/web/shared/middleware/url-redirects.js,核心函数是这个:

/**
 * Takes care of
 *
 * 1. required SSL redirects
 */
_private.getFrontendRedirectUrl = ({requestedHost, requestedUrl, queryParameters, secure}) => {
    const siteUrl = urlUtils.urlFor('home', true);

    debug('getFrontendRedirectUrl', requestedHost, requestedUrl, siteUrl);

    // CASE: configured canonical url is HTTPS, but request is HTTP, redirect to requested host + SSL
    if (urlUtils.isSSL(siteUrl) && !secure) {
        debug('redirect because protocol does not match');

        return _private.redirectUrl({
            redirectTo: https://${requestedHost},
            pathname: requestedUrl,
            query: queryParameters
        });
    }
};

Ghost有给用户看的前端页面(FrontendApp)和给管理员用的后端管理页面(AdminApp),前端页面启动的时候也是启动一个Express Server,然后使用上述middleware,检查每一次请求是不是secure。这个secure与否来自req.secure,而这个req.secure在启动了trust proxy的情况下,会取Header里的X-Forwarded-Proto作为依据。代码在Express项目的lib/request.js文件里,defineGettter里关于protocolsecure这两个函数。

defineGetter(req, 'protocol', function protocol(){
  var proto = this.connection.encrypted
    ? 'https'
    : 'http';
  var trust = this.app.get('trust proxy fn');

  if (!trust(this.connection.remoteAddress, 0)) {
    return proto;
  }

  // Note: X-Forwarded-Proto is normally only ever a
  //       single value, but this is to be safe.
  var header = this.get('X-Forwarded-Proto') || proto
  var index = header.indexOf(',')

  return index !== -1
    ? header.substring(0, index).trim()
    : header.trim()
});

从上述代码可知,express会先检查this.connection.encrypted属性,我猜Nginx的转发是不加密的,因为本地express server只是打开了一个http服务,Nginx转发给它确实也不需要加密。于是就会跳到取Header这一步。所以如果我们的Nginx配置不带X-Forwarded-Proto,express就会认为客户端真实请求不是https,于是redirect给https,结果就是无限循环。

4. What's next?

虽然Ghost这个问题解决起来很简单,“改个配置就好啦”。但是经过层层追踪,查看各个项目代码的过程也很有意思。在这篇文章里,作者有提到x-forwarded-proto会被Ghost检查,但是既然整个服务涉及Nginx,Ghost,Express,我就想弄明白到底问题出在什么地方。

通过这次追查也初窥了Ghost项目的大致架构,过程还是挺有趣的,不过看起来Ghost还不是特别开放,毕竟平台hosting才是这个项目最赚钱的部分。Wordpress这么多年,现在已经变成一个颇具历史包袱的巨无霸,这些年各种“替代开源CMS”也层出不穷,但是也起起落落落,消失了不少。看到Ghost现在能赚钱我感觉还是很棒的,唯有持续盈利,才能一直走下去。

Ghost的设计感确实在目前的开源CMS中无出其右,希望Ghost能一直长青。