🐈

RSC時代のNext.jsにおけるセキュリティ常識

2024/10/22に公開

はじめに

まず最初に、筆者はセキュリティ対策の専門家ではなく、ただのウェブ開発者です。したがって、次の内容は常識的で一般的なセキュリティ対策に限られます。

現実の複雑なセキュリティ対応には全然足りないけど、それらも分からないなら危険です。

RSCがCCに渡すPropsを厳密チェックが絶対必要

まず知っておくべき事実は、RSCがCCに渡すパラメーターはコンソールで確認できるということです。
HTMLのあるスクリプトタグ内には、このPropsが平文で保存されています。

筆者自分のアプリを例にすると、RSCでカードデータリストを取得して、そのままCCに渡して、リンダリングします。だけど、そのデータリストは完全に見られますよ。

こっちはかなり危険なところなんです。
本来表示する情報であれば一般的に問題ありませんが、パラメータを注意深くチェックしないと、不要な機密情報も漏洩してしまう可能性が非常に高いです。
例えば、ユーザーのメール情報を表示したい場合、全ユーザー表のデータをRSCで取得してCCに渡したら、情報漏洩が発生します。

だから、CCに送る内容は慎重に考えなければなりません。可能な限りRSCでロジックを完了させ、CCに情報をできるだけ少なく提供してください。

もちろん、注意深く検査することは開発をやや面倒にします。だからこれはかなり陥りやすい罠です。

認証

認証は最も基本的なセキュリティ対策であり、通常、ページ認証とインターフェース認証に分けられます。ここでは、RSCを使っている場合基本的な認証を行う方法について簡単に説明します。

現代のWebシステムでは、通常、Authライブラリーを使用してログインを実現します。

Googleアカウント、メールアドレス、またはアカウントパスワードを使用してログインすると、すべての場合にjwtフックがトリガーされます。ここでは、ライブラリーから取得したユーザーIDを使用して、自身のユーザーテーブルから権限情報を取得し、それをJWTに追加し、セッションに追加し、その後のすべてのリクエストがセッションからユーザー情報を取得して認証することができます。

ページ承認は通常、ミドルウェアで完了します。

src/middleware.ts
import { auth } from "@/auth";
import { NextRequest, NextResponse } from 'next/server';

export async function middleware(req: NextRequest) {
    const session = await auth();
    // ログインしてないなら、ログイン画面に移動します
    if (!session) {
        return NextResponse.redirect(new URL('/api/auth/signin', req.url));
    }
    // カスタマイズ認証ロジック
    if (hasPermission(session)) {
        return NextResponse.next();
    }
    // 権限がない場合、指定画面に移動します
    return NextResponse.redirect(new URL('/home', req.url));
}

export const config = {
    // 承認が必要なルートリスト
    matcher: [
        //...
    ],
};

ただし、ミドルウェアはエッジノードで実行されるため、DBにアクセスことはできないです。だから、複雑な認証ロジックの場合middlewareだけは足りないし、各画面のRSCで認証しなければならないです。

インターフェース認証は各所でチェックする必要があります。RSCを使用している場合、基本的にインターフェースは使用されず、実際の認証はRSCとServer Actionsで行われます。

簡単ですが、よくある間違いはクライアントからの情報を使用して認証することです。
クライアントからの情報は信じられないから、絶対に避けるべきです。
各認証の場所で、const session = await auth();という関数を使ってユーザー情報を取得し、それを利用して承認するのが適切です。

あと、全てのリクエストに対して、可能であれば以下の条件を加えるべきです:

where: {
    user_id: session?.userId,
}

例えば、試験の機能で exam_id を使ってデータを取得する場合でも、この条件を加えておくべきです。そうすることで、ユーザーが他のユーザーの exam_id を使って不正に試験ページにアクセスすることを防ぐことができます。

不正リクエストの防止

上記の認証だけでは、さまざまな不正リクエストを防ぐには全然足りないです。

リプレイ攻撃

最も理解しやすく実現しやすい攻撃はリプレイ攻撃です。
自分のアプリを例にすると、単語帳画面で「わかる」ボタンを押すと、その単語が消えて復習回数が増えます。

コンソールで確認すると、DBの更新はword-cardsというServer Actionを使用して行われました。
このリクエストはコピーできるし、再度実行することもできます。

コンソールで以下のコードを実行すると、毎回DBが正常に更新され、review_timesが増加しまいます。

fetch("https://japanese-memory-rsc.vercel.app/word-cards", {
  "headers": {
    "accept": "text/x-component",
    "accept-language": "en,zh;q=0.9,zh-CN;q=0.8,zh-TW;q=0.7",
    "cache-control": "no-cache",
    "content-type": "text/plain;charset=UTF-8",
    "next-action": "02cfdf8049a1b227217332c12b088365d46369e8",
    "next-router-state-tree": "%5B%22%22%2C%7B%22children%22%3A%5B%22word-cards%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Fword-cards%22%2C%22refresh%22%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D",
    "pragma": "no-cache",
    "priority": "u=1, i",
    "sec-ch-ua": "\"Google Chrome\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"",
    "sec-ch-ua-mobile": "?0",
    "sec-ch-ua-platform": "\"macOS\"",
    "sec-fetch-dest": "empty",
    "sec-fetch-mode": "cors",
    "sec-fetch-site": "same-origin"
  },
  "referrer": "https://japanese-memory-rsc.vercel.app/word-cards",
  "referrerPolicy": "strict-origin-when-cross-origin",
  "body": "[\"162de8e9-64ec-4b08-918c-99c7cfe4758f\"]",
  "method": "POST",
  "mode": "cors",
  "credentials": "include"
}).then(res => {
    res.text().then(result => {
        console.log(result)
    })
})

