Playwrightの認証ヘッダを別オリジンへ送らない付け方

English

この記事の目次

本記事の概要

Playwrightで加えた認証ヘッダは、extraHTTPHeadersでも、宛先をcontext.routeで判定してからroute.continueで加える方法でも、別オリジンへ届いた。ヘッダは、このサイトのstagingを開くスクリプトが送るCloudflare Accessのサービストークンである。サービストークンは、Accessが人のログインの代わりに受け付ける認証情報である。route.continueで加えたヘッダは、別オリジンへのリダイレクトの先に付いた。

リダイレクトの先もstagingのオリジンかを確認してからヘッダを加える関数では、ヘッダは別オリジンへ届かなかった。2026-10-13の再現テストでは、Chromium 145とWebKit 26.0とFirefox 146のどれでも同じだった。ただし、ページのHTMLを取得するナビゲーションリクエストは、同じサーバに2回届いた。

3つのブラウザは、バージョン1.58.2のPlaywrightが用意した。lockfileが指定する1.61.1のブラウザと、一般のSafariとFirefoxは、試した範囲に入っていない。

記事から得られること

Playwrightで認証ヘッダを付けてstagingを確認するスクリプトを書く人に向けた記事である。

本文の根拠は、このサイトのstagingを確認するスクリプトの修正の記録、2026-10-13に実行した再現テストの結果と同じ日に確認した外部の資料、2026-10-28に本文のコードを実行した結果の3つである。

認証ヘッダをstagingのオリジンだけに付ける方法

Playwrightで認証ヘッダをstagingのオリジンだけに送るには、リダイレクトの先のオリジンまで確認してからヘッダを加える。宛先を選ばずにヘッダを加える設定でも、送る前に宛先だけを判定する方法でも、ヘッダは別オリジンへ届いた。このサイトのstagingでは、Cloudflare Accessが人のログインの代わりにサービストークンを受け付ける。stagingを開くスクリプトは、このトークンをClient IDとClient Secretの2つのヘッダに入れて送る。

2026-10-13の再現テストでは、リダイレクトの先まで確認してからヘッダを加える関数を使った。オリジンが違うローカルサーバを2つ起動し、一方のページから他方へリクエストを送らせた。Chromium 145、WebKit 26.0、Firefox 146のいずれでも、ヘッダは他方のサーバへ届かなかった。他方のサーバへリダイレクトするページは、この関数がリクエストを止めるので、開けなかった。

addAuthHeadersの処理

addAuthHeadersは、リダイレクトの先まで確認し、すべてstagingのオリジンのときだけヘッダを加える関数である。その代わり、ページのHTMLを取得するナビゲーションリクエストを、確認するときと送るときの2回送る。2026-10-13の再現テストでは、同じ処理の関数で、3種類のブラウザのどれでも、このリクエストが同じサーバに2回届いた。1回目と2回目でサーバの応答が変わる場合は、試していない。

宛先がstagingのオリジンでなければ、addAuthHeadersは、リクエストを送り出すroute.continueをヘッダなしで呼ぶ。stagingへのGETかHEADのナビゲーションリクエストでは、リクエストを先に送って応答を受け取るroute.fetchを、maxRedirectsを0にして呼ぶ。maxRedirectsの説明によると、0のときroute.fetchはリダイレクトを追わない。

addAuthHeadersは、応答のLocationヘッダが示す先を、stagingの中で1件ずつたどる。たどった先が全部stagingなら、route.continueでヘッダを加えて送る。別のオリジンが出るか、リダイレクトが5回を超えたら、route.abortで止める。

stagingへのナビゲーションでないリクエストと、GETとHEAD以外のナビゲーションリクエストには、ヘッダを加えたroute.fetchを1回だけ呼ぶ。maxRedirectsは0にし、応答をブラウザへ返す。応答がリダイレクトなら、route.abortで止める。

次のaddAuthHeadersは、2026-10-13の再現テストで試した関数を、記事のために書き起こしたコードである。2026-10-28にChromium 145でこのコードを実行すると、別オリジンのサーバにヘッダは届かなかった。ほかのブラウザでは、実行の結果を確認していない。

import type { Route, Request } from "playwright";

// stagingのURLと、ヘッダの名前と値は、環境変数から読む (名前は仮の名前)
const STAGING_ORIGIN = new URL(process.env.STAGING_URL ?? "").origin;
const AUTH_HEADERS: Record<string, string> = {
  [process.env.AUTH_ID_HEADER_NAME ?? ""]: process.env.AUTH_CLIENT_ID ?? "",
  [process.env.AUTH_SECRET_HEADER_NAME ?? ""]: process.env.AUTH_CLIENT_SECRET ?? "",
};
const MAX_REDIRECTS = 5;

const isStaging = (url: string) => new URL(url).origin === STAGING_ORIGIN;
const isRedirect = (status: number) => status >= 300 && status < 400;

