無印吉澤

ソフトウェア開発、運用管理(俗にいう DevOps)、クラウドコンピューティングなどについて、吉澤が調べたり試したことを書いていくブログです。

Blob をファイルとしてダウンロードさせるブックマークレットが「何もしてないのに壊れた」話

f:id:muziyoshiz:20180430135846p:plain

背景

私は、趣味で 艦これアーケードのプレイデータ管理ツール Admiral Stats というサービスを開発・運用しています。これは、

  • SEGA の艦これアーケード公式ページ から自分のプレイデータを JSON 形式でダウンロードするツール(通称エクスポータ)
  • プレイデータをアップロードしてもらい、それを可視化する Web アプリケーション

を組み合わせて実現しているサービスで、既に1年半以上運用しています。

このエクスポータは Ruby 版、PowerShell 版、ブックマークレット版を提供していて、一番使われているのはユーザにとって手軽なブックマークレット版です。去年9月の調査では、88%のユーザがブックマークレット版を使っていました。

ブックマークレット版のエクスポータにはさらに以下の2種類があります。いずれも、PCとスマホの両方で動く状態でした。

  • 自動アップロード用
    • エクスポートした JSON を、Admiral Stats に自動的にアップロードする
    • ダウンロード用よりもこちらの方が便利で、普通はこちらが使われている(はず)
  • ダウンロード用
    • エクスポートした JSON を、ローカルに .json ファイルとしてダウンロードする
    • Admiral Stats が終了した場合などのために、手元にファイルとして保存しておきたい人向け

今回はこのなかの、ブックマークレット版の「ダウンロード用」が何もしてないのに壊れたという話です。

何が起こったか

4/24 にユーザーの方から、「ダウンロード用のブックマークレットを実行しても、ファイルがダウンロードされない」という不具合報告を頂きました。詳しく情報を伺ってみると、

  • .json ファイルのダウンロードが始まる代わりに、本来ダウンロードしたい JSON そのものがページ内に表示されてしまう
  • ブラウザは Firefox 59.0.2
  • Admiral Stats 上のデータを見る限り、2018年3月29日まではダウンロードできていた

とのことで、僕の手元でも最新の Firefox で再現しました。また、Chrome でも同じく再現しました。

原因を調べたところ、a タグの download 属性が全く効いていないことがわかりました。

SEGA 公式サイトにログインしたあとに、このブックマークレットを実行すると、XMLHttpRequest を実行し、その結果を blob として取得します。そして、この blob の URL を参照する a タグを作り、自動的にクリックさせることでファイルのダウンロードを実現しています。コード中の以下の箇所です。

          var blob = new Blob([req.response]);
          if (window.navigator.msSaveBlob) {
            window.navigator.msSaveBlob(blob, fname);
          } else {
            var url = window.URL || window.webkitURL;
            var blobUrl = url.createObjectURL(blob);
            var a = document.createElement('a');
            document.body.appendChild(a);
            a.download = fileType + '_' + ymdhms + '.json';
            a.href = blobUrl;
            a.click();
            document.body.removeChild(a);
          }

https://github.com/muziyoshiz/admiral_stats_exporter_js/blob/v1.10.1/admiral_stats_exporter.js#L82

この download 属性が効いていないために、blob の表示ページに遷移してしまい、ブラウザの画面に JSON そのものが表示されてしまうようです。

しかし、このコードは元々普通に動いていました。それが突然なぜ……?

調査

最近のブラウザは自動アップグレードされてしまうので、なるべく古いバージョンのブラウザが残っていないかと手元の PC を漁って調べてみました。各バージョンでの動作結果はこちら。

ブラウザ リリース日 ブックマークレット(ダウンロード用)の動作
IE 11.371.16299.0 2013-11-08 成功
Microsoft Edge 41.16299.371.0 2017-11-05 成功
Firefox 47.0.1 2016-06-28 失敗
Firefox 59.0.2 2018-03-27 失敗
Chrome 65.0.3325.181 2018-03-06 失敗
Chrome 66.0.3359.117 2018-04-17 失敗
Safari 11.1 2018-02-22 失敗(ファイル名が unknown になる)

