Node.jsアプリケーションは、その柔軟性と開発効率の高さから多くのプロジェクトで採用されていますが、セキュリティ対策を怠ると重大な脆弱性を抱えるリスクがあります。OWASP Top 10に代表される一般的なWeb脆弱性は、Node.jsアプリケーションにも同様に存在します。

本記事では、依存関係の脆弱性監査から入力値の検証、パス・トラバーサル攻撃の防止、セキュリティヘッダーの設定まで、Node.jsアプリケーションを安全に開発・運用するための必須知識を体系的に解説します。

実行環境

項目 バージョン
Node.js 20.x LTS以上
npm 10.x以上
OS Windows/macOS/Linux

前提条件

  • JavaScriptの基礎文法を理解していること
  • Node.jsの基本的なAPIを理解していること
  • HTTPリクエスト・レスポンスの基本概念を理解していること

バージョンは以下のコマンドで確認できます。

1
2
3
4
5
node -v
# v20.18.0

npm -v
# 10.8.2

依存関係の脆弱性管理

Node.jsプロジェクトでは、多くのサードパーティパッケージを利用します。これらの依存関係に含まれる脆弱性は、アプリケーション全体のセキュリティに影響を与えます。

npm auditによる脆弱性検出

npm auditコマンドは、プロジェクトの依存関係に含まれる既知の脆弱性を検出します。

1
npm audit

脆弱性が検出された場合、以下のような出力が表示されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# npm audit report

lodash  <4.17.21
Severity: high
Prototype Pollution - https://github.com/advisories/GHSA-35jh-r3h4-6jhm
fix available via `npm audit fix`
node_modules/lodash

1 high severity vulnerability

To address all issues, run:
  npm audit fix

脆弱性の重大度レベル

npm auditは脆弱性を5段階の重大度で分類します。

レベル 説明 対応目安
critical システム全体に影響を与える重大な脆弱性 即座に対処
high 重要なセキュリティリスク 24時間以内に対処
moderate 中程度のリスク 1週間以内に対処
low 軽微なリスク 次回リリースまでに対処
info 情報提供のみ 確認のみ

脆弱性の自動修正

1
2
3
4
5
6
7
8
# セマンティックバージョニングの範囲内で自動修正
npm audit fix

# 実際には実行せず、何が行われるかを確認
npm audit fix --dry-run

# メジャーバージョン更新を含む修正(破壊的変更の可能性あり)
npm audit fix --force

--forceオプションは破壊的変更を含む可能性があるため、適用後はテストを必ず実行してください。

CI/CDパイプラインでの脆弱性チェック

本番環境へのデプロイ前に脆弱性をチェックするため、CI/CDパイプラインにnpm auditを組み込みます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# .github/workflows/security-audit.yml
name: Security Audit

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    # 毎日午前9時(JST)に実行
    - cron: '0 0 * * *'

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Run security audit
        run: npm audit --audit-level=high

--audit-level=highを指定すると、high以上の脆弱性が検出された場合にのみエラーとなります。

間接依存の脆弱性をoverridesで解決する

直接依存していないパッケージに脆弱性がある場合、overridesを使用してバージョンを強制的に指定できます。

1
2
3
4
5
6
7
8
9
{
  "name": "my-secure-app",
  "dependencies": {
    "some-package": "^1.0.0"
  },
  "overrides": {
    "vulnerable-package": "^2.0.1"
  }
}

特定のパッケージ配下でのみ上書きする場合は、以下のように記述します。

1
2
3
4
5
6
7
{
  "overrides": {
    "some-package": {
      "vulnerable-package": "^2.0.1"
    }
  }
}

環境変数の安全な管理

データベース接続情報やAPIキーなどの機密情報は、ソースコードにハードコーディングせず、環境変数で管理します。

環境変数の基本的な取得

1
2
3
4
5
6
// process.envから環境変数を取得
const dbHost = process.env.DB_HOST;
const dbPort = process.env.DB_PORT;
const apiKey = process.env.API_KEY;

console.log(`Database: ${dbHost}:${dbPort}`);

環境変数の必須チェック

