Playwrightのプロキシ: ブラウザとコンテキストの設定

11分

Playwrightのプロキシをブラウザまたはコンテキストに設定し、資格情報を渡し、HTTPSのCONNECTを確認し、TLSを切らずに407と証明書の失敗を直します。

Playwrightのトラフィックを、ブラウザ全体または一つのコンテキストから、HTTPまたはSOCKS5プロキシ経由で出す必要がある開発者向けの案内です。実験はPlaywright 1.64.0を、ローカルオリジンとCONNECTできるダミープロキシに当てました。無料の鍵はありません。Top ProxyはIPを売りません。通ったローカルフィクスチャは、本番の口座でも地域でもありません。

01ブラウザのプロキシとコンテキストのプロキシ

Playwrightは、二つの場所に一つの proxy オブジェクトを文書化しています。Network: HTTP proxy(2026年10月14日に参照)は、プロキシをブラウザ全体のグローバル、またはブラウザコンテキストごとに置けると書いています。browser.newContext()も同じ欄を繰り返します。server、任意の bypass、任意の username、任意の password です。Pythonのネットワーク案内も、chromium.launch(proxy=...) と browser.new_context(proxy=...) で同じ形です。この手順が実行するのはNodeのライブラリです。Pythonはそれらの文書でAPIが近いだけであり、第二の実験ではありません。

そのプロセスのすべてのコンテキストが一つの出口を共有するなら、プロキシは chromium.launch へ置いてください。一つのプロセスが出口を二つ、Cookieの壺を二つ、または既定と上書きを持つ必要があるなら、browser.newContext へ置いてください。

  1. 仕事が「実行全体で一つの出口」か「一つのプロセスに二つの身元」かを決めてください。
  2. 仕事がブラウザプロセス全体で一つの出口なら、proxy を chromium.launch へ置きます。Playwright Testでは、playwright.config.ts の use.proxy はコンテキストのフィクスチャです。その試験のコンテキストとそのページに効き、ブラウザ上のすべてのコンテキストが共有する起動の既定ではありません。Configuration (use)を見てください。
  3. 仕事が出口二つなら、二番目の身元を固定せずに起動し、各 newContext へ別の proxy を渡してください。
  4. 作ったコンテキストは、browser.close() の前にそれぞれ閉じてください。browser.newContext() の後始末の注記です。

実験はChromium 156.0.8078.4を、127.0.0.1 へのブラウザ級HTTPプロキシ付きで起動し、ローカルオリジンを取りました。ページはHTTP 200を返しました。プロキシのログは GET http://127.0.0.1:<port>/ を示しました。プロセスから HTTP_PROXY、HTTPS_PROXY、ALL_PROXY、NO_PROXY を外したあと、proxy オプションなしの直接起動は、同じオリジンにプロキシイベント0で当たりました。きれいなプロキシなしの基準です。それらの変数が残っていたときにPlaywrightが何をするかは測っていません。

二番目のブラウザは、起動の既定に最初のプロキシを使い、そのあと proxy.server が別のローカル転送器を指すコンテキストを開きました。コンテキストの要求は200を返しました。ヒットは二番目の転送器に落ちました。最初の転送器は、コンテキストのオリジンURLを見ませんでした。コンテキストの proxy は、そのコンテキストのブラウザ既定を置き換えます。マージはしません。

Playwright Testの use.proxy は、試験コンテキストとそのページを設定します。chromium.launch({ proxy }) は別のブラウザ既定です。試験ごとのローテーションAPIではありません。この実験が実行した1.64.0のAPIに、launch や newContext の rotateSession 欄はありません。

02ホスト、ポート、資格情報を漏さずに渡す

server はURLです。Browser APIの文は明確です。http://myproxy.com:3128 と socks5://myproxy.com:3128 に対応します。短い形 myproxy.com:3128 はHTTPとして扱われます。供給元がSOCKS5端点を渡したなら、socks5:// スキームが必要です。ユーザー名とパスワードは、認証が必要なHTTPプロキシの資格情報として文書化されています。SOCKS5のuserpassとしては文書化されていません。