export async function addAuthHeaders(route: Route, request: Request) {
  // 宛先がstagingのオリジンでなければ、ヘッダを加えずに送る
  if (!isStaging(request.url())) return route.continue();

  const headers = { ...request.headers(), ...AUTH_HEADERS };
  const method = request.method();

  if (request.isNavigationRequest() && (method === "GET" || method === "HEAD")) {
    // リダイレクトを追わずに送り、Locationの先を1つずつ確かめる
    let url = request.url();
    for (let redirects = 0; ; redirects++) {
      const response = await route.fetch({ url, headers, maxRedirects: 0 });
      const location = response.headers()["location"];
      if (!isRedirect(response.status()) || !location) break;
      url = new URL(location, url).href;
      if (!isStaging(url) || redirects >= MAX_REDIRECTS) return route.abort();
    }
    // たどった先がすべてstagingのオリジンなので、ヘッダを加えて送る
    return route.continue({ headers });
  }

  // そのほかのリクエストは1回だけ送り、応答をブラウザへ返す
  const response = await route.fetch({ headers, maxRedirects: 0 });
  if (isRedirect(response.status())) return route.abort();
  return route.fulfill({ response });
}

// 使い方: await context.route("**/*", addAuthHeaders);

ヘッダの付け方ごとに別オリジンへ届いたヘッダ

全部のリクエストにヘッダを加えるextraHTTPHeadersでは、ページが別オリジンへ送るfetchにも、リダイレクトの先にもヘッダが付いた。送る前のリクエストを受け取るcontext.routeで宛先を判定し、送り出すroute.continueでヘッダを加える方法も試した。この方法では、ヘッダはページのfetchには付かず、リダイレクトの先には付いた。試したブラウザは3種類、バージョンは4つで、どれでも結果は同じだった。

2026-10-13の再現テストでは、オリジンが違うローカルサーバを2つ起動した。一方のページから他方へfetchを送らせた。一方のサーバには、他方へ302でリダイレクトするパスも置いた。他方のサーバは、スクリプトが加えたヘッダの名前を、CORSの応答で許可した。

使ったブラウザは、バージョン1.58.2のPlaywrightが用意したChromium 145.0.7632.6、WebKit 26.0、Firefox 146.0.1である。WebKitでは、1.62.0のPlaywright向けの26.5も使った。このサイトのlockfileが指定するPlaywright 1.61.1のブラウザは手元に無く、表に含まない。他方のサーバでの結果は、次の表のとおりである。

ヘッダの付け方 他方のサーバへのリクエスト Chromium 145 WebKit 26.0 WebKit 26.5 Firefox 146
extraHTTPHeaders ページのfetch 付いた 付いた 付いた 付いた
extraHTTPHeaders リダイレクトの先 付いた 付いた 付いた 付いた
context.routeで宛先を判定してroute.continue ページのfetch 付かなかった 付かなかった 付かなかった 付かなかった
context.routeで宛先を判定してroute.continue リダイレクトの先 付いた 付いた 付いた 付いた

extraHTTPHeadersの漏れと、レビューで見つかったリダイレクトの問題

横のはみ出しを数える、オーバーフローを検査するスクリプトは、最初、Accessの認証に使うサービストークンをextraHTTPHeadersに入れていた。そのため、SupabaseのAPIへのリクエストにもトークンが付いた。ただし、送り先はこのサイトのバックエンドで、通信はHTTPSだった。

作業を進めたAIエージェントは、ヘッダを宛先のオリジンだけに付けるために、このスクリプトからextraHTTPHeadersを外した。代わりに、送る前のリクエストを受け取るcontext.routeで宛先を判定した。宛先がstagingのときだけ、送り出すroute.continueでヘッダを加えた。

宛先のオリジンで絞る修正と同じ日に、レビューしたAIエージェントは、リダイレクトの先へヘッダが渡る経路を見つけた。route.continueで加えたヘッダは、リダイレクトの先のリクエストにも付く。その先のリクエストでは、context.routeの関数が呼ばれない。レビューしたAIエージェントは、トークンが無効なときのAccessのリダイレクトと、購入の入口から決済のサービスへの転送を想定した。

route.continueの説明には、headersの指定がリダイレクトにも当てはまると書かれている。page.routeの注記には、応答がリダイレクトなら、routeに渡した関数は最初のURLでだけ呼ばれると書かれている。一方、リクエストを先に送るroute.fetchの説明には、元のリクエストにだけヘッダを付けるならroute.continueを使う、と逆の案内がある。リダイレクトの先への漏れをmaxRedirectsが0のroute.fetchで避ける方法は、issue 32939でPlaywrightの開発者が示した。

送信先のCORSの設定とブラウザによる違い

別オリジンのサーバがヘッダの名前をCORSで許可しなくても、ヘッダの付いたリクエストが届くブラウザがあった。試した4つのバージョンのうち3つで届いた。送信先のCORSの設定は、別オリジンへの送信を防ぐ手段として当てにできない。2026-10-13の再現テストでは、全部のリクエストにヘッダを加えるextraHTTPHeadersを使った。

MDNのCORSの説明によると、CORSのセーフリストに無いヘッダを付けると、ブラウザは先にプリフライトリクエストで許可を求める。許可を受けてから、実際のリクエストを送る。再現テストのChromium 145は、ヘッダの値を含まないプリフライトリクエストだけを送り、実際のリクエストを送らなかった。

WebKit 26.0と26.5、Firefox 146では、プリフライトリクエストは届かず、ヘッダの付いた実際のリクエストが届いた。どのブラウザもPlaywrightが用意したバージョンで、一般のSafariとFirefoxでは試していない。

参考にした資料