💂

お問い合わせフォームにおけるセキュリティにふれる

2024/11/26に公開

セキュリティに関すること

前回、前々回とReact Hook FormとEmailJSを使ってフロントエンドの実装のみで簡単なWebお問い合わせフォームの作成手順について確認してきました。
しかし、実装している間、どうしても拭えない漠然とした不安がありました。それがセキュリティについての懸念です。
セキュリティについては学習不足のため、具体的な懸念事項や対策を十分に把握できていませんでした。それでも、「各入力フィールドに適切なバリデーションが設定できているのか」「第三者が入力した内容をそのままメールとして受信して良いのか」「スパム対策はどうすればいいのか」など、漠然とした不安が募っていました。

そのため、この機会にお問い合わせフォームを取り巻くセキュリティ上の懸念事項と対策について簡単に調べてみましたので、以下にまとめておきたいと思います。

まずは入口として懸念すべき事項をChatGPTに聞いてみました。
ChatGPTのアドバイスを踏まえて、今回作成したお問い合わせフォームについて、以下3つの面から評価していきたいと思います。

  1. XSSへの対策
  2. スパム対策
  3. フィッシング対策

1. XSSへの対策

ここで浮かんだ疑問は、「フォームに悪意のあるスクリプトが入力されて送信された場合、受信側のメールクライアントでそのスクリプトが実行される可能性があるのではないか」という点です。
この可能性はゼロではありません。ただし、XSSの発生条件やEmailJSの仕様、使用しているメールクライアントのセキュリティ対策について知ることで、適切な対応ができると考えました。

XSSの発生条件とEmailJS、メールクライアントにおいて講じられている対策

XSSの動作条件
まずは敵を知るところからということで、XSSの発生条件について確認します。

EmailJSにおける安全対策
EmailJSにはテンプレート内で受信データを扱う際にエスケープ処理があるため、通常は安全だそうです。これについて実際に確認してみます。
上記の手順で見てきたようにEmailJSではメールテンプレート内で{{ }}で囲んだ箇所にフォームから入力された内容を反映していました。EmailJSのドキュメントによると、この二重のカッコを三重カッコにするとHTMLを直接埋め込むことが可能になるそうです。実際に試してみると以下の通りでした。
以下に、現在の状態で問い合わせ内容のフィールドにHTMLタグを埋め込んでメール送信してみた結果を示します。

入力したHTMLタグはそのまま文字列として認識されています。

次にメールテンプレートで{{content}}としていた部分を{{{content}}}に変更し、同様の内容を送信してみます。

フワチャンが入力したHTMLがレンダリングされ赤い文字でテキストが送られてきました。怖さが倍増しています。

このように、{{{ }}}で埋め込まれたHTMLは、送信先のメールクライアントでレンダリングされ流ことがわかりました。
つまりこの場合において、先ほど確認したXSSの動作条件を満たしてしまう可能性があります。{{{ }}}の使用についてはEmailJSのドキュメントでもセキュリティ上問題があるとして、注意喚起されています。
https://www.emailjs.com/docs/faq/can-i-send-html-from-my-code/
以上の結果から、EmailJSでメールテンプレートを作成する際は、ユーザー入力値を反映する箇所では{{{ }}}ではなく{{ }}を使用するように心がけていれば、フィールドにHTMLが入力されたとしても、それがそのままの形で送られることはないと考えられます。

メールクライアントでの安全対策
メールクライアント側での安全対策も確認しておきます。
調べてみた限りでは、昨今GmailやOutlookなどのモダンなメールクライアントのほとんどはセキュリティ対策として、受信したメール内の危険なスクリプト(例: <script>タグやイベントハンドラonloadなど)を無効化または削除する仕様になっているそうです。
そのため、HTMLコンテンツをレンダリングする際、直接的なスクリプトが実行されることは実質困難であると考えられます。
この部分についても簡単に試してみます。

今回はEmailJSのテンプレート設定でcontentの部分に、あえて{{{ }}}を使用し、フィールドに入力されたHTMLはHTMLとして送信されるようにしています。
その上で、以下の内容を入力フィールドに記入し送信してみます。