パスワードは環境またはシークレットストアに置いてください。コミットする設定へ貼り付けないでください。Playwrightは、それでも回線へ Proxy-Authorization を送る必要があります。実験はダミーユーザー lab-user と、@、:、/、#、% を含むダミーパスワードを使いました。結果のログはその値を伏せています。

ChromiumのHTTPプロキシ認証はおしゃべりです。ローカルのBasicプロキシに対して:

  1. server を http://127.0.0.1:<port> にし、ユーザー名なし。HTTPオリジンへの page.goto はステータス407を返しました。プロキシのログは authReason: missing を記録しました。Playwrightは投げませんでした。証明は応答オブジェクトのステータスです。
  2. ユーザー名 lab-user と間違ったパスワード。またステータス407。ログは、資格情報なしの最初の探り、そのあと authReason: wrong を示しました。
  3. ダミーパスワードを正しく設定。ステータス200。ログは同じ探りのあと、authorized: true を示しました。

このChromiumビルドでは、間違った資格情報は吊るソケットではありません。プロキシからのHTTP 407であり、案内のステータスとして出ます。ラッパーがあとで投げるなら、407と例外クラスを残してください。両方を「プロキシが動かない」へ潰さないでください。

同じNetworkページのHTTP Authenticationは、オリジン向けの httpCredentials であり、proxy.username ではありません。混ぜるのはよくある取り違えです。オリジンの401とプロキシの407は、別のホップです。

03HTTPSトラフィックがHTTPプロキシでCONNECTを使うことを確認する

対象URLのスキームとプロキシURLのスキームは、別のつまみです。オリジン https://example.com/ でも、プロキシは http://127.0.0.1:<port> のHTTPプロキシのままです。プロキシのメソッドは CONNECT、対象は example.com:443 です。実験はそのCONNECTを記録し、Chromiumは公開証明書に対してTLSを終え、page.goto は200を返しました。検証を付けたまま、HTTPプロキシ越しのHTTPSです。

実験CAで署名したローカルHTTPSオリジンが、もう半分です。Chromium 156.0.8078.4、ignoreHTTPSErrors は文書の既定 false のまま、同じHTTPプロキシ越しに https://127.0.0.1:<https-port>/ へ案内しました。プロキシは CONNECT 127.0.0.1:<https-port> を記録しました。そのあとPlaywrightは PlaywrightError を投げ、中身は net::ERR_CERT_AUTHORITY_INVALID でした。CONNECTは起きました。TLSはまだ失敗しました。対象が https:// だからといって、プロキシが「httpsプロキシ」になるわけではありません。プロキシがCONNECTを受けても、Chromiumが私的CAを信頼するわけでもありません。

同じオリジンへのNode https.request は、rejectUnauthorized: true でCAなしだと unable to verify the first certificate で失敗しました。同じクライアントに実験CAのPEMを付けるとHTTP 200が返りました。それが文書どおりのNodeの信頼経路です。Playwrightの clientCertificates オプションは、v1.46で newContext に足された、指名したオリジン向けのクライアント証明書であり、私的CAを入れる方法ではありません。失敗を飛ばすと文書化されたPlaywrightのスイッチは ignoreHTTPSErrors: true です。この記事では使いません。rejectUnauthorized: false も付けないでください。

仕事が「CONNECTを証明する」なら、プロキシのログで CONNECT host:port を読んでください。仕事が「ページが読み込まれたことを証明する」なら、Chromiumがすでに信頼する証明書、またはこの案内の外のプロセスが入れたCAが、まだ必要です。個人ブラウザのチェッカーページは、そのブラウザの出口であり、Playwrightの経路ではありません。自分のIPは、ブラウザ出口の補助としてだけ使ってください。Playwrightを向ける前に、ホストがHTTPまたはSOCKS5を受けるかはプロキシチェッカーで見てください。

2026年10月14日のPlaywright 1.64.0ループバック実験からの確認表

図はこの実行の結果表です。16:9の表紙ではなく、チェッカーのスクリーンショットでもありません。

04エンジンが実際に対応する場所だけSOCKS5を使う

