User-AgentとCORSエラーの関係性について確認してみた

2024.01.08

CX事業本部@大阪の岩田です。

一部のブラウザでフロントエンドのアプリからクロスオリジンでAPIをコールする際にFailed to load resource: Request header field User-Agent is not allowed by Access-Control-Allow-Headers.というエラーが発生しており、その原因調査を行う機会がありました。このエラーはフロントエンドからfetchを実行する際にUser-Agentが指定されており、ブラウザが自動設定するのとは異なるUser-Agentが利用されていたことに起因していました。これまでサーバーサイドエンジニアとしてLambdaやAPI GWのレスポンスにAccess-Control-Allow-Headersを設定する機会はありましたが、User-Agentを設定した経験は無かったので、CORSについて再確認する良い機会になりました。このブログでは調査した内容について共有させて頂きます。

環境

今回検証に利用したブラウザのバージョンは以下の通りです。すべてMac版となります。

  • Chrome: 120.0.6099.199
  • Firefox: 121.0
  • Safari: 17.1 (18616.2.9.11.9, 18616)

各ブラウザの開発者ツールのコンソールからfetchを実行しますが、対象のエンドポイントからは以下のレスポンスが返却されるようにAPI GW & Lambdaを用意しています。

  • Access-Control-Allow-Origin: *
  • Access-Control-Allow-Headers: Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token
  • Access-Control-Allow-Methods: DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT

※Preflightリクエストのレスポンス、実際のリクエストへのレスポンス共に上記のレスポンスヘッダを設定しています

ブラウザからfetchする際にUser-Agentは上書きできるのか?

冒頭に述べたように、フロントエンドのアプリがfetchする際にブラウザのデフォルトとは異なるUser-Agentを指定していたことによりエラーが誘発されていました。そもそもfetch時にUser-Agentが上書きできるのかについて仕様面から確認してみました。

MDNのForbidden header nameには以下のように記述されており、Chrome以外のブラウザではfetch時にUser-Agentを上書きできるようです。

The User-Agent header is no longer forbidden, as per spec — see forbidden header name list (this was implemented in Firefox 43) — it can now be set in a Fetch Headers object, or with the setRequestHeader() method of XMLHttpRequest. However, Chrome will silently drop the header from Fetch requests (see Chromium bug 571722).

Firefoxの開発者ツールのコンソールタブからfetch('<API GWのエンドポイント>',{headers:{"User-Agent":"my ua"}})というコードを実行し、実際に試してみました。ネットワークタブを確認すると、以下のようにUser-Agentにmy uaが指定されてリクエストが発行されていることが分かります。

FirefoxでUser-Agentを上書きしてfetchを実行

Safariでも同様の結果でした。

SafariでUser-Agentを上書きしてfetchを実行

Chromeに関してはMDNの記述の通りデフォルトのUser-Agentが利用されていました。

User-Agentと「Simple requests」の関係性

先程のFirefoxの画像を見れば分かるのですが、コンソールからのfetchリクエストはCORSエラーで失敗していました。しかし、実行するコードを単純にfetch('<API GWのエンドポイント>')にすればエラー無しで成功します。エラー時の画像と見比べれば分かるのですが、成功するパターンだとOPTIONSメソッドによるPreflightリクエストが発行されていないことが分かります。

Simple requestではPreflightリクエストが発行されない

これはUser-Agentを上書きしないリクエストがSimple requestsであるため、Preflightリクエスト無しで送信できていると考えられます。MDNのSimple requestsに関する説明には以下のように記載されています。

...略

Apart from the headers automatically set by the user agent (for example, Connection, User-Agent, or the other headers defined in the Fetch spec as a forbidden header name), the only headers which are allowed to be manually set are those which the Fetch spec defines as a CORS-safelisted request-header, which are:

...略

これだけだとリクエストヘッダのUser-Agentについてどう扱われるのか分かりづらいですが、上記の記述の少し前に以下のような記述もありました。

The motivation is that the

element from HTML 4.0 (which predates cross-site fetch() and XMLHttpRequest) can submit simple requests to any origin, so anyone writing a server must already be protecting against cross-site request forgery (CSRF). Under this assumption, the server doesn't have to opt-in (by responding to a preflight request) to receive any request that looks like a form submission, since the threat of CSRF is no worse than that of form submission. However, the server still must opt-in using Access-Control-Allow-Origin to share the response with the script.

つまりHTMLのformをsubmitした時に発生し得るリクエストであればPreflightリクエストが発生しないと考えて良さそうです。普通にformをsubmitすればUser-Agentはブラウザがデフォルトで設定するUser-Agentになるため、ブラウザのデフォルトではないUser-Agentが指定されたリクエストはformのsubmitによって発生したものではないと判定されPreflightリクエストが発生するということだと理解しました。

改めてまとめると、単純にGETするだけのfetchはSimple requestsになるが、User-Agentが上書きされたfetchはSimple requestsにならないということです。

冒頭に記載した、「一部のブラウザでのみエラーが発生していた」という事象は「fetch時にUser-Agentが上書きできるブラウザでのみエラーがしていた」と考えて良さそうです。

Access-Control-Allow-HeadersにUser-Agentを追加してみる

ここまででエラーの原因が切り分けられたので、Preflightリクエストに対するレスポンスヘッダに指定するAccess-Control-Allow-HeadersにUser-Agentを追加して振る舞いを再確認してみましょう。結果は以下のようにfetchが成功するようになりました。

レスポンスヘッダのAccess-Control-Allow-HeadersにUser-Agentを追加するとfetchが成功するように

まとめ

Failed to load resource: Request header field User-Agent is not allowed by Access-Control-Allow-Headers.というエラーに関する調査の紹介でした。とりあえずエラーが発生する仕組みについては理解できたのですが、エラーを解消するためのアプローチとしてそもそもfetch時にUser-Agentを上書きする必要があるのか?という点は要検討だと思います。サーバーサイドのログを見てもユーザーが実際に利用していたブラウザが何か分からなくなってしまうので...(User-Agentは偽装可能ですが、悪意の無い通常のユーザーはそんなことしないので)

同様のエラーに遭遇した方の参考になれば幸いです。

参考