ユーザー入力スクリプトの実行

ユーザー入力スクリプトの実行

投稿日: 2024年12月21日

学習振り返り
要約
  • ユーザーが入力したコードを安全に実行するために、sandbox属性付きのiframeを利用し、postMessageでログ情報を親ウィンドウに伝達する仕組みを実装しました。
  • 実行ボタン押下時に、DOMPurifyでサニタイズしたコードをiframe内で実行し、console.log、console.error、console.warnをオーバーライドしてログを送信しています。
  • セキュリティを意識しながら実装を進め、自身の成長を感じている一方で、引き続き技術を学び続ける必要性を認識しています。

はじめに

学習アプリのプロトタイプを開発するにあたり、山いくつかありましたが、最初の大きな山だった入力スクリプトの実行について実際のロジックを振りかえってみたいと思います。

ユーザー入力プロンプトの実行|ShiftBブログ

このコードエディターで書いたコードを実行ボタンでログのエリアに出力する方法です!


調査

上記リンク記事にも書きましたが、evalやfunctionsはDOMの書き換えされるなどのリスクを伴うので使うなという情報がたくさん目について、安全に実行する方法を調べました。

ユーザー入力プロンプトの実行|ShiftBブログ

結局隠しiframeタグにsandbox属性をつけてその別ウィンドウ中で実行する、親ウィンドウにはpostMessageを使ってログ情報を伝えるという手段で実装しました。

今回はこの内容について詳しく見ていきます。

コード

useCodeExecutor.ts


import DOMPurify from "dompurify";
import { useEffect, useRef } from "react";
import { LogType } from "../(member)/courses/[courseId]/[lessonId]/[questionId]/_types/LogType";

export const useCodeExecutor = (
  addLog: (type: LogType, message: string) => void
) => {
  const iframeRef = useRef<HTMLIFrameElement>(null);
  useEffect(() => {
    const handleMessage = (event: MessageEvent) => {
      const { type, messages } = event.data;
      if (type === "log" || type === "warn" || type === "error") {
        addLog(type, messages);
      }
    };
    window.addEventListener("message", handleMessage);
    return () => {
      window.removeEventListener("message", handleMessage);
    };
  }, [addLog]);
  const executeCode = (code: string) => {
    if (!iframeRef.current) return;
    const iframe = iframeRef.current;
    const sanitizedCode = DOMPurify.sanitize(code);

    iframe.srcdoc = `
      <!DOCTYPE html>
      <html>
        <body>
          <script>
            const setupConsole = () => {
              const originalLog = console.log;
              console.log = (...args) => {
              const formattedArgs = args.map(arg =>
                  typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg
                );
                window.parent.postMessage({ type: 'log', messages: formattedArgs.join(' ') }, '*');
              };
              console.error = (...args) => {
                const formattedArgs = args.map(arg =>
                  typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg
                );
                window.parent.postMessage({ type: 'error', messages: formattedArgs.join(' ') }, '*');
              };
              console.warn = (...args) => {
                const formattedArgs = args.map(arg =>
                  typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg
                );
                window.parent.postMessage({ type: 'warn', messages: formattedArgs.join(' ') }, '*');
              };

              try {
                const userCode = () => {
                  ${sanitizedCode}
                }
                userCode();
              } catch (error) {
                console.error('Error:', error.toString());
                window.parent.postMessage({ type: 'error', error: error.toString() }, '*');
              }
            };
            setupConsole();
          </script>
        </body>
      </html>
    `;
  };

  return { iframeRef, executeCode };
};

ContentArea.tsx