Network案内は、ページはHTTP(S)プロキシまたはSOCKSv5で読み込めると書いています。Chromium 156.0.8078.4は、server: "socks5://127.0.0.1:<port>" でユーザー名なしだと、HTTPオリジンをステータス200で取りました。SOCKSフィクスチャは CONNECT 127.0.0.1:<origin-port> を記録しました。認証なしのSOCKS5は、この固定Chromiumの手順に入っています。

認証付きSOCKS5は入っていません。Playwright 1.64.0の chromium.launch、firefox.launch、webkit.launch はすべて PlaywrightError を投げました。Browser does not support socks5 proxy authentication です。投げは起動時で、page.goto の前です。Playwright 1.64.0は、それらの起動のためにFirefox 157.0とWebKit 27.2をダウンロードしました。メッセージは、長く開いた製品要求 microsoft/playwright#10567 と一致します。2026年10月14日時点でもOpenで、ラベルは P3-collecting-feedback です。2021年のメンテナコメントは、ChromiumとFirefoxのエンジン自身にSOCKS5 userpassが無いと記録しています。

供給元がユーザー名付きSOCKS5しか出さないなら、この固定Playwrightビルドはそのユーザー名を送りません。username と password 付きのHTTPプロキシか、下のような試験でエンジンが実際にダイヤルした認証なしSOCKS5端点を使ってください。プロトコル比較はSOCKS5とHTTPプロキシにあります。PythonのRequestsは別のクライアント、別の記事です。Python requestsのプロキシ。

05バイパスを万能の逃げ道にせず、選んだホストだけ飛ばす

bypass はドメインのカンマ区切り一覧です。APIの例は ".com, chromium.org, .domain.com" です。すべてのホストのキルスイッチではありません。プライバシー機能でもありません。

  1. proxy.server を開いた転送器へ向け、bypass: "127.0.0.1" でChromiumを起動します。
  2. http://127.0.0.1:<origin>/ へ案内します。ページは200を返しました。その窓のプロキシログは空でした。一覧のホストはプロキシを飛ばしました。
  3. bypass: "example.com" で再起動し、同じ 127.0.0.1 オリジンへ案内します。ステータス200、プロキシは GET http://127.0.0.1:<origin>/ を記録しました。一覧に無いホストは、まだプロキシを使いました。

約束はそれだけです。bypass は「このプロキシは全般に私を見られない」ではありません。「これらのホストは、このコンテキストでは、このプロキシへ送られない」です。Chromiumのlocalhost規則は、別ビルドでは驚くことがあります。プロキシのログを測ってください。仮定しないでください。

browser.newContext() は、ほかのコンテキストとCookieを共有しない壺を作ります。二つの試験がセッションを間違った出口へ漏らしてはいけないとき、コンテキストごとの proxy と組み合わせてください。

  1. ブラウザ級プロキシなしでChromiumを起動します。
  2. コンテキストAをプロキシA、コンテキストBをプロキシBで開きます。
  3. 両方を、lab Cookieを付ける同じオリジンへ案内します。
  4. context.cookies() を読みます。

実験は、コンテキストAでCookie t-origin-a-67a984、コンテキストBで t-origin-a-ff4919 を得ました。壺は等しくありませんでした。両方の案内は200を返しました。ここでの隔離は、Playwrightのコンテキスト隔離にプロキシサーバー二つです。供給元のスティッキーセッション宣伝ではありません。レジデンシャルプールが同じIPを保つという主張でもありません。

開いたコンテキストは閉じてください。漏れたコンテキストは、ブラウザが死ぬまでCookieとプロキシ資格情報を生きたままにします。

07拒否された接続、407、証明書の失敗を、TLSを切らずに診断する

Playwrightが「動かない」ときは、失敗したホップに名前を付けてください。

