前回の記事ではアプリを高速化しました。今回は、誰かがアプリを破壊しようとしたときでも、アプリが安定して稼働し続けることを保証する方法についてです。セキュリティ作業の不都合な真実は、それが正しく行われたときには誰も気づかず、見逃した1つのギャップだけが誰の目にも留まるということです。

これは、Full Stack SaaS Masterclassの一部で、マルチテナントSaaSをエンドツーエンドで構築するシリーズです。セキュリティは、認証、データベース、API層、およびそれらすべてのインフラストラクチャに触れるマインドセットであり、単一の記事で終わる作業ではありません。以下は、本番環境対応と呼ぶ前に実際に実行するチェックリストで、各項目の背後にある理由も含めて説明しますので、独自のアプリに適用するかどうかを判断できます。

ここで40個の項目をリストアップする衝動を抑えています。OWASP Top Tenのほとんどは、フレームワークがすでに処理しているか、リスクを生み出す複雑さを獲得するまでは無関係です。以下は、実際にSaaSチームが実務で直面するサブセットです。

認証とセッション処理

これを間違えると、他のことは何も重要ではなくなります。他のユーザーを偽装できる場合、レート制限や入力検証は、正面のドアが開いたままの家を飾っているに過ぎません。

NestJSバックエンドのためのいくつかの必須事項:

  • パスワードはbcryptまたはargon2でハッシュ化し、自分で作成したものは絶対に使用しない。
  • アクセストークンは短命(数日ではなく、数分)、リフレッシュトークンは使用時にローテーションされ、取り消し可能にするためサーバー側に保存する。
  • セッショントークン用のCookieは、クロスサイトリクエストが必要かどうかに応じて、httpOnlysecure、およびsameSite: 'lax'または'strict'を設定する。
// auth/cookie.config.ts
export const REFRESH_COOKIE_OPTIONS = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax' as const,
  path: '/auth/refresh',
  maxAge: 7 * 24 * 60 * 60 * 1000,
};

Enter fullscreen mode Exit fullscreen mode

人々が過小評価するトレードオフは、リフレッシュトークンのローテーションです。これはより多くの可動部分を必要とします:盗まれたリフレッシュトークンがローテーション後に再利用された場合、それを検出してセッションチェーン全体を終了できるように、トークンファミリーの概念が必要です。これは、インシデントが問題を強制的に解決するのではなく、早期に一度構築する価値があります。

最も頻繁に見られる落とし穴は、シングルページアプリにとって便利だからという理由でJWTをlocalStorageに保存することです。デモでは問題なく動作します。しかし、これはフロントエンドのどこかにXSS脆弱性があった場合、侵害されたnpmパッケージがあった場合、エスケープされていないユーザー文字列があった場合、すべてのアクティブなセッションが引き渡されることを意味します。httpOnly CookieはJavaScriptからアクセスできないため、クロスオリジンリクエストでの追加の配管作業が少なくて済み、その攻撃クラス全体を排除できます。

認可とテナント分離

認証は「あなたは誰か」に答えます。認可は「何に触れることを許可されているか」に答えますが、マルチテナントSaaSでは、2番目の質問に追加の次元があります:どのテナントに属しているか、そして実行するすべてのクエリがそれにスコープされているかどうかです。

実際の損害を引き起こす間違いは、通常、organizationIdでフィルタリングすることを忘れたクエリであり、欠落したロールチェックではありません。テナントAのユーザーが、テナントBに属する行を読み書きしてしまうことになります。それをキャッチするのは、壊れた権限チェックではありません。通常は、誰かのダッシュボードに関連性のないデータが表示された理由を尋ねるサポートチケットです。

このリスクを意味のある形で軽減する2つの方法:

  1. クライアントから来たテナントIDを決して信用しない。リクエストボディやクエリパラメータではなく、認証されたセッションから導出する。
  2. アプリケーション層だけでなく、データベースがサポートしている場所ではデータ層でそれを強制する。
-- Postgres row-level security as a backstop, not a replacement for app-layer checks
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING (organization_id = current_setting('app.current_org_id')::uuid);

Enter fullscreen mode Exit fullscreen mode

行レベルセキュリティは、アプリ層のチェックの代わりではなく、深層防御のレイヤーです。すべての接続で正しく設定する必要があるセッション変数を追加し、意図的でない場合、接続プールで忘れやすいものです。それでも持つ価値があると思います。なぜなら、NestJSサービス層のバグが自動的にクロステナントのデータ漏洩にならないことを意味するからです。アプリケーション層のチェックが主要な防御であるべきです;RLSは、そのチェックにバグがあった場合のシートベルトです。

入力検証とインジェクション防止

外部からシステムに入るすべての文字列は、証明されるまで信頼できません:リクエストボディ、クエリパラメータ、ヘッダー、Webhookペイロード、ファイルアップロード、サードパーティAPIから呼び出す値でさえもです。

