ブログに戻る

フォロー&ご登録

単一オリジンの Web サイトでサードパーティを使いこなす

Andrew Betts

Principal Developer Advocate, Fastly

今日のほとんどの Web ページは、ページ本来の場所とは違うオリジンサーバーからリソースを読み込みます。これらのサードパーティのスクリプトを利用することで、サイトのスピードが落ち、厳格な Content-Security-Policy の作成が困難になるだけでなく、サイトへのフルアクセス権をサードパーティに渡してしまうことになります。Compute@Edge とエッジベースのプロキシを使えば、このような問題を回避できる可能性があります。

Fastly の Developer Hub は、ページとリソースの大半をクラウドストレージバケット (Fastly の場合は Google Cloud Storage) から提供する、静的に生成される Web サイトの好例です。ただし、他の多くの Web サイトと同様、これらのページも他のドメインからリソースを取り込んでいます。使用するリソースの例 : 

  • Google Analytics - トラフィックの測定

  • Sentry - JavaScript エラーのレポートと集計

  • FormKeep - ユーザーの評価やフィードバックの回収

  • Swiftype - Web サイトの検索

ここで紹介したのは一般的なベンダーなので、皆様の会社の Web サイトでも使用されているかもしれません。ブラウザのプライバシー保護に関わるサービスを提供する Ghostery 社が2020年に実施した調査によると、平均的なニュースやメディアの Web サイトは、トラッキングだけでも10個以上のサードパーティスクリプトを使用しているということです。

プライバシー、セキュリティ、規制、パフォーマンスの問題

サードパーティのスクリプト (特に動作追跡専用のスクリプト) には、プライバシーの問題が生じることは否定できません。Ghostery などのプラグインは、エンドユーザーのプライバシーを保護する方法として優れています。実際、こうした保護機能を組み込んだブラウザも徐々に増えています。

政府の対応もこれまで以上に厳しくなっています。  最近、ドイツのある地方裁判所は、Google Fonts を使用した Web サイト運営者に対して、エンドユーザーの IP アドレスを Google と共有したとして罰金を科しました。

このサードパーティのサービスを選択したのは Web サイトの所有者ですが、とはいえサードパーティの動作や回収するデータについてはコントロールできません。実際のところ、Google Tag Manager などのツールにおいて、サードパーティのスクリプトのコントロールを社内の別チームに任せている場合、エンジニアリングチームはサイトにロードされる情報をまったく把握していないことがあります。 

開発者がサードパーティのスクリプトの動作を直接コントロールできれば、エンドユーザーの利益を守りながら、サードパーティのサービスのメリットも受けられるようになるかもしれません。

この場合の問題はプライバシーだけに留まりません。トラッカー、分析、フォントなどを追加すると、ユーザーは20、30、50、あるいはそれ以上のドメインから情報を取得することになります。1つの Web ページをレンダリングするだけで、です。

これを現実的な視点で考えると、効果的な Content-Security-Policy を作成できず、ブラウザは各サーバーに複数の TCP 接続をすることになり (つまり、効率的に優先順位をつけることができない)、Web サイトの可用性が、すべてのサードパーティの可用性に依存することになってしまいます。  フォントプロバイダーがダウンした場合や他の国でブロックされた場合、Web サイトが空白のページとしてレンダリングされることになります。

プロキシによる解決策

Fastly を介して Web サイトを提供する場合、セキュリティのベストプラクティスと最新のプロトコルサポートを備えたエッジ配備のリバースプロキシがすでに用意されているため、単一ドメインで表示しながら、複数のバックエンドサーバーにリクエストをルーティングすることができます。多くのお客様がこの機能を利用して、エッジでのマイクロサービスアーキテクチャを構築しています。

同じ原理を、ほとんどのサードパーティスクリプトのプロキシにも適用できます。その仕組みを見ていきましょう。

  1. サードパーティ (例 : www.google-analytics.com) が、新しいバックエンドとして Fastly サービスに追加されます。

  2. ローカルパスから読み込むよう、HTML の <script> タグが修正されます。例 : /services/analytics

  3. このパスへのリクエストは、Fastly によって正しいバックエンドパスに変換され、バックエンドにルーティングされます。

  4. サードパーティが提供するライブラリコードは Fastly に取り込まれ、必要に応じて変換されます。たとえば、サードパーティのデータコレクターの URL を検索し、プロキシエンドポイントに置き換えます (この処理には多少のリスクがありますが、これについては後ほど説明します)。

  5. サードパーティのスクリプトによる後続のリクエストは、Fastly サービスに送信され、必要に応じて検査、フィルタリングされてから、サードパーティに転送されます。

このパターンを導入することで、次のようなメリットがあります。

  • 厳格な Content-Security-Policy

  • 効果的な HTTP 優先順位設定

  • サードパーティの障害からの保護

  • サードパーティとのデータ共有のコントロール

  • クライアント側のブロック/フィルタリングプラグインの回避

最後の項目には、賛否両論あるかもしれません。サードパーティのプロキシ設定の手間は特に気にしないが、エンドユーザーへの影響を最小限に抑えたいと仮定して、話を進めます。では、Developer Hub で利用しているサードパーティに、この機能をどのように実装するか見てみましょう。