場所プロトコル1.64.0の実験が観測したことエンジンの制限
直接の chromium.launch()なしHTTPオリジン200、プロキシイベント0なし
ブラウザ級 proxy.server HTTPHTTP GET200、プロキシログはabsolute-form GETなし
起動を上書きするコンテキスト proxyHTTP GETコンテキストのプロキシで200。起動プロキシではないなし
HTTPプロキシ、無いまたは間違ったBasicHTTP 407page.goto のステータス407、投げなしなし
HTTPプロキシ、正しいBasicHTTP GETChromiumの認証なし探りのあと200なし
HTTPプロキシ、公開CAのHTTPSオリジンCONNECTのあとTLSCONNECT example.com:443、ページ200なし
HTTPプロキシ、実験CAのHTTPSオリジンCONNECTのあとTLSCONNECT記録、そのあと net::ERR_CERT_AUTHORITY_INVALIDコンテキストのCAファイルAPIなし
SOCKS5、ユーザー名なしSOCKS CONNECTChromium 200、フィクスチャCONNECT認証は関与せず
SOCKS5とユーザー名起動時に拒否Chromium、Firefox、WebKitで Browser does not support socks5 proxy authenticationPlaywright 1.64.0とエンジン
proxy.server http://127.0.0.1:1TCPnet::ERR_PROXY_CONNECTION_FAILEDなし
bypass: "127.0.0.1"飛ばすオリジン200、プロキシイベント0一覧は正確。全体ではない

拒否されたプロキシ: Chromiumは起動しました。そのあと page.goto が PlaywrightError を投げ、中身は net::ERR_PROXY_CONNECTION_FAILED でした。実験の既定案内タイムアウトは8000 msでした。拒否のケースはそのタイムアウトより早く失敗しました。クラス名 PlaywrightError と net:: コードを残してください。一般のタイムアウトへ名前を変えないでください。

証明書の失敗: TLSは付けたままです。成功したローカルCA経路は、ca を付けたNode https でした。成功したPlaywrightブラウザ経路は、CONNECT越しの https://example.com/ で、すでに公開CAがあります。失敗したPlaywrightブラウザ経路は実験オリジンでした。ignoreHTTPSErrors はfalseのままです。

経路の確認順です。

  1. プロキシのポートが接続を受けることを確認してください。拒否されたポートは ERR_PROXY_CONNECTION_FAILED であり、悪いパスワードではありません。
  2. HTTPプロキシでは、407対200を見てください。このChromiumでは、無いパスワードも間違ったパスワードも407でした。
  3. HTTPS対象では、プロキシログのCONNECTを見て、そのあとTLSを見てください。CONNECTに ERR_CERT_AUTHORITY_INVALID が続くなら、トンネルは動き、証明書は動きませんでした。
  4. ユーザー名付きSOCKS5では、1.64.0ではページエラーではなく起動エラーを想定してください。
  5. そのあとだけ、オリジンの本文を見てください。

下の公開実験は、読者に見えるフィクスチャです。オリジン、CONNECTプロキシ、Chromiumプロセスを持ち、コンストラクタと起動の失敗も含め、finally で閉じます。public_lab.mjs として保存してください。

npm install playwright@1.64.0
npx playwright install chromium
node public_lab.mjs
#!/usr/bin/env node
/**
 * Self-contained loopback lab for Playwright 1.64.0 (Node).
 * Dummy credentials only. Local origin and CONNECT proxy. Does not call a paid provider.
 *
 *   npm install playwright@1.64.0
 *   npx playwright install chromium
 *   node public_lab.mjs
 *
 * TLS stays verified. This script never sets ignoreHTTPSErrors.
 */
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import http from "node:http";
import https from "node:https";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";

const USER = "lab-user";
const PASSWORD = "p@ss:/#%";
const REDACT = "***";
const TIMEOUT_MS = 8000;
const SHUTDOWN_MS = 4000;
const STRIP = [
  "HTTP_PROXY",
  "HTTPS_PROXY",
  "ALL_PROXY",
  "NO_PROXY",
  "http_proxy",
  "https_proxy",
  "all_proxy",
  "no_proxy",
];

function redact(s) {
  return String(s)
    .split(PASSWORD)
    .join(REDACT)
    .split(Buffer.from(`${USER}:${PASSWORD}`).toString("base64"))
    .join(REDACT);
}

function isolateEnv() {
  for (const k of STRIP) delete process.env[k];
}

function basic() {
  return "Basic " + Buffer.from(`${USER}:${PASSWORD}`, "utf8").toString("base64");
}

