🌤

「GET でパラメータを送るなよ」のセキュリティー要件にユーザビリティを落とさずにどう立ち向かうか

2022/12/03に公開

Web アプリのお話です。プロジェクトの方針により 「GETパラメータを使わないでください」という話しがたまにあると思います。セキュリティーの観点からだと思いますがそもそも Web を否定してない?と思わなくもないです。https で暗号化されているし、リファラから情報が漏れないようにしているし大丈夫かとも思うんですが、「クライアントのキャッシュを見られたら」「サーバのログを見られたら」という話もあり、それはもうその時点で色々終わっているんじゃないかと思いますが対策を考えてみます。

POST で画面遷移していると history back した時やページのリロードをした時に「再送信しますか?」的なダイアログを表示されますが、検索処理であれが表示されるのはユーザビリティの観点から避けたいですよね。それと POST だとページ間の連携がやりづらいとかありますね。

そこで Salesforce の Web ページはどう対応しているのか着目してみました。Salesforce で検索を実行すると URL の後ろに謎の文字列が付いているのが分かりました。これ base64 encode かな?と思い decode してみると JSON 文字列が復元できました。そしてよく見ると URL に ? ではなく # が使われているのが分かりました。つまり以下のような挙動でした。

  1. 検索ボタンがクリックされると Fetch/XHR の POST で検索条件をサーバに送信
  2. 検索条件を JSON文字列に変換 -> 文字列をバイナリに変換 -> base64 エンコード -> URL のハッシュに付ける
  3. history back やリロードされたときは、URL のハッシュ部分を base64 デコード -> バイナリを文字列に変換 -> 復元した検索条件を画面に反映。再検索(Fetch/XHR POST)をして画面を復元する、もしくは history の state から復元する。

この方法だとサーバに GET のパラメータで情報が送られることはないですね。クライアントのキャッシュにはハッシュ部分の情報は残りそうな気がしますが一応要件はクリアし、ユーザビリティも落とすことはなさそうです。base64 でエンコードするのは、パット見分からないようにするくらいの意味しかないかもしれません。

ということで素の JavaScript でサンプルを作ってみました。
操作しているイメージは、以下です。

サンプルページは、以下です。

https://chibat.github.io/hash-param/

コードは以下です。

<!DOCTYPE html>
<html>

<head>
  <meta charset='utf-8'>
  <link href="https://cdn.jsdelivr.net/npm/@exampledev/new.css@1.1.2/new.min.css" rel="stylesheet">
</head>

<body>
  <form id="form">
    <input type="text" name="word" placeholder="Search" autocomplete="off" />
    <button type="submit">Search</button>
  </form>
  <div id="date"></div>
  <div id="action"></div>
  <script>
    const form = document.getElementById('form');
    const date = document.getElementById('date');
    const action = document.getElementById('action');

    // 文字列をバイナリに変換
    // https://developer.mozilla.org/ja/docs/Web/API/btoa#unicode_%E6%96%87%E5%AD%97%E5%88%97
    function toBinary(string) {
      const codeUnits = new Uint16Array(string.length);
      for (let i = 0; i < codeUnits.length; i++) {
        codeUnits[i] = string.charCodeAt(i);
      }

      // TypeScript の場合は、`...Array.from(` を追加しないと型エラーになる。。
      return String.fromCharCode(...new Uint8Array(codeUnits.buffer));
    }

    // バイナリを文字列に変換
    // https://developer.mozilla.org/ja/docs/Web/API/btoa#unicode_%E6%96%87%E5%AD%97%E5%88%97
    function fromBinary(binary) {
      const bytes = new Uint8Array(binary.length);
      for (let i = 0; i < bytes.length; i++) {
        bytes[i] = binary.charCodeAt(i);
      }

      // TypeScript の場合は、`...Array.from(` を追加しないと型エラーになる。。
      return String.fromCharCode(...new Uint16Array(bytes.buffer));
    }

    // サーバにリクエスト
    function search(pushState) {
      if (!form.word.value) {
        return;
      }
      // 検索条件を JSON 化
      const body = JSON.stringify({ word: form.word.value });
      fetch(location.pathname + '/data.json', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body,
      }).then(res => {
        // とりあえずレスポンスは無視して読み込み日時をページに表示するようにします。

        // 検索条件の JSON -> 文字列をバイナリに変換 -> base64 エンコード -> URL のハッシュに付ける
        const hash = '#' + btoa(toBinary(body));
        const now = new Date().toISOString();
        date.innerText = now;
        if (pushState) {
          history.pushState(now, '', hash);
          action.innerText = 'テキストフィールドの入力値で検索';
        } else {
          history.replaceState(now, null, hash);
          action.innerText = 'URLハッシュの値で検索';
        }
      });
    }

    // URL のハッシュ部分から検索条件を復元
    function restoreForm() {
      if (location.hash) {
        // URL のハッシュ部分を base64 デコード -> バイナリを文字列に変換
        const decodedHash = fromBinary(atob(location.hash.substring(1)));
        const formObject = JSON.parse(decodedHash);
        form.word.value = formObject.word; // 検索条件を画面に反映
      } else {
        form.word.value = '';
      }
    }

    form.addEventListener("submit", event => {
      // 検索ボタンをクリックした時に実行される。
      event.preventDefault();
      search(true);
    });

    addEventListener('popstate', event => {
      // history back した時に実行される。
      date.innerText = event.state;
      action.innerText = 'state からページを復元';
      restoreForm();
    });

    // 初回読み込みやリロードした時に実行される。
    restoreForm();
    search(false);

  </script>
</body>

</html>

これでも何か言われるようら「Salesforce と同じ技を使ってますよ」で通したい。。
以上、こんな感じでいかがでしょうか?

Discussion