このように、入力フィールドにscriptタグが埋め込まれていた場合でも、これがそのままGmailクライアントで表示されることはなく、完全に無視されています。

同様に以下のように、イベントハンドラを埋め込んだボタン要素を送信してみます。

メール内容にボタンが表示されていますが、このボタンをデベロッパーツールで確認すると、イベントハンドラが取り除かれたただのボタン要素になっていることがわかります。(画像がわかりにくくてすみません)

次はスクリプト実行を埋め込んだimgタグを埋め込んで送信してみます。

この場合も受信結果には反映されませんでした。
以上から、Gmailでは受信メールに含まれるスクリプトに対して対策されていることがわかりました。

XSSへの対策まとめ

以上から、XSSへの対策についてはEmailJS、Gmailの側でそれぞれ対策が講じられていることがわかりました。利用する側としてはテンプレート作成時に{{{ }}}を使用しないことを徹底すれば、まずは安全に利用することができそうです。
ここからさらに安全を見るのであれば、実装のレベルでは入力した内容がそのまま送信される前に、入力値をサニタイズしHTMLエスケープ処理をしておくといった対応もできそうです。(サニタイズに関連した説明は後述します)

2. スパム対策

スパムは基本的に自動化されたプログラム(ボット)やスクリプトによって大量に送信されます。そのため、スパム対策としてはボットによって行われた問い合わせ送信プロセスを防ぐことができれば効果がありそうです。

スパム対策

スパムの対策にはGoogleが提供するreCAPTCHAというツールが有用です。reCAPTCHAはボット(自動化プログラム)による不正アクセスやスパム送信を防ぐための仕組みです。
そして、EmailJSはreCAPTCHAとの統合をサポートしており、以下のドキュメントで導入手順が説明されています。
https://www.emailjs.com/docs/user-guide/adding-captcha-verification/
設定の流れとしては、

  1. reCAPTCHAのアカウントを作成し、reCAPTCHAを適用するドメイン先の登録などを済ませる。
  2. React側でEmailJSのsendメソッドの引数のparamsにg-recaptcha-response プロパティを追加し、そこにreCAPTCHA側から発行されるトークンを設定する。
  3. EmailJSのメールテンプレート作成画面のSettingタブからreCAPTCHAを有効にする
    と案外簡単そうです。
    実際に導入を進めながら記事に残したいと思ったのですが、Google reCAPTCHA側で適用先のドメインの登録などが必要となるため、導入手順の詳細な説明については今回は割愛させていただきます。
    また改めて試したいと思います。

スパム対策まとめ

スパム対策としてはGoogle reCAPTCHAなどのツールに頼ることで効果的に対策ができそうです。EmailJSとの親和性も高く、実際に公開するWebサイトで問い合わせフォームを作る場合は、ぜひとも導入しておきたいと思いました。

3. フィッシング対策

最後にフィッシング対策について確認しておきます。

https://www.cdnetworks.com/ja/glossary/what-is-a-phishing-attack/

対策

フィッシング対策としては受信者自身の意識向上も不可欠なのですが、今回は入力フォームの側でできそうな対策として、以下2点を実装してみることとしました。

まずは、URLスキームについてです。
URLスキームというのはURLの最初から:までの部分を指すようで、http:https:だけでなくjavascript:data:で始まるものもあるそうです。
https://developer.mozilla.org/ja/docs/Web/URI/Schemes
例えばjavascript:はjavascriptコードを実行するためのスキームで、javascript:alert(”Hello”)で構成されるURLをクリックすると、alert(”Hello”)が実行されます。
今回はこうしたhttp:https:以外のURLがフィールドに入力されていた場合はそれらを排除できるようにしたいと思います。
実装としては入力内容に対して正規表現でURLに該当する部分を検知し、マッチングした部分がhttp:, https:以外のスキームであればそれを””に置き換えることとしました。

