node.js (Express) でのオープンリダイレクト脆弱性対策

この記事は、[WESEEK Tips Wiki](https://tips.weseek.co.jp) に投稿された記事「[/Tips/JavaScript/Express/オープンリダイレクト対策](https://tips.weseek.co.jp/5e5d51463051ce00499cac59)」の転載です。

# オープンリダイレクトとは？

- [WESEEK Tips Wiki: /用語集/オープンリダイレクト](https://tips.weseek.co.jp/5e5d34c83051ce00499cac56)

# Express に於けるリダイレクト

[Express 4.x API#res.redirect](http://expressjs.com/en/api.html#res.redirect)

- URL パラメータ等、ユーザーが指定可能な文字列をそのままリダイレクトさせるコード書くとセキュリティホールとなる
- `res.redirect('//evil.example.com/path/to/attack')` で再現可能
    - -> `http://evil.example.com/path/to/attack` にリダイレクトされる

# 過去実際にあった脆弱性

- GROWI v3.4.6 以前にオープンリダイレクトの脆弱性が含まれていた
    - [GROWI 脆弱性対応のお知らせ (JVN#84876282) ](https://weseek.co.jp/security/2019/06/04/growi-fix-jvn84876282/)


# 対策

## 方針

1. URL をパースし、不正な形式(`//evil.example.com` 等)であればリダイレクトしない
2. URL が、リクエストのホストと不一致ならリダイレクトしない
3. URL が、ホワイトリストに入っていなければリダイレクトしない

## コード

下記コードは、要求された URL へリダイレクトしない代わりに、サイトトップ(`/`)へのリダイレクトに振り替えている。

### 最小の対策(方針1,2まで)


```javascript:middleware/safe-redirect.js
/**
 * Redirect with prevention from Open Redirect
 *
 * Usage: app.use(require('middleware/safe-redirect')())
 */
const logger = request('path/to/logger');


module.exports = () => {

  return function(req, res, next) {

    // extend res object
    res.safeRedirect = function(redirectTo) {
      if (redirectTo == null) {
        return res.redirect('/');
      }

      try {
        // check inner redirect
        const redirectUrl = new URL(redirectTo, `${req.protocol}://${req.get('host')}`);
        if (redirectUrl.hostname === req.hostname) {
          logger.debug(`Requested redirect URL (${redirectTo}) is local.`);
          return res.redirect(redirectUrl.href);
        }
        logger.debug(`Requested redirect URL (${redirectTo}) is NOT local.`);
      }
      catch (err) {
        logger.warn(`Requested redirect URL (${redirectTo}) is invalid.`, err);
      }

      logger.warn(`Requested redirect URL (${redirectTo}) is UNSAFE, redirecting to root page.`);
      return res.redirect('/');
    };

    next();

  };

};
```

#### 利用方法

```javascript
app.use(require('middleware/safe-redirect')());
```

### ホワイトリスト指定機能付き(方針3まで)

```javascript:middleware/safe-redirect.js
/**
 * Redirect with prevention from Open Redirect
 *
 * Usage: app.use(require('middleware/safe-redirect')(['example.com', 'some.example.com:8080']))
 */
const logger = request('path/to/logger')('middleware:safe-redirect');


/**
 * Check whether the redirect url host is in specified whitelist
 * @param {Array<string>} whitelistOfHosts
 * @param {string} redirectToFqdn
 */
function isInWhitelist(whitelistOfHosts, redirectToFqdn) {
  if (whitelistOfHosts == null || whitelistOfHosts.length === 0) {
    return false;
  }

  const redirectUrl = new URL(redirectToFqdn);
  return whitelistOfHosts.includes(redirectUrl.hostname) || whitelistOfHosts.includes(redirectUrl.host);
}


module.exports = (whitelistOfHosts) => {

  return function(req, res, next) {

    // extend res object
    res.safeRedirect = function(redirectTo) {
      if (redirectTo == null) {
        return res.redirect('/');
      }

      try {
        // check inner redirect
        const redirectUrl = new URL(redirectTo, `${req.protocol}://${req.get('host')}`);
        if (redirectUrl.hostname === req.hostname) {
          logger.debug(`Requested redirect URL (${redirectTo}) is local.`);
          return res.redirect(redirectUrl.href);
        }
        logger.debug(`Requested redirect URL (${redirectTo}) is NOT local.`);

        // check whitelisted redirect
        const isWhitelisted = isInWhitelist(whitelistOfHosts, redirectTo);
        if (isWhitelisted) {
          logger.debug(`Requested redirect URL (${redirectTo}) is in whitelist.`, `whitelist=${whitelistOfHosts}`);
          return res.redirect(redirectTo);
        }
        logger.debug(`Requested redirect URL (${redirectTo}) is NOT in whitelist.`, `whitelist=${whitelistOfHosts}`);
      }
      catch (err) {
        logger.warn(`Requested redirect URL (${redirectTo}) is invalid.`, err);
      }

      logger.warn(`Requested redirect URL (${redirectTo}) is UNSAFE, redirecting to root page.`);
      return res.redirect('/');
    };

    next();

  };

};
```