function listen(server, sockets) {
  server._nativeClose = server.close.bind(server);
  server.on("connection", (s) => {
    sockets.push(s);
    s.on("error", () => {});
  });
  return new Promise((resolve, reject) => {
    server.once("error", reject);
    server.listen(0, "127.0.0.1", () => resolve(server.address().port));
  });
}

function closeServer(server) {
  const native = server._nativeClose || server.close.bind(server);
  const attempt = (fn) =>
    new Promise((resolve, reject) => {
      let settled = false;
      const done = (err) => {
        if (settled) return;
        settled = true;
        clearTimeout(t);
        if (err) reject(err);
        else resolve();
      };
      const t = setTimeout(() => done(new Error("server-close-timeout")), SHUTDOWN_MS);
      try {
        fn((err) => done(err || null));
      } catch (e) {
        done(e);
      }
    });
  return (async () => {
    try {
      if (typeof server.closeAllConnections === "function") server.closeAllConnections();
    } catch {}
    let lastErr = null;
    try {
      await attempt((cb) => server.close(cb));
    } catch (e) {
      lastErr = e;
      try {
        if (typeof server.closeAllConnections === "function") server.closeAllConnections();
      } catch {}
      try {
        await attempt((cb) => native(cb));
        lastErr = lastErr;
      } catch (e2) {
        lastErr = e2;
      }
    }
    if (server.listening) throw lastErr || new Error("server-still-listening");
    if (lastErr) throw lastErr;
  })();
}

async function guarded(name, fn, errors) {
  try {
    await Promise.race([
      fn(),
      new Promise((_, reject) => setTimeout(() => reject(new Error(`${name}-shutdown-bound`)), SHUTDOWN_MS)),
    ]);
  } catch (e) {
    errors.push(`${name}: ${e && e.message ? e.message : e}`);
  }
}

function makeCerts(tmp) {
  const caKey = path.join(tmp, "ca.key");
  const caPem = path.join(tmp, "ca.pem");
  const originKey = path.join(tmp, "origin.key");
  const originCsr = path.join(tmp, "origin.csr");
  const originPem = path.join(tmp, "origin.pem");
  const ext = path.join(tmp, "ext.cnf");
  fs.writeFileSync(ext, "subjectAltName=IP:127.0.0.1,DNS:localhost\nbasicConstraints=CA:FALSE\n");
  const run = (args) => {
    const r = spawnSync("openssl", args, { encoding: "utf8" });
    if (r.status !== 0) throw new Error(r.stderr || r.stdout);
  };
  run(["req", "-x509", "-newkey", "rsa:2048", "-keyout", caKey, "-out", caPem, "-days", "1", "-nodes", "-subj", "/CN=pw-public-lab-ca"]);
  run(["req", "-newkey", "rsa:2048", "-keyout", originKey, "-out", originCsr, "-nodes", "-subj", "/CN=127.0.0.1"]);
  run(["x509", "-req", "-in", originCsr, "-CA", caPem, "-CAkey", caKey, "-CAcreateserial", "-out", originPem, "-days", "1", "-extfile", ext]);
  return {
    caPem: fs.readFileSync(caPem, "utf8"),
    originKey: fs.readFileSync(originKey),
    originPem: fs.readFileSync(originPem),
  };
}

function startHttpOrigin(sockets) {
  const server = http.createServer((req, res) => {
    res.setHeader("Content-Type", "application/json");
    res.end(JSON.stringify({ ok: true, path: req.url, via: "http-origin" }));
  });
  return listen(server, sockets).then((port) => ({ server, port }));
}

function startHttpsOrigin(certs, sockets) {
  const server = https.createServer(
    { key: certs.originKey, cert: certs.originPem, minVersion: "TLSv1.2" },
    (req, res) => {
      res.setHeader("Content-Type", "application/json");
      res.end(JSON.stringify({ ok: true, path: req.url, via: "https-origin" }));
    },
  );
  return listen(server, sockets).then((port) => ({ server, port }));
}