"use client";
import {
  faTriangleExclamation,
  faCircleExclamation,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import { LogType } from "../_types/LogType";
import { BreadCrumbs } from "./Breadcrumbs";
import { CodeEditor } from "./CodeEditor";
import { ConsoleType } from "./ConsoleType";
import { PaginationControls } from "./PaginationControls";
import { StatusBadge } from "@/app/_components/StatusBadge";
import { useCodeExecutor } from "@/app/_hooks/useCodeExecutor";
import { useQuestion } from "@/app/_hooks/useQuestion";
import { useQuestions } from "@/app/_hooks/useQuestions";
import { Status } from "@/app/_types/Status";
import { answerStatus } from "@/app/_utils/answerStatus";
import { language } from "@/app/_utils/language";

type ContentAreaProps = {
  answerCode: string;
  setAnswerCode: (value: string) => void;
  addLog: (type: LogType, message: string) => void;
  resetLogs: () => void;
  executionResult: { type: string; message: string }[];
};
export const ContentArea: React.FC<ContentAreaProps> = ({
  answerCode,
  setAnswerCode,
  addLog,
  resetLogs,
  executionResult,
}) => {
  const { iframeRef, executeCode } = useCodeExecutor(addLog);
  const { lessonId, questionId } = useParams();
  const { data, error } = useQuestion({ questionId: questionId as string });
  const { data: questions, error: questionsError } = useQuestions({
    lessonId: lessonId as string,
  });

  useEffect(() => {
    if (!data?.answer) return;
    setAnswerCode(data.answer.answer);
  }, [data, setAnswerCode]);

  if (!questions) return <div>読込み中...</div>;
  if (!data) return <div>読込み中...</div>;
  if (error) return <div>問題情報取得中にエラーが発生しました</div>;
  if (questionsError)
    return <div>問題一覧情報取得中にエラーが発生しました</div>;
  if (questions.questions.length === 0)
    return <div className="text-center">問題がありません</div>;
  const currentQuestionNumber = questions.questions.findIndex(
    (q) => q.id === parseInt(questionId as string, 10)
  );
  const currentStatus: Status = data.answer
    ? answerStatus(data.answer.status)
    : "未提出";
  const example = data.question.example ? `例)${data.question.example}` : "";
  return (
    <div className="flex size-full gap-6 px-6 py-5">
      <div className="w-2/5">
        <div className="flex flex-col gap-14">
          <div className="flex items-center justify-between">
            <BreadCrumbs />
            <PaginationControls />
          </div>
          <div className="flex flex-col gap-6">
            <div className="flex items-center gap-4">
              <div className="text-2xl font-bold ">{`問題${
                currentQuestionNumber + 1
              }`}</div>
              <StatusBadge status={currentStatus} />
            </div>
            <h2 className="text-4xl">{data.question.title}</h2>
            <div className="font-bold">{data.question.content}</div>
            <div className="font-bold">{example}</div>
          </div>
        </div>
      </div>
      <div className="h-full w-3/5">
        <div className="relative">
          <CodeEditor
            language={language(data.course.name)}
            value={answerCode}
            onChange={setAnswerCode}
            editerHeight="50vh"
          />
          <button
            type="button"
            className="absolute bottom-4 right-6 rounded-md bg-blue-400 px-6 py-2 text-white"
            onClick={() => {
              resetLogs();
              executeCode(answerCode);
            }}
          >
            実行
          </button>
        </div>
        <iframe
          ref={iframeRef}
          sandbox="allow-scripts allow-modals"
          className="hidden"
        />
        <div className="mt-6 h-[20vh] overflow-y-scroll bg-[#333333]">
          <ConsoleType text="ログ" />
          <div className="px-4 text-white">
            {executionResult.map((item, index) => (
              <div
                key={index}
                className={`${
                  item.type === "warn"
                    ? "text-yellow-400"
                    : item.type === "error"
                    ? "text-red-500"
                    : ""
                }`}
              >
                {item.type === "warn" && (
                  <FontAwesomeIcon
                    className="mr-2 text-yellow-400"
                    icon={faTriangleExclamation}
                  />
                )}
                {item.type === "error" && (
                  <FontAwesomeIcon
                    className="mr-2 text-red-500"
                    icon={faCircleExclamation}
                  />
                )}
                {item.message}
              </div>
            ))}
        LogType  </div>
        </div>
      </div>
    </div>
  );
};

ブログに不向きなくらいながーい!! !! と思いつつ、別で作る気がちょっと起きないのでごめんなさい。このままいきますw

解説

関係あるところをピックアップして整理します。

ContentArea.tsxのProps

answerCode: string;
setAnswerCode: (value: string) => void;
addLog: (type: LogType, message: string) => void;

ステートanswerCodeはユーザーが入力したコードが入るステートです。

addLogは下記のような処理で、実行ボタン押下時に実行する関数です。

const addLog = (type: LogType, message: string) => { 
    setExecutionResult(prevLogs => [...prevLogs, { type, message }]);   };

executionResultは実行結果を管理するステート(ログエリアに出力する値の状態管理)です。ログの型(LogType)は通常のログ(log)、警告(warn)、エラー(error)の三種類です。

肝心なのはフックuseCodeExecutor.tsです。

処理を順番に見ていきます。

まずは引数に

addLog: (type: LogType, message: string) => void

を受け取ります。

useEffect(() => {
    const handleMessage = (event: MessageEvent) => {
      const { type, messages } = event.data;
      if (type === "log" || type === "warn" || type === "error") {
        addLog(type, messages);
      }
    };
    window.addEventListener("message", handleMessage);
    return () => {
      window.removeEventListener("message", handleMessage);
    };
  }, [addLog]);

ここは、初回レンダリング時にwindow.addEventListener("message", handleMessage);が呼び出され、handleMessageがメッセージイベントを受け取る準備をします。

iframe内でJavaScriptが実行されて、postMessageが呼ばれると、親ウィンドウにメッセージが送信されます。これを親ウィンドウが監視します。

メッセージが送信されると、親ウィンドウのhandleMessage関数が呼び出されます。

メッセージのtypeがlog、warn、errorのときにaddLog関数が実行できます。(=コンソールエリアに出力するステートexecutionResultを更新される)

次にexecuteCode関数ですが、これはコードを実行するための関数です。

userefで監視しているiframeタグにHTMLを挿入してscriptタグで入力されたスクリプトを実行しています。

iframe.srcdocにHTMLを設定することで、iframe内にHTMLドキュメントが生成されます。

念のため、サニタイズの処理を行ったコードをscriptタグに入れました。

DOMPurify.sanitizeを使ってサニタイズしています。これにより、悪意のあるコード(例えば、XSS攻撃など)を防ぐことができます。

const sanitizedCode = DOMPurify.sanitize(code);

ここです。

サニタイズとは、特別な意味を持つ文字や文字列を、一定の規則に基づき排除したり他の表現に置き換える処理です!

以上が、ユーザーが入力したスクリプトを実行する処理の流れです!

scriptタグ内の処理

const originalLog = console.log;
              console.log = (...args) => {
              const formattedArgs = args.map(arg =>
                  typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg
                );
                window.parent.postMessage({ type: 'log', messages: formattedArgs.join(' ') }, '*');
              };
              console.error = (...args) => {
                const formattedArgs = args.map(arg =>
                  typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg
                );
                window.parent.postMessage({ type: 'error', messages: formattedArgs.join(' ') }, '*');
              };
              console.warn = (...args) => {
                const formattedArgs = args.map(arg =>
                  typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg
                );
                window.parent.postMessage({ type: 'warn', messages: formattedArgs.join(' ') }, '*');
              };

ここでは、iframe内で発生するコンソール出力を親ウィンドウに送信するための処理を行っています。

const originalLog = console.log;

元のconsole.logメソッドを保存して、オブジェクトの場合はJSON.stringifyを使って文字列に変換し、それ以外はそのままの形で扱います。

window.parent.postMessageを使って、フォーマットされたメッセージを親ウィンドウに送信します。
  送信されるメッセージには、メッセージのタイプ(log, error, warn)とフォーマットされたメッセージ内容が含まれます。

これで送信された時にhandleMessageが発火して、コンソールエリアに出力という流れです。

重要

sandbox="allow-scripts allow-modals"

ここです。iframeタグのsandbox属性。

sandbox属性は、iframe内での動作を制限してセキュリティを強化するために使用されます。これで安全に実行しようという意図です。

allow-scriptsをつけることで、iframe内でJavaScriptの実行を許可しますが、iframe内のスクリプトは、親ウィンドウのDOMを操作することはできません。これで親ウィンドウ(見えてる部分)の操作をすることは出来ないです。

終わりに

安全に実行するって当然大事なことなので、やり方調べてこの方針でどうでしょうかと相談してから実装しました。

とはいえ、evalの危険性等見ると怖くてすごくビビりながら実装しました。。

無知な善人なので悪意ある人が何しようとしてるかわからないし、想像してないことが出来ないようにしないといけないという思いから未だに怖いです。

だいぶAIにも助けてもらってとりあえず別ウィンドウで実行するということは出来ました。

一応ちゃんと理解してアウトプットできているので良しとしています。

やはり知らないことだらけで調べながら知識つけながら、実装する方法調べながらってやってると力が付く感じがします。

基本ボコボコですが、この辺は特にレビューでは何もコメントなかったです。

最初ちんぷんかんぷんだった処理も調べたら実装出来るようになってきて、少し力ついてきたと若干自信ついてきたようなついてないような気がします!

元々みなぎっていたやる気がさらに増幅!頑張ります!! !!(頑張る宣言しかしてないw)

シェア!

Threads
user
吉本茜
山口在住/二児の母
Loading...
記事一覧に戻る
Threads
0