NestJSは、実際に使用する場合、これらのほとんどを無料で提供します。DTOを使用したグローバル検証パイプは、サービスに到達する前に不正な形式や予期しないペイロードを停止します。

// main.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
);

Enter fullscreen mode Exit fullscreen mode

// dto/create-invoice.dto.ts
import { IsUUID, IsNumber, Min, IsISO8601 } from 'class-validator';

export class CreateInvoiceDto {
  @IsUUID()
  customerId: string;

  @IsNumber()
  @Min(0)
  amountCents: number;

  @IsISO8601()
  dueDate: string;
}

Enter fullscreen mode Exit fullscreen mode

whitelist: trueはDTOで宣言されていないプロパティをすべて削除します。forbidNonWhitelisted: trueは、余分なフィールドを静かに削除するのではなく、リクエストを完全に拒否します。この組み合わせは、クライアントが{ ...formData, role: 'admin' }を送信し、保護されていない更新ハンドラーがそれを喜んで書き込むような、mass-assignmentスタイルのバグから静かに救ってくれました。

SQLインジェクションは、パラメータ化されたクエリを使用するクエリビルダーまたはORM(Prisma、TypeORM)を使用する場合、ほとんど問題になりません。これはデフォルトで実行すべきことです。落とし穴は、ORMの制限を回避するために6ヶ月後に誰かが書く生のクエリであり、パラメータ化された構文を学ぶよりも速かったために値をSQLに文字列連結することです。コードレビューで文字列連結されたSQLを禁止し、例外なく、ニットピックではなくブロックコメントとして扱います。

シークレットと設定管理

ソース管理内のシークレットは、私が見た中で最も避けられる本番インシデントであり、.envファイルが一度コミットされ、git履歴に永遠に残るため、今でも頻繁に発生しています。

ベースライン:.envは最初のコミットから.gitignoreに入れ、実行時にデプロイメントプラットフォーム(AWS Secrets Manager、SSM Parameter Store、またはCI/CDプロバイダーのシークレットストア)を通じてシークレットを注入し、環境ごとに異なる認証情報を使用することで、侵害されたステージングキーが本番環境に触れられないようにします。

# docker-compose.yml (local dev only, not how production gets secrets)
services:
  api:
    env_file:
      - .env.local
    environment:
      - NODE_ENV=development

Enter fullscreen mode Exit fullscreen mode

本番環境では、プレーンテキストのシークレットファイルをコンテナイメージやリポジトリにenv_fileで入れません。管理されたストアからデプロイ時にシークレットをプルし、実行中のプロセスに環境変数として注入します。これにより、シークレットがイメージレイヤーやgitコミットに触れることはありません。

より微妙な落とし穴:ログ記録です。デバッグのためにリクエストオブジェクト全体をログに記録し、リクエストにAuthorizationヘッダーやパスワードフィールドが含まれていることを忘れがちです。既知の機密キーは、コールサイトごとではなく、ロガーの設定レベルで編集します。コールサイトごとの規律は最終的に緩むからです。

// logger/redact.ts
export const REDACTED_KEYS = ['password', 'authorization', 'refreshToken', 'apiKey'];

export function redact(obj: Record<string, unknown>) {
  const clone = { ...obj };
  for (const key of REDACTED_KEYS) {
    if (key in clone) clone[key] = '[REDACTED]';
  }
  return clone;
}

Enter fullscreen mode Exit fullscreen mode

トランスポートとインフラストラクチャの強化

どこでもHTTPSは、議論の余地のない最低限の要件であり、議論するチェックボックスではありません。より明白でないのは、「HTTPSがオン」から「実際に強化されている」の間に何があるかです。

手動で作成するのではなく、helmetのようなものでセキュリティヘッダーを設定し、認証に触れたり、呼び出し元が高価な作業をトリガーしたりするものには、レート制限をスキップしないでください。

// main.ts
import helmet from 'helmet';
import { ThrottlerGuard } from '@nestjs/throttler';

app.use(helmet());
app.useGlobalGuards(new ThrottlerGuard());

Enter fullscreen mode Exit fullscreen mode

レート制限は、特にログイン、パスワードリセット、およびメールやSMSを送信するエンドポイントで再検討する価値があります。それがなければ、サインアップフォームは誰かが他の人の受信箱をスパムするための無料ツールになり、ログインペンドポイントは摩擦のない資格情報スタッフィングの標的になります。Redisをバックエンドとしたレート制限は、複数のインスタンスにわたってこれをスケールし、複数のコンテナを実行するようになった瞬間に重要になります。