function startConnectProxy(sockets) {
  const events = [];
  const server = net.createServer((socket) => {
    let buf = Buffer.alloc(0);
    const onData = (chunk) => {
      buf = Buffer.concat([buf, chunk]);
      const text = buf.toString("latin1");
      const split = text.indexOf("\r\n\r\n");
      if (split < 0) return;
      socket.removeListener("data", onData);
      const head = text.slice(0, split);
      const lines = head.split("\r\n");
      const [method, target] = lines[0].split(" ");
      const headers = {};
      for (const line of lines.slice(1)) {
        const c = line.indexOf(":");
        if (c >= 0) headers[line.slice(0, c).trim().toLowerCase()] = line.slice(c + 1).trim();
      }
      const rec = { method, target, auth: headers["proxy-authorization"] === basic() };
      events.push(rec);
      if (!rec.auth) {
        socket.end("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"lab\"\r\nContent-Length: 0\r\n\r\n");
        return;
      }
      if (method === "CONNECT") {
        const [host, portStr] = target.split(":");
        const dest = net.connect(Number(portStr), host, () => {
          socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
          socket.pipe(dest);
          dest.pipe(socket);
        });
        sockets.push(dest);
        dest.on("error", () => socket.destroy());
        return;
      }
      let url;
      try {
        url = new URL(target);
      } catch {
        socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
        return;
      }
      const req = http.request(
        {
          hostname: url.hostname,
          port: url.port || 80,
          path: url.pathname + url.search,
          method,
          headers: { host: url.host },
        },
        (res) => {
          socket.write(`HTTP/1.1 ${res.statusCode} ${res.statusMessage}\r\n`);
          for (const [k, v] of Object.entries(res.headers)) socket.write(`${k}: ${v}\r\n`);
          socket.write("\r\n");
          res.pipe(socket);
        },
      );
      req.on("error", () => socket.destroy());
      req.end();
    };
    socket.on("data", onData);
    socket.on("error", () => {});
  });
  return listen(server, sockets).then((port) => ({ server, port, events }));
}

function nodeHttpsWithCa(port, caPem, timeoutMs = TIMEOUT_MS) {
  return new Promise((resolve, reject) => {
    let settled = false;
    let deadline;
    const finish = (fn, arg) => {
      if (settled) return;
      settled = true;
      clearTimeout(deadline);
      fn(arg);
    };
    const failTimeout = () => {
      const err = new Error("node-https-timeout");
      err.name = "NodeHttpsTimeoutError";
      try {
        req.destroy(err);
      } catch {}
      finish(reject, err);
    };
    const req = https.request(
      {
        hostname: "localhost",
        port,
        path: "/",
        method: "GET",
        ca: caPem,
        rejectUnauthorized: true,
        timeout: timeoutMs,
      },
      (res) => {
        const chunks = [];
        res.on("data", (c) => chunks.push(c));
        res.on("end", () => finish(resolve, { status: res.statusCode, body: Buffer.concat(chunks).toString("utf8") }));
        res.on("error", (e) => finish(reject, e));
      },
    );
    req.on("timeout", failTimeout);
    req.on("error", (e) => finish(reject, e));
    deadline = setTimeout(failTimeout, timeoutMs);
    req.end();
  });
}