アプリケーション起動時に必須の環境変数が設定されているかを検証します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// src/config/env-validator.mjs
const requiredEnvVars = [
  'NODE_ENV',
  'DB_HOST',
  'DB_PORT',
  'DB_USER',
  'DB_PASSWORD',
  'JWT_SECRET'
];

export function validateEnv() {
  const missing = requiredEnvVars.filter(
    (envVar) => !process.env[envVar]
  );

  if (missing.length > 0) {
    throw new Error(
      `Missing required environment variables: ${missing.join(', ')}`
    );
  }
}

アプリケーションのエントリーポイントで呼び出します。

1
2
3
4
5
6
7
// src/index.mjs
import { validateEnv } from './config/env-validator.mjs';

// 起動時に環境変数を検証
validateEnv();

console.log('Environment variables validated successfully');

dotenvによる開発環境での管理

開発環境ではdotenvパッケージを使用して.envファイルから環境変数を読み込みます。

1
npm install dotenv

プロジェクトルートに.envファイルを作成します。

1
2
3
4
5
6
7
# .env(開発環境用)
NODE_ENV=development
DB_HOST=localhost
DB_PORT=5432
DB_USER=devuser
DB_PASSWORD=devpassword
JWT_SECRET=your-development-secret-key

アプリケーションで読み込みます。

1
2
3
4
5
6
7
// src/index.mjs
import 'dotenv/config';
import { validateEnv } from './config/env-validator.mjs';

validateEnv();

// 以降の処理...

.envファイルのセキュリティ

.envファイルは絶対にGitリポジトリにコミットしないでください。.gitignoreに追加します。

1
2
3
4
# .gitignore
.env
.env.local
.env.*.local

代わりに、.env.exampleファイルを作成してテンプレートを共有します。

1
2
3
4
5
6
7
# .env.example(リポジトリにコミットするテンプレート)
NODE_ENV=development
DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
JWT_SECRET=

環境変数の型変換と検証

環境変数は常に文字列として取得されるため、必要に応じて型変換と検証を行います。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// src/config/config.mjs
import 'dotenv/config';

function getEnvNumber(key, defaultValue) {
  const value = process.env[key];
  if (value === undefined) {
    return defaultValue;
  }
  const parsed = parseInt(value, 10);
  if (isNaN(parsed)) {
    throw new Error(`Environment variable ${key} must be a number`);
  }
  return parsed;
}

function getEnvBoolean(key, defaultValue) {
  const value = process.env[key];
  if (value === undefined) {
    return defaultValue;
  }
  return value.toLowerCase() === 'true';
}

export const config = {
  nodeEnv: process.env.NODE_ENV || 'development',
  port: getEnvNumber('PORT', 3000),
  db: {
    host: process.env.DB_HOST,
    port: getEnvNumber('DB_PORT', 5432),
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    ssl: getEnvBoolean('DB_SSL', false)
  },
  jwt: {
    secret: process.env.JWT_SECRET,
    expiresIn: process.env.JWT_EXPIRES_IN || '1h'
  }
};

入力値の検証とサニタイズ

ユーザーからの入力値は信頼できないため、必ず検証(バリデーション)とサニタイズを行います。入力値の検証は、SQLインジェクション、XSS、コマンドインジェクションなど多くの攻撃を防ぐ第一歩です。

validatorライブラリによる検証

validatorパッケージは、文字列の検証とサニタイズに特化したライブラリです。

1
npm install validator

基本的な使用例を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import validator from 'validator';

// メールアドレスの検証
const email = 'user@example.com';
if (!validator.isEmail(email)) {
  throw new Error('Invalid email address');
}

// URLの検証
const url = 'https://example.com';
if (!validator.isURL(url, { protocols: ['https'], require_protocol: true })) {
  throw new Error('Invalid URL');
}

// 数値範囲の検証
const age = '25';
if (!validator.isInt(age, { min: 0, max: 150 })) {
  throw new Error('Invalid age');
}

// 英数字のみの検証
const username = 'john_doe123';
if (!validator.isAlphanumeric(username, 'en-US', { ignore: '_' })) {
  throw new Error('Username must contain only alphanumeric characters and underscores');
}