依存関係スキャンは、このリストの中で最も派手ではなく、チームが最もスキップする項目です。npm auditをCIで実行するか、SnykやGitHubのDependabotのようなサービスは、すでに依存しているパッケージの既知の脆弱性をキャッチします。トレードオフはノイズです:フラグが立てられた脆弱性のすべてがあなたのコンテキストで悪用可能とは限らず、すべてのアラートですべての依存関係を盲目的にバンプすると、破損のリスクが伴います。アラートをトリアージし、盲目的に自動マージせず、無視もしないでください。

ログ記録、監視、およびインシデント対応準備

セキュリティ作業は、コードが出荷されたときに終了するわけではありません。ほとんどのチームには、防止のギャップではなく、検出のギャップがあります:何かがうまくいかず、顧客が不満を言うまで誰も気づかないのです。

イベントを再構築するのに十分なコンテキスト(誰が、何のテナントで、何のアクションを、いつ)を持つ構造化ログは、5分の調査と2日の調査の違いを生みます。それと、実際に重要なシグナルに対するアラートを組み合わせます:1つのアカウントからの繰り返しの失敗ログイン、401/403レスポンスの急増、データエクスポートの異常な量。

これらのどれも、初日には精巧である必要はありません。ログアグリゲーター、いくつかのアラートルール、そして「これが発火したらどうするか」という文書化されたランブックで、小規模なSaaSチームが直面する現実的なシナリオのほとんどをカバーできます。このシリーズの最初の記事のスタック決定と同じ精神で、正当化するトラフィックを得る前に完全なSIEMセットアップを構築するのは、獲得していない複雑さです。

主要なポイント

  • 認証が基盤です:短命のアクセストークン、ローテーションされるリフレッシュトークン、そしてhttpOnly Cookieは、最も一般的なセッションハイジャック経路を閉じます。
  • テナント分離は、まずアプリ層に属し、行レベルセキュリティは、代替ではなく深層防御のバックストップです。
  • whitelistforbidNonWhitelistedを備えたグローバルDTO検証は、サービスに到達する前にmass-assignmentと不正なペイロードのバグを停止します。
  • シークレットは管理されたストアに入れ、実行時に注入します。git履歴やデプロイされたイメージ上のプレーンテキストファイルに存在することはありません。
  • セキュリティヘッダー、レート制限、および依存関係スキャンは、追加するのが安価で、スキップするのが高価です。インシデントの後ではなく、ローンチ前に追加します。
  • 検出は防止と同じくらい重要です:構造化ログと、いくつかの適切に選択されたアラートは、気づかれない侵害を5分の対応に変えます。

FAQ

新しいSaaS製品にとって最も重要なセキュリティコントロールは何ですか?
認証とテナント分離です。その順序で。短命のトークン、ローテーションされるリフレッシュトークン、そしてサーバー側のテナントスコープを正しく設定してから、他のものをレイヤーします。

アプリがすでにテナントIDをチェックしている場合、Postgresで行レベルセキュリティは必要ですか?
それは厳格な要件ではなく、強力な深層防御レイヤーです。アプリのチェックは主要な防御のままですが、RLSはサービスメソッドのバグが自動的にクロステナントのデータ漏洩にならないことを意味します。

JWTをlocalStorageに保存することは実際に危険ですか?
はい、主にXSS露出のためです。ページ上で実行されるスクリプト(自社コードまたは侵害された依存関係)は、localStorageを読み取ってトークンを流出させる可能性があります。httpOnly CookieはJavaScriptから到達できないため、その攻撃経路を完全に排除します。

リフレッシュトークンはどのくらいの頻度でローテーションする必要がありますか?
使用するたびにローテーションし、トークンファミリーを追跡して、ローテーション後に再利用された盗まれたリフレッシュトークンを検出可能で取り消し可能にします。使用時のローテーションは、選択する正確な有効期限ウィンドウよりも重要です。

WAFが必要ですか、それともレート制限と検証で対応できますか?
ほとんどの小〜中規模のSaaS製品では、堅固な入力検証、レート制限、およびセキュリティヘッダーで現実的な脅威表面をカバーできます。WAFはより高いトラフィックで役立ちますが、自社コードの検証ギャップを修正する代わりにはなりません。

初期段階のSaaSアプリにおける最も一般的なセキュリティミスは何ですか?
テナントIDでフィルタリングすることを忘れたクエリです。欠落したロールチェックから来ることはほとんどありません。テナントスコープを念頭に置かずに書かれた、見た目は正しいクエリから来ることが多く、セキュリティスキャンではなくサポートチケットを通じて表面化します。

参考文献


著者について

こんにちは、私はAman Singhです — スケーラブルなSaaS製品、分散システム、クラウドアーキテクチャ、およびAIを活用したアプリケーションを専門とするシニアフルスタックエンジニアです。

システム設計、フルスタックエンジニアリング、分散システム、Redis、PostgreSQL、AWS、Node.js、およびNestJSについて執筆しています。