async function main() {
  isolateEnv();
  const owned = [];
  const sockets = [];
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pw-public-lab-"));
  let browser;
  const result = { ok: false, ignoreHTTPSErrors: false, cases: [] };
  const wipeTmp = () => {
    if (!tmp || !fs.existsSync(tmp)) return;
    for (const f of fs.readdirSync(tmp)) {
      if (f.endsWith(".key") || f.endsWith(".pem") || f.endsWith(".csr") || f.endsWith(".srl") || f.endsWith(".cnf")) {
        fs.rmSync(path.join(tmp, f), { force: true });
      }
    }
    fs.rmSync(tmp, { recursive: true, force: true });
  };
  try {
    const certs = makeCerts(tmp);
    const httpOrigin = await startHttpOrigin(sockets);
    owned.push(httpOrigin.server);
    const httpsOrigin = await startHttpsOrigin(certs, sockets);
    owned.push(httpsOrigin.server);
    const proxy = await startConnectProxy(sockets);
    owned.push(proxy.server);

    browser = await chromium.launch({
      proxy: {
        server: `http://127.0.0.1:${proxy.port}`,
        username: USER,
        password: PASSWORD,
      },
      timeout: 30000,
    });
    const context = await browser.newContext();
    const page = await context.newPage();
    page.setDefaultTimeout(TIMEOUT_MS);

    const httpRes = await page.goto(`http://127.0.0.1:${httpOrigin.port}/`);
    const httpBody = await page.textContent("body");
    result.cases.push({
      name: "browser-http-via-proxy",
      status: httpRes.status(),
      body: httpBody,
      proxyEvents: proxy.events.filter((e) => e.method === "GET").map((e) => ({ method: e.method, target: e.target })),
    });

    let tlsErr = null;
    try {
      await page.goto(`https://127.0.0.1:${httpsOrigin.port}/`);
    } catch (e) {
      tlsErr = { name: e.name, message: redact(e.message) };
    }
    result.cases.push({
      name: "browser-https-untrusted-ca",
      error: tlsErr,
      connectEvents: proxy.events.filter((e) => e.method === "CONNECT"),
    });

    const trusted = await nodeHttpsWithCa(httpsOrigin.port, certs.caPem);
    result.cases.push({
      name: "node-https-with-lab-ca",
      status: trusted.status,
      body: trusted.body,
    });

    await context.close();
    result.ok =
      result.cases[0].status === 200 &&
      tlsErr != null &&
      trusted.status === 200 &&
      result.cases[1].connectEvents.length >= 1;
    result.httpOrigin = httpOrigin.port;
    result.httpsOrigin = httpsOrigin.port;
    result.proxyPort = proxy.port;
  } finally {
    const errors = [];
    try {
      await guarded(
        "browser",
        async () => {
          if (browser) await browser.close();
        },
        errors,
      );
      await guarded(
        "sockets",
        async () => {
          for (const s of sockets.splice(0)) {
            try {
              s.destroy();
            } catch {}
          }
        },
        errors,
      );
      await guarded(
        "servers",
        async () => {
          const leftover = [];
          for (const s of owned) {
            try {
              await closeServer(s);
            } catch (e) {
              errors.push(`server: ${e && e.message ? e.message : e}`);
            }
            if (s.listening) leftover.push(s.address() && s.address().port);
          }
          if (leftover.length) throw new Error(`servers-still-listening:${leftover.join(",")}`);
        },
        errors,
      );
    } finally {
      await guarded("temp-keys", async () => wipeTmp(), errors);
    }
    if (errors.length) result.cleanupErrors = errors;
    result.serversLeftover = owned.filter((s) => s.listening).map((s) => s.address() && s.address().port);
  }
  console.log(JSON.stringify(result, null, 2));
  if (!result.ok || (result.serversLeftover && result.serversLeftover.length) || (result.cleanupErrors && result.cleanupErrors.some((e) => String(e).includes("still-listening")))) {
    process.exitCode = 1;
  }
}

export { nodeHttpsWithCa, TIMEOUT_MS, makeCerts, SHUTDOWN_MS, closeServer };

const isMain =
  process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isMain) {
  main().catch((e) => {
    console.error(redact(e && e.stack ? e.stack : e));
    process.exit(1);
  });
}

2026年10月14日、そのスクリプトは ok: true、プロキシ越しのHTTP 200、ローカルHTTPSポートへの CONNECT、Chromiumの net::ERR_CERT_AUTHORITY_INVALID、実験CA付きNodeからのHTTP 200を返しました。ダミー資格情報だけです。opensslは PATH にある必要があります。

クライアントが動いたあとに供給元が要るなら、Bright Data、Oxylabs、SOAX、Decodo (Smartproxy)のカタログページは、2026年10月14日にHTTP 200を返し、HTTPとSOCKS5に触れています。それらのページは実験の出口ではありませんでした。カタログの適合は公開されたプロトコルの文であり、その業者へのPlaywrightログインではありません。

Bright Data — レジデンシャルとデータセンタープロキシ
Bright Data — レジデンシャルとデータセンタープロキシ4.41442