ダウンロード用エクスポータの公開日は 2016-10-19 でした。この時点では Chrome, Firefox, Edge, IE 11 で動いていて、特に動かないという報告もありませんでした。

ダウンロード用エクスポータに最後に機能追加したのは 2017-09-22 でした。普段の開発は Chrome で行っているので、この時点で、少なくとも Chrome では動いていた……はずです。

IE と Edge は window.navigator.msSaveBlob を使っているからか問題なし。Safari は当時からダウンロードが動かなかったので、状況変わってません。

Firefox は、かなり古いバージョンがインストールされたものが見つかったので動作確認したところ、エクスポータ公開日の 2016-10-19 より過去のバージョンなのに動きませんでした。ということは、SEGA 公式サイト側の動作が何か変わっている……?

Chrome は、2ヶ月以上古いバージョンが手元になかったので状況不明。しかし、Chrome 65 からクロスオリジンに対する a タグの download 要素はブロックされるようになったことがわかりました。

developers.google.com

Block cross-origin <a download>
To avoid what is essentially a user-mediated cross-origin information leakage, Blink will now ignore the presence of the download attribute on anchor elements with cross origin attributes. Note that this applies to HTMLAnchorElement.download as well as to the element itself.

今回のブックマークレットの場合、公式サイトと JSON の取得元は同一ドメインなので、このクロスオリジンに対する変更は関係ないのでは?と思ったのですが、以下のページによると手動設定された HTTP ヘッダーの種類によっては同一ドメインでもクロスオリジンの扱いになるようです。

developer.mozilla.org

ユーザーエージェントによって自動的に設定されたヘッダー (たとえば Connection、 User-Agent、 または Fetch 仕様書で "forbidden header name" として定義されている名前のヘッダー) を除いて、手動で設定できるヘッダーは、 Fetch 仕様書で "CORS-safelisted request-header" として定義されている以下のヘッダーだけです。
- Accept
- Accept-Language
- Content-Language
- Content-Type (但し、下記の要件を満たすもの)
- Last-Event-ID
- DPR
- Save-Data
- Viewport-Width
- Width

問題のブックマークレットは、JSON を取得する際に、CSRF チェックに引っかからないように X-Requested-With ヘッダを手動で設定しています。これが原因??

    req.setRequestHeader('X-Requested-With', 'XMLHttpRequest');

https://github.com/muziyoshiz/admiral_stats_exporter_js/blob/v1.10.1/admiral_stats_exporter.js#L53

Chrome 64 以前で動くならコレが原因と断言できるのですが、手元の Chrome はすべて Chrome 65 以降にアップグレード済みだったので検証できず。

それとは別に、Google Chrome ver60からa要素のdownload属性が同一オリジンポリシーに厳密になった(Qiita) には、Chrome 60(2017-07-25 公開)からこの制限が入ったとの情報もあります。しかし、2017-09-22 に新機能の動作確認をしているので、そのときには動いていたはずなんですよね……。Deprecations and Removals in Chrome 60 にはそういう記載もなく、動作が変わった時期はよくわかりません。うーん。

対応

結局のところ、この問題が具体的にいつから発生したのかはわかりませんでした。しかし、モダンブラウザでは crossorigin 属性の付いたコンテンツの <a download> は許可しない方向に動いていることはわかりました。

そのため、この制限を何らかの方向で回避することは不可能と判断して、報告してくれたユーザーには代替案の利用をオススメしました。

こういうことが時々あるのが、Web アプリ開発の嫌なところですね……。

Admiral Stats のユーザ向けのまとめ

  • ブックマークレットでの SEGA 公式からの JSON ダウンロードは不可能になりつつある
  • ブラウザ側の機能制限によるものなので対策不可
  • 自動アップロード用のブックマークレットのほうが便利なので、そちらに乗り換えて欲しい(使い方
  • .json ファイルをダウンロードしたい場合は PowerShell 版などをどうぞ(使い方