おめでとうございます、リプレー攻撃を成功させました。ただ、それに対抗する防御も容易です。

一番理解しやすいの対策は使い捨てトークンを利用することです。
まずuser_id、token、table_id(card_idを保存するために使用される)、及びaction_nameを含んでいるnonceテーブルを作成します。
カードリストリクエストを受け取った際、各カードに対応するtokenを作成してnonceに追加します。
その後、このtokenを他のカードデータと一緒にレスポンスします。
ユーザは更新のリクエストを発送するとき、tokenを含める必要があります。
サーバーは更新のリクエストを受け取った際、nonceテーブルでこのトークンが有効かどうかを確認し、有効な場合のみDBを更新することが許可されます。更新後、トークンはテーブルから削除されます。

このように、トークンは一度限りなので、リプレイ攻撃を防ぐことができます。

UI操作せずにリクエスト

上記の対策は、リプレイ攻撃を防ぐことができますが、UI操作せずにリクエストを回避するわけではありません。

「RSCがCCに渡すPropsを厳密チェックが絶対必要」というセクションで、RSCからCCに渡した内容は全部見られることはわかりました。だから、card_idや使い捨てtokenも見られます。ユーザはその更新リクエストのパラメータを変更して実行すると、UI操作せずにDBの更新を完了できます。

一般的に、このような行動は何の害も引き起こさないため、対処する必要はありません。しかし、使い捨てトークンは完全な対策ではないことは知っておくべきです。

身元冒用攻撃

ユーザーが悪意のあるブラウザプラグインをインストールし、その後に有名なウェブサイトにログインした場合、このサイトですべてのリクエストもクッキーを含めているため、悪意のあるプラグイン内のJSスクリプトはクッキー内のログイン状態を利用してユーザーの身元を偽装し、攻撃を実行することができます。

この攻撃とCSRFもユーザのクッキーを利用して攻撃は完成させますが、CSRFでは、開発者はクッキーをsame-siteに設定して回避することはできます。このような攻撃は当サイトで行われるため、ユーザの適当な安全意識が不可欠です、開発者に対しては回避することはできません。

もしこのような状況が発生した場合、徹底的に攻撃を防ぐことは難しいです。しかし、さまざまな対策を講じることで攻撃の難易度を上げることができます。

  1. 敏感な操作に一度性のトークンを追加し、さらに有効期限を設定します。期限が切れると再度入力する必要があります。
  2. token本体を暗号化する。
  3. 重要な操作には、SMSまたはメールでの認証コードを追加します。
    ...

暗号化

RSCがCCに渡すパラメーター、Server Actionsのパラメーターと応答、インターフェースのパラメーターと応答値などは、暗号化されていない場合にはコンソールで見ることができます。

tokenも以上の方式でクライアントに渡しますので、ここには漏洩してはならない情報が含まれていると分かります。

適度に暗号化はいいですけど、暗号化は完全に安全と言えません。理由は、クライアントが暗号化に鍵を使用する必要があるため、クライアントには鍵が必ず存在します。そして、クライアントのコードは原則として見られて解読される可能性があります。これにより、理論的には暗号化だけでは安全性が不十分であるということになります。

まとめ

  1. RSCがCCに渡すPropsのチェックは見落とされがちであり、必要だけのものを絞り込むとかなるべくRSCを運用すれば安全を確保することができるため、コスパ高いの対応です
  2. 認証は一番基本のセキュリティ対応ですが、現実世界には行わってないシステムもあるらしいです。もしそうなら、絶対最優先にやるべきことです
  3. 承認のところ、DB操作をする際に、ログイン中のユーザーIDがその行のuser_idと一致するという条件を忘れがちで、これがセキュリティ上のリスクにつながる可能性があります
  4. リプレイ攻撃は一番実現やすい攻撃だから、注意してほうがいいと思います。
  5. 現代の認証ライブラリ(例:AuthJS)では、http-onlyかつsame-siteのクッキーを使用しています。これによりCSRF攻撃やXSS攻撃を防ぐことができます、しかし悪意のあるブラウザプラグインによるの身分冒用は防げません。
  6. 悪意のあるブラウザプラグインによる攻撃は完全に防ぐことはできませんが、さまざまな対策を講じることで攻撃の難易度を上げることができます。
  7. 暗号化は有効ですが、鍵は解読される可能性があり、複雑な内容を暗号化すると性能が悪くなる可能性があります。

Discussion