サニタイズ処理

サニタイズは、危険な文字を無害化または除去する処理です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import validator from 'validator';

// HTMLタグのエスケープ
const userInput = '<script>alert("XSS")</script>';
const escaped = validator.escape(userInput);
console.log(escaped);
// &lt;script&gt;alert(&quot;XSS&quot;)&lt;&#x2F;script&gt;

// 前後の空白を除去
const trimmed = validator.trim('  hello world  ');
console.log(trimmed); // 'hello world'

// 小文字に正規化
const normalized = validator.normalizeEmail('User@Example.COM');
console.log(normalized); // 'user@example.com'

// 特定の文字をエスケープ
const sanitized = validator.blacklist(userInput, '<>');
console.log(sanitized); // 'scriptalert("XSS")/script'

リクエストボディの検証パターン

Express.jsなどのフレームワークでリクエストボディを検証する実装例を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import validator from 'validator';

// ユーザー登録リクエストの検証
function validateUserRegistration(body) {
  const errors = [];

  // 必須フィールドのチェック
  if (!body.email || !body.password || !body.username) {
    errors.push('email, password, and username are required');
    return { isValid: false, errors };
  }

  // メールアドレスの検証
  if (!validator.isEmail(body.email)) {
    errors.push('Invalid email format');
  }

  // パスワードの強度チェック
  if (!validator.isStrongPassword(body.password, {
    minLength: 8,
    minLowercase: 1,
    minUppercase: 1,
    minNumbers: 1,
    minSymbols: 1
  })) {
    errors.push('Password must be at least 8 characters with uppercase, lowercase, number, and symbol');
  }

  // ユーザー名の検証(3-20文字、英数字とアンダースコアのみ)
  if (!validator.isLength(body.username, { min: 3, max: 20 })) {
    errors.push('Username must be between 3 and 20 characters');
  }

  if (!validator.matches(body.username, /^[a-zA-Z0-9_]+$/)) {
    errors.push('Username can only contain letters, numbers, and underscores');
  }

  return {
    isValid: errors.length === 0,
    errors,
    sanitized: {
      email: validator.normalizeEmail(body.email),
      username: validator.trim(body.username),
      password: body.password // パスワードはサニタイズしない
    }
  };
}

// 使用例
const requestBody = {
  email: 'User@Example.COM',
  password: 'SecurePass123!',
  username: '  john_doe  '
};

const result = validateUserRegistration(requestBody);
if (!result.isValid) {
  console.error('Validation errors:', result.errors);
} else {
  console.log('Validated data:', result.sanitized);
}

型の強制と予期しない入力への対処

JavaScriptの動的型付けにより、クエリパラメータやリクエストボディには予期しない型の値が含まれる可能性があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// ExpressでのクエリパラメータのURLパース結果の例
// ?foo=bar       -> 'bar' (string)
// ?foo=bar&foo=baz -> ['bar', 'baz'] (array)
// ?foo[]=bar     -> ['bar'] (array)
// ?foo[bar]=baz  -> { bar: 'baz' } (object)

function ensureString(value) {
  if (typeof value === 'string') {
    return value;
  }
  if (Array.isArray(value)) {
    return String(value[0] ?? '');
  }
  return '';
}

function ensureNumber(value, defaultValue = 0) {
  const str = ensureString(value);
  const num = parseInt(str, 10);
  return isNaN(num) ? defaultValue : num;
}

// 使用例
function handleRequest(query) {
  const page = ensureNumber(query.page, 1);
  const limit = ensureNumber(query.limit, 10);
  const search = ensureString(query.search);

  // 範囲の制限
  const safePage = Math.max(1, page);
  const safeLimit = Math.min(Math.max(1, limit), 100);

  return { page: safePage, limit: safeLimit, search };
}

パス・トラバーサル攻撃の防止

パス・トラバーサル(ディレクトリトラバーサル)攻撃は、../などのパス文字列を使用して、意図しないディレクトリやファイルにアクセスする攻撃手法です。

脆弱なコードの例

以下のコードは、パス・トラバーサル攻撃に対して脆弱です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import fs from 'node:fs/promises';
import path from 'node:path';