Developer Hub は、JavaScript で書かれた Compute@Edge のサービスを前面に出した GatsbyJS のアプリケーションです。 Compute@Edge に移行した方法については、過去のブログ記事で詳しくご紹介しています。

HTTP API (FormKeep と Swiftype)

スタートは簡単です。サードパーティの中には、実際にはスクリプトがなく、クライアント側のスクリプトから問い合わせる API エンドポイントに過ぎないものもあります。たとえば、FormKeep はフィードバックフォームから HTTP POST でデータを受け取り、Swiftype は検索クエリの結果を返します。これらをプライマリドメインに移動させるのは簡単です。

まず、特定のパスを認識し、そのパスのリクエストを新しいバックエンド (ここでは「formkeep」とします) に誘導するよう、Compute@Edge プログラムを修正します。

// src/index.js (Fastly Compute@Edge app)

const req = event.request
const reqUrl = new URL(req.url)
const reqPath = reqUrl.pathname

let backendName;

if (reqPath === "/api/internal/feedback") {
    backendName = "formkeep";
    reqUrl.pathname = "/f/xxxxxxxxxxxx"
} else {
    backendName = "gcs";
}

let beReq = new Request(reqUrl, req);

let beResp = await fetch(beReq, { backend: backendName });

return beResp;

次に、新しいパスに API リクエストを送信するように、フロントエンドアプリケーションまたは HTML ページの動作を変更します。

// feedback.html (client side HTML page)

async function handleFormSubmit(evt) {
  const data = new FormData(evt.target)
  buttonEl.current.disabled = true
  await fetch("/api/internal/feedback", {
    method: "post",
    body: data,
    headers: { accept: "application/json" },
  })
  setIsSubmitted(true)
}

最後に、コントロールパネルまたは Fastly CLI を使用してバックエンドを追加し、更新したアプリをデプロイします。

fastly backend create --name=formkeep --host=formkeep.com --version=active --autoclone
fastly compute publish

--version=active--autoclone フラグは、現在有効なバージョンのサービスのクローンを作成します。そのクローンに新しいバックエンドが追加されますが、有効化はされません。compute publish コマンドは、更新されたコードをドラフトバージョンのサービスにアップロードし、有効化します。

こうしたサードパーティとの統合が簡単に実行できるので、Fastly を利用しない理由はないでしょう。

設定可能なクライアント (Sentry)

サードパーティのサービスには、エラー集計サービス Sentry など、ブラウザでの実行が必要な JavaScript クライアントを提供するものもあります。プロバイダーによっては、クライアントが行うリクエストのホスト名とパスを設定できるようにしてくれる場合もあります。

Sentry はその一例で、tunnel オプションを使用して設定できます。Sentry の設定はどこに置いても構いません。Developer Hub では、Gatsby 用の Sentry プラグインを使用しているため、gatsby-config.jsplugins 配列に設定します。

// gatsby-config.js

{
  resolve: "@sentry/gatsby",
  options: {
    dsn: "https://#######@###.ingest.sentry.io/######",
    tunnel: "/api/internal/errors",
    sampleRate: 0.7,
    tracesSampleRate: 0.7,
    release: process.env.COMMIT_SHA,
  }
}

Gatsby などのアプリケーションフレームワーク以外で Sentry を使用する場合、Sentry.init を呼び出す場所に tunnel オプションを指定するケースがほとんどです。

次に、Compute@Edge アプリを変更して新しいパスを追加し、Sentry が想定しているパスに再マッピングします。

// src/index.js (Fastly Compute@Edge app)

if (reqPath === "/api/internal/feedback") {
    backendName = "formkeep";
    reqUrl.pathname = "/f/xxxxxxxxxxx"
} else if (reqPath === "/api/internal/errors") {
    backendName = "sentry";
    reqUrl.pathname = "/api/" + SENTRY_PROJECT_ID + "/envelope/"
} else {
    backendName = "gcs";
}

前回と同様、コード内で使用した名前と一致する新しいバックエンドを追加し、新しいバージョンのプログラムをデプロイする必要があります。

fastly backend create --name=sentry --host=oXXXXXXXX.ingest.sentry.io --version=active --autoclone
fastly compute publish

Sentry クライアントコードをサイトバンドルに組み込むことで、ライブラリをロードするリクエスト自体ではなく、Sentry のコレクターにデータを送信するリクエストのみに注意すれば良いというのも、Sentry の Gatsby プラグインのメリットです。

クライアントを動的に書き換える (Google Analytics)

他のスクリプトは、もう少しサポートが必要になります。Google Analytics (GA) は、トラッキングスクリプトに宛先 URL をハードコードし、GA 用の Gatsby プラグインは Google から直接ライブラリを読み込みます。この場合、クライアントライブラリの修正版をセルフホスティングすることもできますが、プロバイダーがクライアントコードに行う更新は取得できません。

代わりに、Compute@Edge のストリーミング変換を使って、これらの URL をオンザフライで変更することができます。 

