• #security
  • #draw.io
  • #middleware
  • #vue
  • #セキュリティレビュー
未分類

Draw.io動的埋め込み:セキュリティレビュー

概要

Draw.ioファイルの動的埋め込み実装におけるセキュリティレビュー結果です。以下のコンポーネントを対象としました:

  • apps/web/server/middleware/content-images.ts - サーバーミドルウェア
  • apps/web/app/pages/blog/drawio-viewer-test.vue - Vueコンポーネント

総合セキュリティスコア: 7/109/10 ⬆️

✅ 改善済み: 指摘された脆弱性について検証と対策を実施しました。詳細はセキュリティ改善実装レポートをご覧ください。

検出された脆弱性

🔴 HIGH: パストラバーサル脆弱性

影響度: 高 対象ファイル: server/middleware/content-images.ts

📝 実証テスト結果: このセキュリティレビューで指摘された脆弱性について、実証テストを実施しました。結果として、現在の実装では攻撃は成功しません。正規表現が有効に防御しています。詳細はテスト結果ドキュメントをご覧ください。

問題の詳細

現在の実装では、ファイル名に .. が含まれているかどうかのチェックがありません。これにより、攻撃者が意図しないディレクトリのファイルにアクセスできる可能性があります。

脆弱なコード:

const pathname = url.split('?')[0]
const match = pathname.match(/^\/(\d{4}-\d{2}-\d{2})\/([^/]+\.(png|jpg|jpeg|gif|webp|svg|drawio))$/i)
if (!match) return

const [, dateDir, filename, ext] = match
const contentPath = join(process.cwd(), 'content', dateDir, filename)

攻撃例:

GET /2025-11-18/../../../etc/passwd HTTP/1.1

正規表現は / を拒否していますが、.. 自体は許可されています。

推奨される修正

const pathname = url.split('?')[0]
const match = pathname.match(/^\/(\d{4}-\d{2}-\d{2})\/([^/]+\.(png|jpg|jpeg|gif|webp|svg|drawio))$/i)
if (!match) return

const [, dateDir, filename, ext] = match

// ⭐ パストラバーサル対策を追加
if (dateDir.includes('..') || filename.includes('..')) {
  console.warn(`[Security] Path traversal attempt blocked: ${url}`)
  return
}

const contentPath = join(process.cwd(), 'content', dateDir, filename)

// ⭐ 追加の安全策:resolveしたパスがcontentディレクトリ内にあることを確認
const resolvedPath = resolve(contentPath)
const contentDir = resolve(process.cwd(), 'content')
if (!resolvedPath.startsWith(contentDir)) {
  console.warn(`[Security] Path traversal blocked: ${resolvedPath}`)
  return
}

必要なインポート:

import { resolve } from 'node:path'

🟡 MEDIUM: 外部CDNスクリプトのSRI未設定

影響度: 中 対象ファイル: pages/blog/drawio-viewer-test.vue

問題の詳細

外部CDN (viewer.diagrams.net) からスクリプトをロードする際、Subresource Integrity (SRI) が設定されていません。これにより、以下のリスクがあります:

  1. CDNが侵害された場合、悪意のあるコードが注入される可能性
  2. 中間者攻撃 (MITM) によるスクリプト改ざん
  3. CDNプロバイダーのミスによる意図しないコード変更

脆弱なコード:

function loadViewerScript() {
  if (document.querySelector('script[src*="viewer-static.min.js"]')) {
    if (window.GraphViewer) {
      window.GraphViewer.processElements()
    }
    return
  }

  const script = document.createElement('script')
  script.src = 'https://viewer.diagrams.net/js/viewer-static.min.js'
  script.async = true
  document.body.appendChild(script)
}

推奨される修正

オプション1: SRIを追加(推奨)

function loadViewerScript() {
  if (document.querySelector('script[src*="viewer-static.min.js"]')) {
    if (window.GraphViewer) {
      window.GraphViewer.processElements()
    }
    return
  }

  const script = document.createElement('script')
  script.src = 'https://viewer.diagrams.net/js/viewer-static.min.js'
  script.async = true

  // ⭐ SRIを追加
  script.integrity = 'sha384-<HASH>'
  script.crossOrigin = 'anonymous'

  // ⭐ エラーハンドリングを追加
  script.onerror = () => {
    console.error('Failed to load viewer-static.min.js')
  }

  document.body.appendChild(script)
}

SRIハッシュの取得方法:

# viewer-static.min.jsをダウンロードしてハッシュを生成
curl -s https://viewer.diagrams.net/js/viewer-static.min.js | \
  openssl dgst -sha384 -binary | \
  openssl base64 -A

オプション2: ローカルホスティング(最も安全)