// 危険なコード - 絶対に使用しないでください
async function readUserFile(filename) {
  const filePath = `./uploads/${filename}`;
  return await fs.readFile(filePath, 'utf-8');
}

// 攻撃例: filename = '../../../etc/passwd'
// 実際のパス: ./uploads/../../../etc/passwd -> /etc/passwd

安全な実装パターン

パス・トラバーサルを防ぐには、パスの正規化と基準ディレクトリ内に収まっているかの検証が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import fs from 'node:fs/promises';
import path from 'node:path';

const UPLOAD_DIR = path.resolve('./uploads');

async function readUserFileSafe(filename) {
  // ファイル名から危険な文字を除去
  const sanitizedFilename = path.basename(filename);

  // 完全なパスを構築
  const requestedPath = path.join(UPLOAD_DIR, sanitizedFilename);

  // パスを正規化して実際のパスを取得
  const normalizedPath = path.resolve(requestedPath);

  // 基準ディレクトリ内に収まっているか検証
  if (!normalizedPath.startsWith(UPLOAD_DIR + path.sep)) {
    throw new Error('Access denied: Path traversal detected');
  }

  // ファイルの存在確認
  try {
    await fs.access(normalizedPath);
  } catch {
    throw new Error('File not found');
  }

  return await fs.readFile(normalizedPath, 'utf-8');
}

より堅牢な実装

ネストされたディレクトリ構造を扱う場合の実装例を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import fs from 'node:fs/promises';
import path from 'node:path';

class SecureFileAccess {
  constructor(baseDir) {
    this.baseDir = path.resolve(baseDir);
  }

  resolvePath(relativePath) {
    // nullバイト攻撃の防止
    if (relativePath.includes('\0')) {
      throw new Error('Invalid path: null byte detected');
    }

    // パスを正規化
    const normalizedPath = path.resolve(this.baseDir, relativePath);

    // 基準ディレクトリ内に収まっているか検証
    if (!normalizedPath.startsWith(this.baseDir + path.sep) && 
        normalizedPath !== this.baseDir) {
      throw new Error('Access denied: Path traversal detected');
    }

    return normalizedPath;
  }

  async readFile(relativePath) {
    const safePath = this.resolvePath(relativePath);
    return await fs.readFile(safePath, 'utf-8');
  }

  async writeFile(relativePath, content) {
    const safePath = this.resolvePath(relativePath);

    // 親ディレクトリが存在することを確認
    const dir = path.dirname(safePath);
    await fs.mkdir(dir, { recursive: true });

    await fs.writeFile(safePath, content, 'utf-8');
  }

  async listDirectory(relativePath = '.') {
    const safePath = this.resolvePath(relativePath);

    const stats = await fs.stat(safePath);
    if (!stats.isDirectory()) {
      throw new Error('Not a directory');
    }

    return await fs.readdir(safePath);
  }
}

// 使用例
const secureAccess = new SecureFileAccess('./uploads');

try {
  // 安全なファイル読み込み
  const content = await secureAccess.readFile('user123/document.txt');
  console.log(content);
} catch (error) {
  console.error('File access error:', error.message);
}

try {
  // 攻撃の試みはブロックされる
  await secureAccess.readFile('../../../etc/passwd');
} catch (error) {
  console.error('Blocked:', error.message);
  // Blocked: Access denied: Path traversal detected
}

Node.js Permission Modelの活用

Node.js 20以降では、--permissionフラグを使用してファイルシステムへのアクセスを制限できます。

1
2
3
4
5
# 特定のディレクトリのみ読み取りを許可
node --permission --allow-fs-read=/app/uploads/ index.mjs

# 読み取りと書き込みを特定のディレクトリに制限
node --permission --allow-fs-read=/app/uploads/ --allow-fs-write=/app/uploads/ index.mjs

許可されていないパスへのアクセスは実行時エラーとなります。

1
2
3
4
5
6
7
8
// /app/uploads/ 以外へのアクセスは拒否される
import fs from 'node:fs/promises';

