SASキーを用いた認証でユーザー専用コンテナによるセキュリティ強化策を考えた

概要

今回は、Azure Blob Storage を使用したマルチテナントファイルアップロードシステムにおけるセキュリティ問題の分析と、ユーザー専用コンテナアーキテクチャの結果をまとめたものです。

元々考えていたアーキテクチャだとSASキーをフロントエンドで受け取るため、傍受されるとそのコンテナー全体でセキュリティの問題が発生するというものでした。今回の対応策は、ユーザごとに専用コンテナを用意します。これだとBlobトリガーで全てのコンテナーを監視する必要があり非効率だなと感じたため、これ以降の話に繋がります。

そもそも今回のアーキテクチャを検討した理由は、SASキーが仮想ディレクトリまで細かく権限のコントロールができないためです。Azure Data Lake Storage のアクセス制御モデルでアクセス制御リスト (ACL)を使えば解決できそうでしたが、今回はAzuriteで動作確認をやりたいというのもありました。

目次

  1. 背景と問題の定義
  2. 従来アーキテクチャの問題点
  3. 提案アーキテクチャの詳細
  4. アーキテクチャ比較とシーケンス図
  5. セキュリティ分析
  6. 技術的評価
  7. 代替アプローチの検討
  8. 推奨事項と実装計画
  9. 結論

背景と問題の定義

今回のアーキテクチャ

マルチテナント Web アプリケーションにおける技術スタック:

  • フロントエンド: SPA (React/Vue/Angular 等)
  • 認証: OAuth 2.0 / OpenID Connect
  • バックエンド: サーバーレス関数 (Azure Functions 等)
  • ストレージ: クラウドオブジェクトストレージ
  • イベント処理: ストレージトリガー型処理

問題

現在のアーキテクチャでは、フロントエンドに SAS トークンを返して Azure Blob Storage に直接アップロードしているが、悪意のあるユーザーが他ユーザーのファイルにアクセス可能なセキュリティリスクが存在する。

従来アーキテクチャの問題点

セキュリティ脆弱性

1. 共有コンテナによるデータ混在

documents/

├── user123/2025/file1.jpg

├── user456/2025/file2.jpg ← 他ユーザーのファイル

└── user789/2025/file3.jpg ← 他ユーザーのファイル

2. 過剰な SAS 権限

  • 現在: documentsコンテナ全体への読み書き権限
  • 問題: ユーザー A が他のユーザーのファイルにアクセス可能

3. フロントエンド露出リスク

ブラウザに返される SAS トークンが広範囲の権限を持つため、悪意のあるクライアントサイドコードによる不正アクセスが可能。

従来アーキテクチャのシーケンス図

新アーキテクチャの詳細

コンテナ構成

Zsh
Azure Blob Storage

├── triggers/ # トリガー専用コンテナ

 ├── user123.txt # トリガーファイル

 ├── user456.txt

 └── ...

├── user-abc123-documents/ # ユーザー専用コンテナ

 ├── 2025/

  ├── file-001.jpg

  └── file-002.jpg

 └── metadata/

├── user-def456-documents/ # 別ユーザー専用コンテナ

 ├──2025/
  └── file-003.jpg

 ├──  metadata/

設計思想

1. 完全なデータ分離

  • ユーザー専用コンテナ: 各ユーザーが独自のコンテナを持つ
  • 認証ベース命名: 認証済み userID ベースのコンテナ名(オプション:ハッシュ化)

2. 間接トリガー方式

  • 実データ: ユーザー専用コンテナに保存
  • トリガー: 共有triggersコンテナのuserid.txtで発火
  • 処理: トリガーファイルの内容を基にユーザー専用コンテナを処理

アーキテクチャ比較とシーケンス図

新アーキテクチャのシーケンス図

アーキテクチャ比較表

セキュリティ分析

セキュリティ強化ポイント

1. 物理的データ分離

Zsh
// コンテナ名生成(基本版)

export function getUserContainerName(userId: string): string {

return `user-${userId}-documents`;

}



// オプション: 追加セキュリティ版(より秘匿性を重視する場合)

export function getUserContainerNameHashed(userId: string): string {

const crypto = require('crypto');

const hash = crypto.createHash('sha256').update(userId).digest('hex');

return `user-${hash.substring(0, 16)}-documents`;

}

userID の特性:

  • OAuth/OpenID Connect 認証プロバイダーが生成する固有 ID
  • すでに推測困難な値(GUID 等)
  • 攻撃者が userID を知っても直接的な攻撃は困難

ハッシュ化の利点:

  • ストレージレベルでの完全な匿名化
  • ログやメトリクスでのプライバシー保護

ハッシュ化が不要な理由:

  • userID が既に推測困難
  • 認証ゲートウェイによる実質的な保護(他人の userID を知っても SAS トークン取得不可)
  • 追加の複雑性とデバッグ困難化
  • 実質的なセキュリティ向上は限定的