Bright Dataはデータ収集向けの大規模プロキシ網です。レジデンシャル、モバイル、ISP、データセンターのIPが約195か国にあります。レジデンシャルトラフィックは定期契約なしの従量払いができ、データセンターとISPアドレスは10 IPからのパックです。レジデンシャルとモバイルの利用には本人確認が必要です。

10か国
$5.00
1GBあたり
Oxylabs — レジデンシャルとデータセンタープロキシ
Oxylabs — レジデンシャルとデータセンタープロキシ4.0771

Oxylabsはレジデンシャル、モバイル、ISP、データセンタープロキシを、月$30からのひとつの月次契約で売っています。支払いはカード、電信送金、AliPay、PayPalです。

10か国
$0.45
1GBあたり
SOAX — レジデンシャルとモバイルプロキシ
SOAX — レジデンシャルとモバイルプロキシ3.8218

SOAXは共有クレジットプール上のローテーションするレジデンシャルとモバイルを売り、データセンターとISPも同じプランに入ります。Builderは月$200 + VAT(200クレジット)から。Builderのティア1トラフィックは1GBあたり$3です。

85か国
$3.00
1GBあたり
Decodo(Smartproxy) — レジデンシャルとデータセンタープロキシ
Decodo(Smartproxy) — レジデンシャルとデータセンタープロキシ4.22118

Decodo(旧Smartproxy)はレジデンシャルとデータセンタープロキシを売っています。レジデンシャルは月次プランなしで1GBあたり$4、または3 GBから1GBあたり$3.75の月次パックです。支払いはカード、PayPal、Alipay、Google/Apple Pay、暗号資産(制限あり)です。

83か国
$0.38
1GBあたり

Playwrightを配線する前のホスト、ポート、プロトコルの事前確認には、プロキシチェッカーを使ってください。より深い「このプロキシは漏らすか」の通過は、プロキシが動くかの確認を見てください。

プロキシはブラウザとコンテキストのどちらに置くべきですか?

ブラウザ級プロキシは、そのブラウザのすべてのコンテキストを覆います。コンテキスト級プロキシは一つの壺に効き、ブラウザの既定を置き換えられます。実験では、コンテキストのプロキシが二番目の転送器へトラフィックを送り、起動プロキシは静かでした。

PlaywrightはHTTPプロキシ越しにHTTPSを送りますか?

はい。プロキシURLはhttp://host:portのままです。Chromiumはそのあとhost:443へCONNECTを出します。実験はCONNECT example.com:443を記録し、TLS検証オンでHTTP 200を受けました。

プロキシ認証はどう渡しますか?

HTTPプロキシでは、proxyオブジェクトへusernameとpasswordを置いてください。無いまたは間違ったBasic認証は、Chromium 156.0.8078.4でHTTP 407を返しました。オリジンのhttpCredentialsはプロキシ資格情報ではありません。

Chromium、Firefox、WebKitはユーザー名付きSOCKS5を使えますか?

Playwright 1.64.0は、chromium、firefox、webkitの起動時にSOCKS5認証を拒み、Browser does not support socks5 proxy authenticationと出しました。認証なしのsocks5://は、実験のChromiumで動きました。

実験CAを通すためにignoreHTTPSErrorsを付けるべきですか?

いいえ。信頼されないローカルTLSは、CONNECTのあとnet::ERR_CERT_AUTHORITY_INVALIDで失敗しました。実験CA付きのNode httpsは検証できました。この案内ではignoreHTTPSErrorsはfalseのままです。

bypassはプロキシを一度も使わない意味ですか?

いいえ。bypassはホストの一覧です。bypassに127.0.0.1があるとプロキシを飛ばしました。bypassがexample.comだけのとき、同じオリジンはまだプロキシを使いました。

チェッカーや自分のIPのページは、Playwrightの経路を証明しますか?

いいえ。チェッカーは端点が応答するかを試します。ブラウザの自分のIPページは、そのブラウザの出口です。Playwrightの証明は、プロキシのCONNECTまたはGETのログと、Playwrightプロセスからのページ結果です。