try {
  await fs.readFile('/etc/passwd');
} catch (error) {
  console.error(error.code); // 'ERR_ACCESS_DENIED'
}

セキュリティヘッダーの設定

HTTPセキュリティヘッダーは、ブラウザに対してセキュリティポリシーを指示し、XSS、クリックジャッキング、MIMEスニッフィングなどの攻撃を軽減します。

helmetによるセキュリティヘッダーの設定

helmetは、Expressアプリケーションに複数のセキュリティヘッダーを一括で設定するミドルウェアです。

1
npm install helmet express

基本的な使用方法を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import express from 'express';
import helmet from 'helmet';

const app = express();

// helmetのデフォルト設定を適用
app.use(helmet());

app.get('/', (req, res) => {
  res.json({ message: 'Secure response' });
});

app.listen(3000);

helmetが設定する主要なヘッダー

ヘッダー 説明
Content-Security-Policy XSS攻撃を軽減するリソース読み込み制限
Strict-Transport-Security HTTPS接続の強制
X-Content-Type-Options MIMEタイプスニッフィングの防止
X-Frame-Options クリックジャッキング攻撃の防止
X-DNS-Prefetch-Control DNSプリフェッチの制御
Referrer-Policy リファラー情報の送信制御

Content Security Policy(CSP)の設定

CSPは、XSS攻撃を軽減する強力なセキュリティ機構です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import express from 'express';
import helmet from 'helmet';

const app = express();

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", 'https://trusted-cdn.com'],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", 'data:', 'https:'],
        fontSrc: ["'self'", 'https://fonts.gstatic.com'],
        connectSrc: ["'self'", 'https://api.example.com'],
        frameAncestors: ["'none'"],
        objectSrc: ["'none'"],
        upgradeInsecureRequests: []
      }
    }
  })
);

app.listen(3000);

各ヘッダーの個別設定

helmetの各機能は個別に設定できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import express from 'express';
import helmet from 'helmet';

const app = express();

// HSTS(HTTP Strict Transport Security)
app.use(
  helmet.hsts({
    maxAge: 31536000, // 1年
    includeSubDomains: true,
    preload: true
  })
);

// X-Frame-Options
app.use(helmet.frameguard({ action: 'deny' }));

// X-Content-Type-Options
app.use(helmet.noSniff());

// Referrer-Policy
app.use(
  helmet.referrerPolicy({
    policy: 'strict-origin-when-cross-origin'
  })
);

// X-Powered-By ヘッダーの削除(Express情報の隠蔽)
app.disable('x-powered-by');
// または helmet.hidePoweredBy() を使用

app.listen(3000);

フレームワークを使用しない場合のヘッダー設定

Express以外のフレームワークやNode.js組み込みのHTTPサーバーを使用する場合のヘッダー設定例を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import http from 'node:http';

function setSecurityHeaders(res) {
  // Content-Security-Policy
  res.setHeader(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
  );

  // Strict-Transport-Security
  res.setHeader(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains'
  );

  // X-Content-Type-Options
  res.setHeader('X-Content-Type-Options', 'nosniff');

  // X-Frame-Options
  res.setHeader('X-Frame-Options', 'DENY');

  // Referrer-Policy
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');

  // Permissions-Policy(旧Feature-Policy)
  res.setHeader(
    'Permissions-Policy',
    'geolocation=(), microphone=(), camera=()'
  );
}

const server = http.createServer((req, res) => {
  setSecurityHeaders(res);

  res.statusCode = 200;
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify({ message: 'Secure response' }));
});

server.listen(3000, () => {
  console.log('Server running on port 3000');
});

セキュリティ対策のベストプラクティス

ここまで解説した個別の対策に加えて、総合的なセキュリティ対策のベストプラクティスを紹介します。

本番環境でのセキュリティチェックリスト