2. 最小権限の原則

  • ユーザー専用 SAS: 自分のコンテナのみアクセス可能
  • トリガー SAS: テキストファイルアップロードのみ

3. 監査性

  • トリガーファイル: 処理ログとして機能
  • アクセス分離: ユーザー間の完全な分離

脅威モデル評価

技術的評価

強み

1. セキュリティ強化

  • 完全な物理分離: コンテナレベルでの分離により最強のセキュリティ
  • 認証ベース分離: 認証済み userID による確実な分離(オプション:ハッシュ化で匿名化)

2. 機能継続性

  • 既存機能維持: Blob トリガーによる自動 AI 処理を継続
  • スケーラビリティ: ユーザー増加に対する柔軟な対応

課題と懸念

1. 複雑性の増加

  • 二段階プロセス: 実データ → トリガーの二段階アップロード
  • 状態管理: フロントエンドでの複雑な状態管理

2. 信頼性への影響

  • 部分失敗リスク: 実データアップロード成功後、ユーザーがブラウザを閉じてトリガー送信が中断されるケース(対策を後述)
  • 孤立ファイル: 処理されないファイルの可能性(対策を後述)

部分失敗の緩和策:

  • フロントエンドでの継続的な状態表示(「アップロード中です。ブラウザを閉じずにお待ちください」)
  • リトライ機能や未処理ファイルの検出・復旧機能

3. 運用負荷

  • コンテナ管理: 数千〜数万のコンテナ管理
  • 監視複雑化: 分散されたデータの監視
  • コスト増加: 追加のストレージトランザクション

パフォーマンス分析

レイテンシ比較

従来: API呼び出し(1) → アップロード(1) → Blobトリガー(1) = 3ステップ

新案: API呼び出し(1) → アップロード(1) → トリガー(1) → Blobトリガー(1) = 4ステップ

実際の違い: トリガーファイルを元に別コンテナを参照する内部処理の追加のみ

スループット

  • 同時アップロード: コンテナ分離により競合減少
  • 処理遅延: 間接トリガーによる若干の遅延

設計経緯と代替アプローチの検討

設計の背景

当初は、既存の共有コンテナアーキテクチャに対してアプリケーションレベルでの多層防御による対策を検討していました:

JavaScript
// 最初に検討したアプローチ

const allowedPrefix = `${userId}/${currentYear}`;

if (!blobName.startsWith(allowedPrefix)) {

throw new SecurityError('Unauthorized path');

}

しかし、この手法ではSAS 権限の根本的制限が残存することが判明:

  • フロントエンドに返される SAS トークンは依然としてコンテナ全体への権限を持つ
  • 悪意のあるクライアントサイドコードによる直接的な Blob アクセスを完全には防げない
  • アプリケーションロジックに依存したセキュリティ実装

これらの制約から、物理的なデータ分離を実現するユーザー専用コンテナアーキテクチャの検討に至りました。

設計経緯と代替アプローチの検討

設計の背景

当初は、既存の共有コンテナアーキテクチャを維持しつつ、アプリケーションレベルでの多層防御による対策を検討していました。具体的には、バックエンドのロジックでアップロード先のパスを検証するようなアプローチです。

JavaScript
// 最初に検討したアプローチ
const allowedPrefix = `${userId}/${currentYear}`;

if (!blobName.startsWith(allowedPrefix)) {
  throw new SecurityError('Unauthorized path');
}

しかし、この手法では SASトークンの権限が広すぎるという根本的な問題が残ることが判明しました。

  • フロントエンドに返されるSASトークンは、依然としてコンテナ全体への書き込み権限を持ってしまいます。
  • そのため、悪意のあるユーザーがクライアントサイドのコードを改変し、検証ロジックをバイパスしてBlob Storageへ直接リクエストを送ることで、他のユーザーの仮想ディレクトリ (user456/) にファイルをアップロードする攻撃を防ぐことができません。

アプリケーションロジックだけに依存したセキュリティには限界があると判断し、より堅牢なストレージ層での物理的なデータ分離アーキテクチャの検討に至りました。

代替案の検討: なぜBlobレベルのSASでは不十分だったか

次に、より権限を絞った BlobスコープのSAS(個別のファイル名を指定して発行するSAS)を利用する案を検討しました。しかし、このアプローチも今回の要件には合致しませんでした。その理由は、Azure Blob StorageのSASが持つ技術的な制約にあります。

技術的制約:ディレクトリ(プレフィックス)単位の権限付与ができない

今回の問題の核心は、user123/ というプレフィックス(仮想ディレクトリ)配下への書き込みだけを許可する」といった柔軟なSASトークンを生成できない点にあります。

