0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[悪用禁止] 外部サイトのDOMを自前SPAで操る(中編):RPCによる関数の遠隔実行

0
Last updated at Posted at 2026-03-29

はじめに

前回の 「前編」 では、JSを用いてCross-Domain通信路(Bridge)を確保し、Heartbeatで生存確認を行う方法を解説しました。

シリーズ記事

記事 内容
1 [悪用禁止] 外部サイトのDOMを自前SPAで操る(前編):Cross-Domain通信の仕組み postMessageによるセキュアな通信基盤とHeartbeatの実装
2 [悪用禁止] 外部サイトのDOMを自前SPAで操る(中編):RPCによる関数の遠隔実行 本編
3 [悪用禁止] 外部サイトのDOMを自前SPAで操る(後編):Node.jsとTerserによる自動化と配備 Terserを利用したビルド自動化とBookmarkletによる配備

開発者自身がコンソールでデバッグする分には「繋がっていること」が確認できれば十分ですが、高度なツールを作るには「Parent側のDOMをChildから自在に操る」という一歩踏み込んだ機能が欲しくなります。

今回は、あたかもParent側の関数を直接呼んでいるかのような 「RPC(Remote Procedure Call)」 の実装について詳しく見ていきます。


方法:

postMessageを「関数実行」として扱う仕組み

postMessage は本来「データ(文字列やオブジェクト)」しか送れません。
これを「関数実行」に変換する技術的な仕組みは、JavaScriptの .toString()eval() の組み合わせによるものです。

SPA側 (Child) のRPCロジック統合

前編で解説した「疎通確認(Heartbeat)」のロジックに、今回の「RPC実行」のロジックを統合した全量コードです。

index.js
// index.js (Child)
let isConnected = false;
let lastPongTime = Date.now();
let rpcCallbacks = {}; // レスポンス待機中のリクエストを管理
const targetOrigin = new URLSearchParams(window.location.hash.slice(1)).get('parentDomain');

// Parent側で関数を実行するためのPromiseラッパー
const execInParent = (executorFn, ...args) => {
    return new Promise((resolve, reject) => {
        if (!isConnected) return reject("Not connected");

        const timestamp = Date.now();
        rpcCallbacks[timestamp] = { resolve, reject };

        // 関数を文字列化して送信
        window.opener.postMessage({
            type: 'RPC_REQUEST',
            functionStr: executorFn.toString(),
            args: args,
            timestamp: timestamp
        }, targetOrigin);
    });
};

// メッセージハンドラ(Heartbeat + RPCレスポンスの両方を処理)
window.addEventListener("message", (event) => {
    if (event.origin !== targetOrigin) return;
    const { type, timestamp, data } = event.data;

    if (type === 'HEARTBEAT_PONG') {
        isConnected = true; 
        lastPongTime = Date.now();
    } 
    else if (type === 'RPC_RESPONSE' && rpcCallbacks[timestamp]) {
        rpcCallbacks[timestamp].resolve(data);
        delete rpcCallbacks[timestamp];
    }
});

// 1秒ごとにPING送信および死活監視
setInterval(() => {
    if (Date.now() - lastPongTime > 5000) {
        isConnected = false;
        console.warn("Status: Disconnected (Timeout)");
    }

    if (window.opener && targetOrigin) {
        window.opener.postMessage({ type: 'HEARTBEAT_PING' }, targetOrigin);
    }
}, 1000);

// 使用例:Parent側のタイトルを取得し、引数のメッセージと共に返す関数
// ※ この関数の中身が文字列化され、Parent(外部サイト)側で実行されます
function getParentTitleAndLog(message) {
    const currentTitle = document.title;

    // このログはParent(外部サイト)側のコンソールに出力されます
    console.log(`[RPC Environment] Title: ${currentTitle}, Message: ${message}`);
    
    return { 
        title: currentTitle, 
        text: message 
    };
}

// 第一引数に関数、第二引数以降に任意の引数を渡して実行
execInParent(getParentTitleAndLog, "Hello from Child!").then(result => {
    // 実行結果はChild(SPA)側のコンソールに出力されます
    console.log("RPC Result received in Child:", result);
});

Parent側 (Snippet内) の実行ロジック

前編で解説した「ウィンドウ起動」と「Heartbeat応答」のコードに、今回の「RPC実行」のロジックを統合した全量コードです。このスニペットをParent側(操作対象のサイト)のコンソールに貼り付けることで、通信と実行の準備が整います。

// Parent (Console)
const parentDomain = window.location.origin;
const spaUrl = `http://localhost:3000/#parentDomain=${encodeURIComponent(parentDomain)}`;

// 1. SPAを子画面として開く
const subWin = window.open(spaUrl, "Cross-Domain-RPC-Demo");
if (subWin) subWin.focus();

// 2. メッセージハンドラ(Heartbeat応答 + RPC実行)
window.addEventListener("message", (event) => {
    // セキュリティチェック:許可したオリジン(localhost:3000)以外は無視
    if (event.origin !== "http://localhost:3000") return;

    const { type, functionStr, timestamp, args } = event.data;

    // Heartbeat (PING) への応答
    if (type === 'HEARTBEAT_PING') {
        event.source.postMessage({ type: 'HEARTBEAT_PONG' }, event.origin);
    } 
    // RPCリクエスト(関数実行命令)の処理
    else if (type === "RPC_REQUEST") {
        try {
            // 文字列化された関数を復元してParentのコンテキストで実行
            const executor = eval('(' + functionStr + ')');
            const result = executor(...args);

            // 実行結果をChild側へ返信
            event.source.postMessage({
                type: "RPC_RESPONSE",
                timestamp,
                data: result
            }, event.origin);
        } catch (e) {
            console.error("RPC Error:", e);
        }
    }
});

この仕組みの特筆すべきメリットは、Parent側のDOMを操作するロジックを Child側のエディタで管理しつつ、実行自体はParent側のコンテキストで行える 点にあります。


おわりに(中編)

ここまでの手順で、ドメインの壁を超えて「関数」を送り込み、その実行結果を受け取ることが可能になりました。

しかし、開発のたびにこの長いスクリプトをコンソールへ貼り付けるのは非効率ですし、一般ユーザーに「開発者ツールを開いてください」と頼むのも現実的ではありません。

次回の 「後編」 では、この仕組みを自動化し、誰でもワンクリックで導入できる 「Bookmarklet」 として配布・運用する方法について詳しく解説します。


余談: CSP(Content Security Policy)による制限と回避策

本手法は、postMessage によってブラウザの同一生成元ポリシー(Same-Origin Policy)を回避できますが、Parent側(操作対象のサイト)の CSP(Content Security Policy) による制限は依然として受けます。

具体的には、対象サイトの CSP ヘッダーの script-src ディレクティブに 'unsafe-eval' が明示的に許可されていない場合、関数をシリアライズして送り込もうとすると、ブラウザ側で実行が拒否され EvalError が発生します。

もしこの制限を回避して、より高度な自動化やデータ抽出を実現したい場合は、ページ側の CSP に縛られない特権を持つ ブラウザ拡張機能(Content Script) として開発するか、Tampermonkey などのユーザースクリプトマネージャを活用するアプローチを検討してください。


参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?