本番環境にデプロイする前に確認すべき項目をまとめます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// src/utils/security-checklist.mjs
export function runSecurityChecklist() {
  const issues = [];

  // NODE_ENV の確認
  if (process.env.NODE_ENV !== 'production') {
    issues.push('NODE_ENV is not set to production');
  }

  // デバッグモードの確認
  if (process.env.DEBUG) {
    issues.push('DEBUG mode is enabled');
  }

  // 機密情報の環境変数確認
  const sensitiveVars = ['JWT_SECRET', 'DB_PASSWORD', 'API_KEY'];
  for (const varName of sensitiveVars) {
    const value = process.env[varName];
    if (!value) {
      issues.push(`${varName} is not set`);
    } else if (value.length < 32) {
      issues.push(`${varName} may be too weak (less than 32 characters)`);
    }
  }

  return {
    passed: issues.length === 0,
    issues
  };
}

エラーハンドリングでの情報漏洩防止

本番環境ではスタックトレースなどの詳細情報を公開しないようにします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// エラーハンドリングミドルウェア
function errorHandler(err, req, res, next) {
  // エラーログの記録(内部用)
  console.error({
    message: err.message,
    stack: err.stack,
    timestamp: new Date().toISOString(),
    path: req.path,
    method: req.method
  });

  // クライアントへのレスポンス
  const isDevelopment = process.env.NODE_ENV === 'development';

  res.status(err.status || 500).json({
    error: {
      message: isDevelopment ? err.message : 'Internal Server Error',
      // 開発環境のみスタックトレースを含める
      ...(isDevelopment && { stack: err.stack })
    }
  });
}

レート制限の実装

ブルートフォース攻撃やDoS攻撃を軽減するため、レート制限を実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import express from 'express';

// 簡易的なレート制限の実装
function createRateLimiter(windowMs, maxRequests) {
  const requests = new Map();

  return (req, res, next) => {
    const key = req.ip;
    const now = Date.now();
    const windowStart = now - windowMs;

    // 古いエントリを削除
    const requestTimes = (requests.get(key) || []).filter(
      (time) => time > windowStart
    );

    if (requestTimes.length >= maxRequests) {
      res.status(429).json({
        error: 'Too many requests, please try again later'
      });
      return;
    }

    requestTimes.push(now);
    requests.set(key, requestTimes);

    next();
  };
}

const app = express();

// 15分間に100リクエストまで
app.use(createRateLimiter(15 * 60 * 1000, 100));

// ログインエンドポイントはより厳しく制限
app.post(
  '/login',
  createRateLimiter(15 * 60 * 1000, 5),
  (req, res) => {
    // ログイン処理
  }
);

セキュリティ対策の全体像

以下の図は、Node.jsアプリケーションのセキュリティ対策の全体像を示しています。

flowchart TB
    subgraph Input["入力層"]
        A[リクエスト受信]
        B[レート制限]
        C[入力値検証]
        D[サニタイズ]
    end

    subgraph Process["処理層"]
        E[認証・認可]
        F[ビジネスロジック]
        G[ファイルアクセス制御]
        H[データベースアクセス]
    end

    subgraph Output["出力層"]
        I[レスポンス生成]
        J[セキュリティヘッダー]
        K[エラーハンドリング]
    end

    subgraph Infra["基盤"]
        L[環境変数管理]
        M[依存関係監査]
        N[ログ監視]
    end

    A --> B --> C --> D --> E
    E --> F --> G --> H
    H --> I --> J --> K

    L -.-> Process
    M -.-> Process
    N -.-> Process

まとめ

本記事では、Node.jsアプリケーションのセキュリティ対策として以下の項目を解説しました。

  • 依存関係の脆弱性管理: npm auditによる脆弱性検出と自動修正、CI/CDパイプラインへの組み込み
  • 環境変数の安全な管理: 機密情報のハードコーディング防止、dotenvによる開発環境管理、必須チェックと型検証
  • 入力値の検証とサニタイズ: validatorライブラリの活用、型の強制、予期しない入力への対処
  • パス・トラバーサル攻撃の防止: パスの正規化と基準ディレクトリの検証、Node.js Permission Modelの活用
  • セキュリティヘッダーの設定: helmetによる一括設定、CSPの構成

セキュリティ対策は一度実装すれば終わりではなく、継続的な監視と更新が必要です。定期的な脆弱性スキャン、依存関係の更新、セキュリティベストプラクティスの見直しを行い、安全なアプリケーションを維持してください。

参考リンク