AzureのSASで指定できるスコープは、基本的に以下の2つです。

  1. コンテナ全体: コンテナ内のすべてのBlobに対する操作権限を与えてしまうため、今回の問題を引き起こします。
  2. 特定のBlob(ファイル): file1.jpg のように、特定のファイル名に対してのみ有効なSASを発行します。

ユーザーがファイルをアップロードするシナリオでは、アップロードが実行されるまでファイル名が確定しません。そのため、事前にファイル名を指定してSASを発行する「特定のBlob」スコープのSASは利用できません。

この制約により、共有コンテナ内でSASトークンだけを用いてユーザー間の書き込み領域を安全に分離することは、極めて困難であると結論付けました。

これらの検討を経て、論理的な分離には限界があり、セキュリティを最優先するならば、各ユーザーに専用のコンテナを割り当てる物理的なデータ分離が最も確実で安全な解決策であるという結論に至りました。

実装例

デバッグ・検証の容易さ

新アーキテクチャでも、デバッグは比較的容易

JavaScript
// 1. 実データアップロードの確認
const userContainer = `user-${userId}-documents`;
const uploadedFiles = await listBlobsInContainer(userContainer);


// 2. トリガーファイルでの手動テスト
const triggerContent = JSON.stringify({
  userId,
  timestamp: new Date().toISOString(),
});
await uploadTextFile('triggers', `${userId}.txt`, triggerContent);


// 3. Blobトリガー動作の確認
// triggers/userId.txt の作成 → 自動的にBlobトリガー関数が起動

フロントエンド状態管理の実装例

JavaScript
const handleUpload = async (files: FileList) => {
  setUploadState('uploading-files');

  try {
    // Step 1: 実データアップロード
    await uploadFilesToUserContainer(files);

    setUploadState('triggering-processing');
    // Step 2: トリガー送信
    await sendTrigger(userId);

    setUploadState('processing');
    // Step 3: 処理完了まで待機
    await waitForProcessingComplete(userId);

    setUploadState('completed');
  } catch (error) {
    setUploadState('error');
    // リトライロジック
  }
};

UI 状態表示の実装例

JavaScript
const UploadStatus = ({ state }) => {
  const messages = {
    'uploading-files': 'ファイルをアップロード中です...',
    'triggering-processing': '処理を開始しています...',
    processing: '解析中です。ブラウザを閉じずにお待ちください...',
    completed: 'アップロード完了!',
  };

  return (
    <div className="upload-status">
      <Spinner />
      <p>{messages[state]}</p>
      {state === 'processing' && (
        <p className="warning">処理が完了するまでページを閉じないでください</p>
      )}
    </div>
  );
};

残存する課題:ブラウザを閉じられた場合のリスク

このUI実装は、ユーザーに現在の状況を伝え、処理中にページを離れないよう促す上で重要です。しかし、これだけでは根本的な問題は解決されません。

もしユーザーが警告を無視してブラウザを閉じてしまったり、PCがスリープしたり、ネットワークが切断されたりした場合、データ不整合が発生する可能性があります。

特に問題となるのは、「① 実データのアップロード」が完了した直後で、「② トリガーの送信」が行われる前にセッションが中断されたケースです。

この場合、ユーザーのコンテナにはファイルがアップロードされたものの、処理を開始するためのトリガーが存在しない**「孤立したファイル(Orphan File)」**が生まれてしまいます。

残存する課題:ブラウザを閉じられた場合のリスク

このUI実装は、ユーザーに現在の状況を伝え、処理中にページを離れないよう促す上で非常に重要です。しかし、これだけでは根本的な問題は解決されません。

もしユーザーが警告を無視してブラウザを閉じてしまったり、PCがスリープしたり、ネットワークが切断されたりした場合、深刻なデータ不整合が発生する可能性があります。

特に問題となるのは、「① 実データのアップロード」が完了した直後で、「② トリガーの送信」が行われる前にセッションが中断されたケースです。

この場合、ユーザーのコンテナにはファイルがアップロードされたものの、処理を開始するためのトリガーが存在しない**「孤立したファイル(Orphan File)」**が生まれてしまいます。このファイルはストレージコストを消費し続けるだけで、後続のAI処理などが実行されることはありません。

緩和策と今後の検討事項

このようなクライアント側の操作に依存するアーキテクチャの弱点を補うためには、サーバーサイドでの対策が不可欠です。

すぐに思いつくのは、定期的なクリーンアップ処理の実装です。

Azure Functionsのタイマートリガーなどを利用し、例えば1日に1回、すべてのユーザーコンテナをスキャンするバッチ処理を実装します。作成から一定時間(例:24時間)が経過しているにもかかわらず、処理済みフラグ(メタデータやDB上の記録)がないファイルを見つけ出し、再度トリガーを発行するか、あるいは不要なファイルとして削除するロジックです。これにより、孤立したファイルが放置されるのを防げます。