[CloudFront+S3]HTTPレスポンスヘッダのContent-Typeにcharset=UTF-8を指定する

2021.08.22

吉川@広島です。

CloudFront+S3なSPAにLambda@Edge(もしくはCloudFront Functions)でセキュリティに関するレスポンスヘッダを追加する、というのはよくやると思います。

その中で、今回は、

安全なウェブサイトの作り方 - 1.5 クロスサイト・スクリプティング:IPA 独立行政法人 情報処理推進機構

で紹介されている、

HTTPレスポンスヘッダのContent-Typeフィールドに文字コード(charset)を指定する。

に対応してみました。

具体的な危険性は、

HTTPのレスポンスヘッダのContent-Typeフィールドには、「Content-Type: text/html; charset=UTF-8」のように、文字コード(charset)を指定できます。この指定を省略した場合、ブラウザは、文字コードを独自の方法で推定して、推定した文字コードにしたがって画面表示を処理します。たとえば、一部のブラウザにおいては、HTMLテキストの冒頭部分等に特定の文字列が含まれていると、必ず特定の文字コードとして処理されるという挙動が知られています。

ということのようです。そのため、

したがって、この問題の解決策としては、Content-Typeの出力時にcharsetを省略することなく、必ず指定することが有効です。

こちらが解消策になるようなので、

  • HTMLを返す際、Content-Typeヘッダにcharset=UTF-8を付与する

をゴールとしてLambda@Edgeで対応する方法を考えます。下記を参考に、Content-Typeをカスタマイズしてみました。

Lambda@Edgeを使ってX-Frame-Optionsヘッダを追加してみた | DevelopersIO

サンプルプロジェクト作成

Getting Started | Vite

S3+CloudFront上にホスティングするSPAの想定なので、viteのreact-tsテンプレートからサンプルプロジェクトを生成し、S3+CloudFrontにデプロイしました。

npm init vite@latest sample-project -- --template react-ts

方法1 Lambda@Edge

まずLambda@Edgeを使う場合です。以下の内容でorigin reponseとしてLambda@Edgeをデプロイします。

'use strict'
exports.handler = (event, context, callback) => {
  const response = event.Records[0].cf.response
  const headers = response.headers

  if (headers['content-type']?.[0]?.value === 'text/html') {
    headers['content-type'] = [
      { key: 'Content-Type', value: 'text/html; charset=UTF-8' },
    ]
  }

  callback(null, response)
}

意図通り動作してくれました。

注意(失敗コード)

ちなみに、上の例のif文をなくして無条件に text/html; charset=UTF-8 を代入した場合も試してみます。

'use strict'
exports.handler = (event, context, callback) => {
  const response = event.Records[0].cf.response
  const headers = response.headers

  // if (headers['content-type'][0].value === 'text/html') がない場合
  headers['content-type'] = [
    { key: 'Content-Type', value: 'text/html; charset=UTF-8' },
  ]

  callback(null, response)
}

この内容でLambda@Edgeにデプロイしました。すると、Chromeで表示した際に次のようなエラーとなり、JSが実行できず画面全体は真っ白となってしまいました。

vendor.412157d2.js:1 Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.
index.85056a6b.js:1 Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.

DeveloperToolのNetworkを見ると原因がわかりました。

ページ自体はtext/htmlで返っています。これは意図通りです。

問題はこちら。JSファイルもtext/htmlで返してしまっています。これは当然おかしいので、

  • text/htmlの場合のみ値を text/html; charset=UTF-8 とする

という条件が必要になるというわけでした。

方法2 CloudFront Functions

書き方が若干異なりますが、CloudFront Functionsも同じように実現できます。viewer responseとしてデプロイします。

function handler(event) {
  var response = event.response
  var headers = response.headers

  if (headers['content-type'].value === 'text/html') {
    headers['content-type'] = { value: 'text/html; charset=UTF-8' }
  }

  return response
}

細かい点ですが、CloudFrontFunctionsは原則ES5なので、Lambda@Edgeの時とは異なりoptional chainingは使っていません。

方法3 S3へのアップロード時にメタデータを指定する

Lambda@EdgeやCloudFrontFunctionsを使わず、そもそもS3にアップロードするタイミングでContent-Typeを指定しておく方法もあります。

例えば、デプロイにs3 syncコマンドを使っているのであれば、次のような手順となります。

# まずHTML以外をアップロードする
aws s3 sync /path/to/dist s3://{BUCKET_NAME} \
--exclude *.html

# Content-Typeを'text/html; charset=UTF-8'と指定してHTMLのみアップロードする
aws s3 sync /path/to/dist s3://{BUCKET_NAME} \
--exclude '*' \
--include *.html \
--content-type 'text/html; charset=UTF-8'

これでS3に保存されているファイル自体のメタデータについて Content-Type: text/html; charset=UTF-8 とすることができました。ブラウザからレスポンスヘッダを確認した場合も、しっかりこの値は反映されていました。

参考