Google から返された CSS で実際のフォントファイルを読み込む Google Fonts にも同じ手法を使用できます。この場合もプライマリドメインを介してルーティングする必要があります。 Fastly のお客様である Houzz は、このソリューションを利用して、Google からフォントを読み込む際にプライバシーを保護するメソッドを構築しています。

まず、ストリームに対して単純な検索と置換を行う関数を追加します。

// src/index.js (Fastly Compute@Edge app)

const streamReplace = (inputStream, targetStr, replacementStr) => {
  let buffer = ""
  const decoder = new TextDecoder()
  const encoder = new TextEncoder()
  const inputReader = inputStream.getReader()
  const outputStream = new ReadableStream({
    start() {
      buffer = ""
    },
    pull(controller) {
      return inputReader.read().then(({ value: chunk, done: readerDone }) => {
        buffer += decoder.decode(chunk)

        if (buffer.length > targetStr.length) {
          buffer = buffer.replaceAll(targetStr, replacementStr)
          controller.enqueue(encoder.encode(buffer.slice(0, buffer.length - targetStr.length)))
          buffer = buffer.slice(0 - targetStr.length)
        }

        // Flush the queue, and close the stream if we're done
        if (readerDone) {
          controller.enqueue(encoder.encode(buffer))
          controller.close()
        } else {
          controller.enqueue(encoder.encode(""))
        }
      })
    },
  })
  return outputStream
}

バックエンドへのフェッチの直後、beResp. body は読み込み可能なストリームになります。ストリームの置換関数を使用して、GA ドメインを Fastly のドメインに置き換えます。

let beResp = await fetch(beReq, { backend: backendName });

const respContentType = beResp.headers.get("content-type") || ""
if (respContentType.startsWith("text/")) {
  const newRespStream = streamReplace(
    beResp.body,
    "www.google-analytics.com",
    "developer.fastly.com/api/internal/analytics"
  )
  beResp = new Response(newRespStream, { headers: beResp.headers })
}

return beResp;

GA の Gatsby プラグインはすべてのページで <script> タグをハードコードします。すべての HTML ページに、analytics. js ライブラリ自体にハードコードされた両方のホスト名とそのライブラリを読み込むためのマークアップが含まれているため、すべてのテキストレスポンスに適用する必要があります。gatsby-plugin-google-gtag は、<script> タグをローカルパスに書き換える代替手段のようなものですが、この記事では、ほぼ何にでも対応できる究極の代替策を取り上げる価値があると考えました。

このようなサードパーティのコードの書き換えは、本質的にリスクが高いことを認識しておいてください。 サードパーティライブラリの中には、複数のホスト名から取得するものがあります。 取得先のホスト名が変更される可能性もあります。 こうした書き換えを避けるために、URL の構造を難解化しようとするケースも考えられます。 Google Analytics と Google Fonts によって、良い経験を得ることができました。

1つの URL にのみ変換を適用する必要がある場合は、先ほど定義した reqPath 変数をチェックするように if 構文を変更します。

次にルーティングコードに新しいパスを追加します。

if (reqPath === "/api/internal/feedback") {
    backendName = "formkeep";
    reqUrl.pathname = "/f/xxxxxxxxxxx"
} else if (reqPath === "/api/internal/errors") {
    backendName = "sentry";
    reqUrl.pathname = "/api/" + SENTRY_PROJECT_ID + "/envelope/"
} else if (reqPath.startsWith("/api/internal/analytics")) {
    backendName = "ga";
    reqUrl.pathname = reqPath.replace("/api/internal/analytics/", "/")
} else {
    backendName = "gcs";
}

もちろん、Google Analytics のバックエンドの追加、新しいコードのアップロード、新バージョンのサービスの有効化も必要です。

fastly backend create --name=ga --host=www.google-analytics.com --version=active --autoclone
fastly compute publish

リクエストから Cookie を削除

すべてのリクエストを自分のドメインに誘導することで、サードパーティの動作をはるかにコントロールしやすくなります。たとえば、サードパーティにはエンドユーザーの IP アドレスは見えなくなり、すべてのリクエストが Fastly から送信されるようになります。

また、リクエストから不要なデータをプロアクティブに削除することもできます。 検討が必要な最も重要な項目は、X-Forwarded-ForFastly-Client-IPCookie ヘッダーでしょう。これらを処理しないと個人情報がサードパーティに漏れ、プロキシのプライバシーに関するメリットがすべて台無しになってしまいます。 これは、バックエンドにリクエストを送る直前に、簡単な方法で削除できます。

beReq.headers.delete("cookie");
beReq.headers.delete("x-forwarded-for");
beReq.headers.delete("fastly-client-ip");

他にも、リクエストのボディコンテンツにフィルターをかける、サンプルをログエンドポイントにコピーして検査するなど、さまざまな方法があります。

最後に

サイトのすべてのリソースとリクエストを単一ドメインに統合することで、大きなメリットが得られ、想定外のパフォーマンスやプライバシーの低下を抑えるのに役立ちます。Compute@Edge、および一般的なエッジコンピューティングは、このような処理をいずれ簡素化する見込みですが、Fastly ではすでにいくつかの強力な手段を用いてサイトの読み込み方法を調整しています。