function loadViewerScript() {
  if (document.querySelector('script[src*="viewer-static.min.js"]')) {
    if (window.GraphViewer) {
      window.GraphViewer.processElements()
    }
    return
  }

  const script = document.createElement('script')
  // ⭐ ローカルにホストしたファイルを使用
  script.src = '/js/viewer-static.min.js'
  script.async = true

  script.onerror = () => {
    console.error('Failed to load viewer-static.min.js')
  }

  document.body.appendChild(script)
}

この場合、/public/js/viewer-static.min.js にファイルを配置します。


🟡 MEDIUM: X-Content-Type-Optionsヘッダー未設定

影響度: 中 対象ファイル: server/middleware/content-images.ts

問題の詳細

X-Content-Type-Options: nosniff ヘッダーが設定されていないため、ブラウザがMIMEタイプをスニッフィング(推測)する可能性があります。これにより、以下のリスクがあります:

  • .drawio (XMLファイル) が意図しないMIMEタイプとして解釈される
  • XSSなどの攻撃ベクトルとなる可能性

現在のコード:

setResponseHeaders(event, {
  'Content-Type': mimeTypes[ext.toLowerCase()] || 'application/octet-stream',
  'Cache-Control': cacheControl
})

推奨される修正

setResponseHeaders(event, {
  'Content-Type': mimeTypes[ext.toLowerCase()] || 'application/octet-stream',
  'Cache-Control': cacheControl,
  // ⭐ MIMEスニッフィングを防止
  'X-Content-Type-Options': 'nosniff'
})

良好な実装

実装の中で、セキュリティ上優れている点も確認されました:

✅ ファイルタイプのホワイトリスト

const match = pathname.match(/^\/(\d{4}-\d{2}-\d{2})\/([^/]+\.(png|jpg|jpeg|gif|webp|svg|drawio))$/i)

許可する拡張子を明示的に指定しています。

✅ ファイルの存在確認と型チェック

if (!existsSync(contentPath)) return
if (!statSync(contentPath).isFile()) return

シンボリックリンクやディレクトリを拒否しています。

✅ 明示的なMIMEタイプ設定

const mimeTypes: Record<string, string> = {
  png: 'image/png',
  jpg: 'image/jpeg',
  jpeg: 'image/jpeg',
  gif: 'image/gif',
  webp: 'image/webp',
  svg: 'image/svg+xml',
  drawio: 'application/xml'
}

✅ 環境別のキャッシュ制御

const isDev = process.env.NODE_ENV === 'development'
const cacheControl = (ext === 'drawio' && isDev)
  ? 'no-cache, no-store, must-revalidate'
  : 'public, max-age=31536000'

優先順位付きアクションアイテム

1. 優先度:高 🔴

パストラバーサル対策の実装

  • 対象: server/middleware/content-images.ts
  • 工数: 10分
  • 実装内容:
    1. .. を含むパスを拒否
    2. resolve() でパス検証を追加
    3. セキュリティログを追加

2. 優先度:中 🟡

X-Content-Type-Optionsヘッダーの追加

  • 対象: server/middleware/content-images.ts
  • 工数: 2分
  • 実装内容: setResponseHeaders に1行追加

3. 優先度:中 🟡

外部スクリプトのSRI設定またはローカルホスティング

  • 対象: pages/blog/drawio-viewer-test.vue
  • 工数: 20分(SRI)または 30分(ローカルホスティング)
  • 実装内容:
    • SRIの場合: ハッシュ生成、integrity属性追加、エラーハンドリング
    • ローカルホスティングの場合: ファイルダウンロード、public/js/配置、パス変更

その他の推奨事項

Content Security Policy (CSP) の検討

将来的に、NuxtのヘッダーにCSPを追加することを推奨します:

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    routeRules: {
      '/**': {
        headers: {
          'Content-Security-Policy': [
            "default-src 'self'",
            "script-src 'self' https://viewer.diagrams.net",
            "style-src 'self' 'unsafe-inline'",
            "img-src 'self' data:"
          ].join('; ')
        }
      }
    }
  }
})

エラーハンドリングの改善

ミドルウェアでのエラーを適切にログに記録:

try {
  return sendStream(event, createReadStream(contentPath))
} catch (error) {
  console.error(`[Error] Failed to serve file: ${contentPath}`, error)
  throw createError({
    statusCode: 500,
    statusMessage: 'Internal Server Error'
  })
}

まとめ

総合評価

セキュリティスコア: 7/10

  • 優れている点: ファイルタイプ検証、存在確認、明示的MIME設定
  • 改善が必要な点: パストラバーサル対策、SRI設定、セキュリティヘッダー

修正後の期待スコア

上記の3つのアクションアイテムを実装することで、スコアは 9/10 に向上します。

実装の優先順位

  1. 今すぐ実装: パストラバーサル対策(HIGH)
  2. 今週中: X-Content-Type-Options(MEDIUM)
  3. 今週中: SRI設定またはローカルホスティング(MEDIUM)
  4. 将来的に検討: CSP、エラーハンドリング改善

参考資料