表現の改善にAIを活用しています。
あらかじめご了承ください。
プロジェクトのアーキテクチャ
セキュリティ対策は、アプリケーションのアーキテクチャによって大きく変わります。
同じ Web アプリでも、フロントエンドとバックエンドが同一オリジンか別オリジンか、静的ファイルをどこから配信するかによって、必要な設定や注意点は異なります。
そのため、個別の対策を説明する前に、まずは本プロジェクトの構成を整理しておきます。
図の通り、ブラウザは三つの異なるオリジンと通信します。
- フロントエンド(Vercel): React SPAとして動作し、HTMLやJavaScriptなどの静的ファイルを配信します。ページのレンダリングはすべてクライアント側で行われます。
- バックエンド(Express): APIサーバーとして動作し、JSONのみを返します。HTMLは一切配信しません。
- Cloudflare R2: ユーザーアバターなどの静的ファイルを直接配信します。バックエンドを経由しません。
SQLインジェクション対策
SQLインジェクションとは
SQL文を組み立てる際には、ユーザ入力を条件として利用することがよくあります。
例えばサインイン処理では、ユーザが入力したメールアドレスをもとにアカウントを特定するため、次のようなSQLを実行します。
SELECT id, role FROM users WHERE email = ?
この ? の部分にユーザが入力したメールアドレスを埋め込む必要があります。しかし、ユーザー入力を文字列連結で SQL 文に埋め込むと、重大な脆弱性につながります。
const sql = `SELECT id, role FROM users WHERE email = '${email}'`;
例えば攻撃者が次のような値を入力したとします。
' OR 1 = 1 --
すると生成されるSQLは次のようになります。
SELECT id, role FROM users WHERE email = '' OR 1 = 1 --'
-- はSQLのコメント開始記号であり、それ以降の文字列はすべて無視されます。これにより、元のSQL文の末尾にあった ' が無効化され、構文エラーにならずにSQL文が成立してしまいます。
1 = 1 は常に真となる条件であり、結果として全レコードが取得される可能性があります。
このように、ユーザー入力によって SQL 文の構造や意味を意図せず変えられてしまう攻撃を SQLインジェクション と呼びます。
プレースホルダーによるエスケープ処理
このプロジェクトでは、データベースアクセスに Node.js の mysql2 を使用しており、SQL文は query() で実行しています。
await pool.query(
'SELECT id, role FROM users WHERE email = ?',
[email]
);
query() でプレースホルダー(?)を使うと、値は SQL 文の一部として文字列連結されるのではなく、ライブラリ側で適切に処理されたうえで渡されます。
そのため、ユーザー入力に SQL として意味を持つ文字列が含まれていても、検索条件の値として安全に扱いやすくなります。
パスワードのハッシュ化(bcrypt)
パスワードは機密情報であり、平文のままデータベースに保存してはいけません。
保存する場合は、パスワードハッシュ関数でハッシュ化したうえで保存するのが基本です。
ハッシュ関数は種類によって安全性と速度が大きく異なるため、用途に応じた選択が重要です。
本プロジェクトでは、パスワードハッシュ関数として bcrypt を使用しています。
bcryptを使用する理由
ハッシュ関数は他にMD5、SHA256 があります。
| MD5 / SHA256 | bcrypt | |
|---|---|---|
| 設計目的 | データの完全性検証、チェックサム | パスワード保存 |
| 速度 | 高速(意図的) | 低速(意図的) |
| ソルト | なし(別途実装が必要) | 組み込み済み |
| コスト調整 | 不可 | 可能(saltRounds) |
ソルトとはハッシュ化前にパスワードへ付加するランダムな文字列です。同じパスワードでも毎回異なるハッシュ値が生成されるため、レインボーテーブル攻撃を無効化します。
レインボーテーブルとは、よく使われるパスワードとそのハッシュ値を事前に計算した対応表です。
なお、MD5は異なる入力から同じハッシュ値が生成される衝突脆弱性も発見されており、セキュリティ用途では完全に非推奨です。
MD5 や SHA256 がパスワード保存に向いていない大きな理由は、計算が速すぎることです。
MD5 / SHA256は「データの完全性検証」や「チェックサム」のために設計されており、高速に計算できることが目的です。しかしパスワードハッシュにおいては高速であることが弱点になります。
攻撃者がハッシュ値を入手した場合、問題になるのは総当たり攻撃や辞書攻撃を非常に高速に試せてしまうことです。
MD5 や SHA256 は GPU による並列計算とも相性がよく、短時間で大量の候補を試行できます。
それに対してbcryptはコストファクター(saltRounds)によって意図的に計算を遅くする設計です。コストファクターを上げると計算時間が倍増します。(実際の処理時間は実行環境に依存する)
// saltRounds: 10 → 約100ms
// saltRounds: 12 → 約400ms
const hashedPassword = await bcrypt.hash(password, 10);
攻撃者が毎秒数十億回試行できるのに対し、bcryptなら毎秒数十回程度に抑えられます。これで攻撃者がハッシュ値からパスワードを推測するリスクが大きく下がります。
タイミング攻撃対策
タイミング攻撃とは
タイミング攻撃(Timing Attack)とは、処理時間のわずかな差から秘密情報を推測するサイドチャネル攻撃の一種です。
コンピュータの処理時間は、扱うデータによってわずかに変わることがあります。攻撃者はこの時間差を精密に計測することで、秘密の情報(パスワード、暗号鍵など)を少しずつ推測します。
このプロジェクトで特に注意したいのは、サインイン処理です。
サインイン時には、入力されたパスワードを保存済みハッシュと照合する必要があります。 前節で説明したとおり、bcrypt は安全性のために意図的に計算コストを高くしているため、この照合処理には一定の時間がかかります。
アカウント存在
| - DB確認 - | - ハッシュ化処理 - |
アカウントが存在しない
| - DB確認 - |
アカウントが存在する場合、bcryptのハッシュ化処理(約100ms)が加わるため、存在しない場合と比べて応答時間が明らかに長くなります。
もし「アカウントが存在する場合だけ bcrypt.compare() が実行される」実装になっていると、攻撃者は複数のアカウントIDでサインインを試し、応答時間の差からアカウントの存在を推測できる可能性があります。
サインイン処理時間の定数化
if (userRows.length > 0) {
user = userRows[0];
passwordMatch = await bcrypt.compare(password, user.password);
} else {
// タイミング攻撃対策:
// あらかじめ生成しておいた bcrypt ハッシュと比較する
const fakeHash = '$2b$10$eImiTXuWVxfM37uY4JANjQ.abcdefghijklmnopqrstuvwxyz';
await bcrypt.compare(password, fakeHash);
}
アカウントが存在しない場合でも、あらかじめ用意したダミーの bcrypt ハッシュと比較処理を行います。
これにより、アカウントの有無によって処理時間に大きな差が出にくくなり、タイミング攻撃による存在確認をされにくくできます。
なお、レスポンスメッセージ自体も「メールアドレスが存在しない」「パスワードが違う」のように分けず、認証失敗時は同じ内容にそろえると、アカウント存在確認への耐性をさらに高められます。
ファイルアップロードのバイナリ検証
このアプリでは、ユーザーがアバターをアップロードすることができます。
ファイルの拡張子はリネームで簡単に偽装できます。MIMEタイプはリクエストヘッダーの Content-Type から取得しますが、これも攻撃者が自由に書き換えられます。そのため、拡張子・MIMEタイプのチェックだけでは悪意のあるファイルを確実に検出できません。
バイナリ検証
本プロジェクトでは、fileTypeFromBuffer を使用し、ファイルに対してバイナリ検証を実施します。
import { fileTypeFromBuffer } from 'file-type';
export async function verifyAvatarBinary(req, res, next) {
const result = await fileTypeFromBuffer(req.file.buffer);
if (!result || !ALLOWED_MIME.AVATAR_FILE.has(result.mime)) {
return res.status(400).json({
error: { code: ERROR_CODES.UNSUPPORTED_FILE_TYPE }
});
}
next();
}
fileTypeFromBuffer は、ファイル先頭付近のバイト列をもとに実際のファイル形式を推定します。 拡張子や Content-Type ヘッダーより信頼性が高く、単純な偽装を見抜くのに有効です。
ただし、これだけであらゆる不正ファイルを完全に防げるわけではありません。 画像アップロード機能では、許可する MIME タイプの制限・ファイルサイズ制限・保存先の分離 などと組み合わせて運用することが重要です。
Content Security Policy(CSP)
Content-Security-Policy(CSP)は、ブラウザが読み込みを許可するリソースの取得元(オリジン)を制限するセキュリティ機能です。
主な目的は XSS(Cross-Site Scripting)対策 です。仮に悪意のあるスクリプトがページ内に入り込んだ場合でも、読み込み元や実行可能なリソースを制限することで、被害の拡大を抑える効果が期待できます。
前述のとおり、フロントエンドはVercelにデプロイしているため、CSP設定はフロントエンド側の vercel.json で行います。
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self';
script-src 'self';
style-src 'self' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: blob: https://実際のR2公開ドメイン;
connect-src 'self' https://バックエンドAPIのドメイン;
frame-ancestors 'none';
"
}
]
}
]
}
以下各設定値について説明していきます。
default-src
default-src 'self'
個別のディレクティブが指定されていないリソースに対する、デフォルトの取得元です
'self' は現在のオリジン(自サイト)を意味します。
例えば https://example.com で公開している場合、同じドメインからのリソースのみ許可します
script-src
script-src 'self'
JavaScript の読み込み元を制限します。
この設定では自サイトで配信している JavaScript のみ実行可能です。外部スクリプトを利用する場合は、そのドメインをここに追加する必要があります。
style-src
style-src 'self' https://fonts.googleapis.com
CSS の読み込み元を制限します。
この例では以下を許可しています。
- 自サイトの CSS
- Google Fonts のスタイルシート
font-src
font-src 'self' https://fonts.gstatic.com
フォントファイルの取得元を制限します。
Google Fonts を利用する場合、実際のフォントファイルは fonts.gstatic.com から配信されるため、このドメインを許可する必要があります。
img-src
img-src 'self' data: blob: https://実際のR2公開ドメイン
画像の取得元を制限します。
このプロジェクトでは、ユーザーがアップロードした画像を Cloudflare R2 に保存しているので、許可されている取得元は下記になります。
- 自サイトの画像
- R2 バケット配信用ドメイン
data:、URL.createObjectURL()で生成したBlobオブジェクト(ファイルプレビューなど)を使用する場合はblob: を追加する必要があります。
connect-src
connect-src 'self' https://バックエンドAPIのドメイン
Ajax、Fetch API、WebSocket などの通信先を制限します。
例えば以下の通信が対象になります。
fetch('/api/user/profile');
この設定では自サイトとバックエンドへの api リクエストのみ許可されます。
frame-ancestors
frame-ancestors 'none'
本プロジェクトのページが他サイトの iframe に埋め込まれることを禁止します。クリックジャッキング対策です。
クリックジャッキング(Clickjacking)とは、偽装した透明なWebページやボタンを正規のWebサイトに重ね合わせ、ユーザーを視覚的に騙して意図しないクリックや操作を行わせるサイバー攻撃です。
まとめ
この CSP は、「原則として自サイト以外からのリソース読み込みを禁止し、必要な取得元だけを個別に許可する」という考え方で構成しています。
外部スクリプトや画像配信サービスを利用する場合は、必要最小限のドメインだけを許可することが重要です。
Express + Helmet で HTTPセキュリティヘッダーを設定する
Helmetとは
helmet は、HTTPレスポンスヘッダを適切に設定することで、Webアプリケーションのセキュリティを強化するExpress用 middleware です。
XSS、クリックジャッキング、MIMEスニッフィングなど、よくある攻撃手法への対策となるヘッダーを、わずか数行のコードでまとめて設定できます。
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: false,
crossOriginResourcePolicy: {
policy: 'cross-origin'
},
crossOriginEmbedderPolicy: false,
}));
カスタマイズした設定
helmetは app.use(helmet()) と書くだけで、12種類以上セキュリティヘッダーをまとめて適切な値に設定してくれます。ただし、すべてのデフォルト値がそのまま使えるとは限らないため、アプリの要件に応じて一部のヘッダーを上書きする必要があります。
contentSecurityPolicy: false
このバックエンドは API サーバーとして JSON を返す用途が中心であり、HTML を描画するフロントエンドのように CSP を主戦場として扱う構成ではありません。
そのため、CSP はバックエンドではなく、主にフロントエンド側(Vercel)で管理します。
crossOriginResourcePolicy: { policy: 'cross-origin' }
アーキテクチャの節で説明したとおり、フロントエンドとバックエンドは別オリジンで動作します。
そのため、必要なクロスオリジンアクセスを妨げないように cross-origin を指定しています。
crossOriginEmbedderPolicy: false
クロスオリジンのリソース利用に不要な制約をかけないよう、この構成では crossOriginEmbedderPolicy は無効化しています。
デフォルト値のままで効果的な設定
一部のヘッダーについては、helmetのデフォルト値のままでも効果的で、明示に再設定する必要がありません。代表的なものを挙げます。
-
Strict-Transport-Security— ブラウザに対し、必ずHTTPS接続を使うよう指示します。 -
Referrer-Policy: no-referrer— リンク先にリンク元のURL情報を送信しないようにします。
効果が薄い設定
一部のヘッダーは、このプロジェクトではほぼ効果がありません。代表的ものを挙げます。
-
X-Frame-Options: SAMEORIGIN— クリックジャッキング対策。ただし、このバックエンドは主に JSON を返すため、HTML を配信するフロントエンド側に比べると重要度は低めです。 -
X-Content-Type-Options: nosniff— ブラウザのMIMEタイプスニッフィングを防ぐヘッダーです。JSON 中心の API では恩恵を実感しにくいものの、付与しておいて損は少ないヘッダーです。 -
X-DNS-Prefetch-Control: off— DNS プリフェッチを制御するヘッダーです。HTML を配信しない API サーバーでは、相対的に重要度は低いと考えています。
最後に
前回のレート制限・入力サイズ制限と合わせて、本プロジェクトで実施しているセキュリティ対策の全体像を一通り整理できたと思います。
完璧なセキュリティ対策は存在しませんが、こうした積み重ねが攻撃のハードルを上げることにつながります。
最後までお読みいただき、ありがとうございました。