次にhttp:, https:で始まるURLに対してrel="noopener noreferrer"の属性を追加します。
それぞれのオプションは以下のような働きをします。

  • noopener : hrefで指定されるリソースにアクセスした際に、開いた元の文書への参照を防ぐことができます。
  • noreferrer : hrefで指定されるリソースにアクセスした際に、リファラー情報が漏洩することを防ぐことができます。リファラー情報とは、現在アクセスしているウェブページが、どのページ(URL)からリンクをたどって訪問されたかを示す情報です。

https://developer.mozilla.org/ja/docs/Web/HTML/Attributes/rel/noopener

https://developer.mozilla.org/ja/docs/Web/HTML/Attributes/rel/noreferrer

https://mathiasbynens.github.io/rel-noopener/

今回はこのURLを変換する機能をカスタムフックとして実装しました。
入力値に対してカスタムフックから取得した変換用関数を実行し、その戻り値をEmailJSのsend関数に渡すようにします。
以下にカスタムフックの実装とそれをEmailJSのsend関数の引数に渡す例を示します。
src/hooks/useUrlSanitize.tsxに実装したカスタムフック

import { useCallback } from "react";

const urlRegex = /([a-zA-Z][a-zA-Z0-9+.-]*:[^\s]+)/g;

// isValidUrl: URLのスキームを確認する関数
const isValidUrl = (url: string): boolean => {
  try {
    const parsedUrl = new URL(url);
    return parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
  } catch {
    return false;
  }
};

// addRelNoopener: URL部分に rel="noopener noreferrer" を追加する関数
const addRelNoopener = (url: string): string => {
  return `<a href="${url}" rel="noopener noreferrer">${url}</a>`;
};

// カスタムフック
const useUrlSanitizer = () => {
  const sanitizeText = useCallback((inputText: string): string => {
    // URL部分を置き換え処理
    return inputText.replace(urlRegex, (urlMatch) => {
      if (!isValidUrl(urlMatch)) {
        // 無効なURLは空文字に置き換える
        return "";
      }
      // 有効なURLには rel="noopener noreferrer" を付与
      return addRelNoopener(urlMatch);
    });
  }, []);

  return { sanitizeText };
};

export default useUrlSanitizer;

このカスタムフックからsanitizeText関数を取り出し、EmailJSのsend関数に渡す引数に対して関数を実行します。

import { init, send } from '@emailjs/browser';

// ...

const TestForm = () => {
	const { register, handleSubmit, reset, formState: { errors } } = useForm<InputTypes>();

  const onsubmit = async (data: InputTypes) => {
    const userID = process.env.REACT_APP_EMAIL_API_KEY
    const serviceID = process.env.REACT_APP_EMAIL_SERVICE_ID
    const templateID = process.env.REACT_APP_EMAIL_TEMPLATE_ID

    if (userID && serviceID && templateID) {
      init(userID)
      
      // textareaに入力された値に対してカスタムフックから取り出した関数を実行
      const sanitizedText = sanitizeText(data.content)

      const params = {
        name: data.name,
        furigana: data.furigana,
        email: data.email,
        content: sanitizedText // <- 戻り値をsend関数に渡すpramsの値に設定
      }

      try {
        await send(serviceID, templateID, params, userID)
        alert('この度はお問い合わせ頂き、ありがとうございます。\nお問い合わせを受け付けました。。')
        reset()
      } catch (error) {
        alert('お問い合わせの受付に失敗しました。\n大変恐れ入りますが、しばらく時間をおいてから再度お試しください。')
      }
    }
  }
  
  // ..,
}

実際に以下のようなURLを入力し送信してみます。
すると、受信したメールの内容にはjavascript:alert(”hello”)は記載されず、https://google.comのaタグには、rel="noopener noreferrer" の属性が付与されていることが確認できました。

まとめ

前回記事に引き続き、フロントエンドの実装のみでお問い合わせフォームを実装する方法について見てきました。
実装したのはごく簡単なフォームでしたが、それでもセキュリティ上の懸念すべき事項はいくつかあり、今回はその中の一端についてふれてみました。
攻撃手法は日々変化を遂げており、セキュリティについてはいくら対策してもしすぎることはないと思うので、今後もキャッチアップを続けていきたいと思います。
以上、長くなりましたがお読みいただきありがとうございました。ではまた。

Discussion