no7.space
🤔

やっぱりリンクカードのリンク先をちゃんと検証する

前回リンクカードを作る際に、next/image を使うように修正しましたが、その際に、画像読み込みを全て許可するのはよろしくないってなってて、でもブログだし、OGP画像だし…って思ってたんですけど、やっぱりちゃんと対策を考えることにしました。

というのも、このままだと SSRF 攻撃のリスクがあるためです。

next/image の remotePatterns はホワイトリスト方式

さて、ちゃんと許可したドメインのみを認めるようにするとなると、 next.config.tsimages/remotePatterns をひたすら増やすことになります。
所謂ホワイトリスト方式(今は allow list とか言わないといけないんでしたっけ?)です。

ですが、それはできれば避けたい。

かといってこの設定はブラックリスト(ブロックリスト)方式で設定する手段はなさそうでした。

ないなら渡す前に無害化すればいいじゃない

ということで、今回は next/image に画像を渡すよりも前の時点でフィルタする方式にします。

具体的には、 cheerio で og:image の値を見つけた時点で、その URL があやしいものじゃないかを検証し、問題なければそのまま、URLの設定があっても怪しい場合は undefined にすげ替えて見つからなかった体にする、というふうにすることにしました。

画像表示側は、undefined なら Image を呼ばないようにしてるのでこれで大丈夫なはず。

具体的には、

-metadata.image = $('meta[property="og:image"]').attr('content')
+metadata.image = sanitizeExternalUrl($('meta[property="og:image"]').attr('content'))

としてあげて、この sanitizeExternalUrl を今から作っていきます。

要件

  • cheerio で見つけた og:image 要素の値が飛んでくる
  • 多分 URL か、見つからないなら undefind
  • なので、怪しいURLじゃないかを判断する
  • 怪しいURLの基準は以下とする
    • localhost
    • IPアドレス
    • スキーマが http https 以外

ということで、つくりました

type SchemePattern = string
type HostPattern = RegExp

/**
 * 許可するスキーマのリスト
 */
const allowedList: SchemePattern[] = ['http:', 'https:']

/**
 * 禁止するホストパターン
 *  localhost とか IP アドレスとか
 *  IPアドレスの正規表現参照:
 *    https://lukehaas.me/projects/regexhub/
 */
const invalidHosts: HostPattern[] = [
  /^localhost/i,
  // IPv4アドレス
  /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
  // IPv6アドレス
  /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))$/,
]

const sanitizeExternalUrl = (url?: string): string | undefined => {
  if (!url) return undefined

  try {
    const parsed = new URL(url)
    const parsedProtocol = parsed.protocol.toLowerCase()
    const parsedHost = parsed.hostname.toLowerCase()

    // スキーマチェック
    if (!allowedList.includes(parsedProtocol)) {
      return undefined
    }

    // ホスト部が禁止パターンにマッチするかチェック
    if (invalidHosts.some((host) => host.test(parsedHost))) {
      return undefined
    }
  } catch {
    // URLパースできない場合は除外
    return undefined
  }

  return url
}

export default sanitizeExternalUrl

IPアドレスの正規表現は、Useful Regex Patterns からお借りしました。
IPv6のパターンはまさかの613文字。やべぇ

やってる事自体は非常に簡単で、あらかじめ用意していたリストと照らし合わせて、

  • スキーマに関しては、条件にマッチするものだけ
  • ホスト名については条件にマッチしないものだけ

をそれぞれ通すようにしています。

問題ない場合は URL をそのまま返すし、そうでない場合は undefined を返します。
これは cheerio で狙った要素の値が取れない場合 undefined になってたので、 sanitizeExternalUrl の戻りも、文字列 or undefined としておけばこれ以降の実装は今までのままいじる必要がないためです。

これで怪しいURLが渡された時にそれを読み込んでしまってトラブルになることが防げるようになる(はず…!)

ちなみにスキーマリストは2つだけなのになんで分けてるのかと言うと、当初はブラックリスト方式でNGなスキーマ(file: とか javascript: とか)を羅列していたんですが、よくよく考えてみたら http:https: だけあれば十分だよなぁってなって、どんどん削って、ロジック反転させたって経緯があるためです。

SSRF について

SSRF (Server-Side Request Forgery・サーバーサイドリクエストフォージェリ)はサーバーに成り代わってリクエスト送信を共用する攻撃手段だそうです。
フォージェリは「偽造」という意味で、リクエストフォージェリでリクエストの偽造というわけですね。
IT業界だと CSRF(Cross-Site Request Forgery・クロスサイトリクエストフォージェリ)のほうがよく聞くかも。

SSRF についての解説はこちらの記事がわかりやすかったです。