mcpField
S
security-bun

security-bun

github.com/igorwarzochaセキュリティ122104 回インストール38 週間前に更新
リポジトリ

security-bun について

Bun アプリ開発者向けのセキュリティ監査スキルです。コマンド・SQL インジェクション、パストラバーサル、サーバー設定などを確認する検索コマンドと強化チェックリストを含みます。

主な機能

  1. 01シェルとコマンドインジェクションの確認
  2. 02SQL の文字列補間とパラメーター化の問題検出
  3. 03リクエスト検証、CORS、ホストバインドの確認
  4. 04パストラバーサルと WebSocket 認証の確認
  5. 05環境変数と bunfig.toml の確認
  6. 06監査コマンドと強化チェックリスト

活用シーン

  1. 01Bun.shell と Bun.spawn でのユーザー入力処理の監査
  2. 02bun:sqlite クエリのインジェクションリスク確認
  3. 03Bun.serve のパス、CORS、ネットワーク公開の評価

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/igorwarzocha/opencode-workflows --skill security-bun

インストール後、Claude Code で/security-bunを入力して読み込まれたか確認します。

同梱ファイルscripts/scan.sh

詳細

Bun ランタイムアプリケーション向けのセキュリティ監査パターンです。シェルインジェクション、SQL インジェクション、サーバーセキュリティ、Bun 固有の脆弱性を対象とします。

Bun で最も注意すべき点:シェルのエスケープと raw シェル

Bun のシェル $ はタグ付きテンプレートで、デフォルトでエスケープします。raw モードでエスケープを回避すると、ユーザー入力がコマンドインジェクションにつながる可能性があります。

import { $ } from "bun";

const userInput = "hello; rm -rf /";

// ✓ SAFE: Tagged template - automatically escapes
await $`echo ${userInput}`;
// Executes: echo 'hello; rm -rf /'

// ❌ CRITICAL: Spawning a new shell (bypasses Bun escaping)
await $`bash -c "echo ${userInput}"`;
// The nested shell interprets user input as code
引数インジェクション(エスケープしていても発生)

安全なタグ付きテンプレートでも、引数インジェクションの脆弱性があります。

import { $ } from "bun";

// ❌ HIGH: Argument injection via -- prefix
const userRepo = "--upload-pack=id>/tmp/pwned";
await $`git ls-remote ${userRepo} main`;
// The -- prefix makes it a command-line argument, not a value

// ✓ Validate input format before use
const userRepo = getUserInput();
if (!userRepo.match(/^https?:\/\//)) {
  throw new Error("Invalid repository URL");
}
await $`git ls-remote ${userRepo} main`;

// ✓ Or use -- to end argument parsing
await $`git ls-remote -- ${userRepo} main`;

bun:sqlite の SQL インジェクション

sql は値をパラメーター化するタグ付きテンプレートです。SQL 文字列を手動で組み立てると、依然として脆弱性が生じる可能性があります。

import { sql } from "bun";

const userId = "1 OR 1=1";

// ❌ CRITICAL: Function call - SQL injection!
await sql(`SELECT * FROM users WHERE id = ${userId}`);
// Executes: SELECT * FROM users WHERE id = 1 OR 1=1

// ✓ SAFE: Tagged template - parameterized query
await sql`SELECT * FROM users WHERE id = ${userId}`;
// Executes: SELECT * FROM users WHERE id = $1 with params ['1 OR 1=1']
bun:sqlite の Database クラス
import { Database } from "bun:sqlite";

const db = new Database("mydb.sqlite");
const userInput = "'; DROP TABLE users; --";

// ❌ CRITICAL: String interpolation
db.run(`INSERT INTO logs VALUES ('${userInput}')`);

// ✓ SAFE: Parameterized with .run()
db.run("INSERT INTO logs VALUES (?)", [userInput]);

// ✓ SAFE: Prepared statements
const stmt = db.prepare("SELECT * FROM users WHERE id = ?");
stmt.get(userInput);

// ✓ SAFE: Query with parameters
db.query("SELECT * FROM users WHERE email = ?").get(userInput);

Bun.serve() のセキュリティ

リクエスト検証の欠如
// ❌ No input validation
Bun.serve({
  fetch(req) {
    const url = new URL(req.url);
    const file = url.searchParams.get("file");
    return new Response(Bun.file(`./uploads/${file}`)); // Path traversal!
  },
});

// ✓ Validate and sanitize
import { join, basename, resolve } from "path";

Bun.serve({
  fetch(req) {
    const url = new URL(req.url);
    const file = url.searchParams.get("file");
    
    // Sanitize filename
    const safeName = basename(file ?? "");
    const uploadsDir = resolve("./uploads");
    const filePath = resolve(join(uploadsDir, safeName));
    
    // Verify path is within uploads directory
    if (!filePath.startsWith(uploadsDir)) {
      return new Response("Forbidden", { status: 403 });
    }
    
    return new Response(Bun.file(filePath));
  },
});
リクエストサイズ制限(DoS 対策)
// ❌ No body size limit (large uploads can exhaust memory)
Bun.serve({
  fetch(req) {
    return new Response("ok");
  },
});

// ✓ Set a max request body size
Bun.serve({
  maxRequestBodySize: 1_000_000, // 1 MB
  fetch(req) {
    return new Response("ok");
  },
});
CORS の設定
// ❌ Wide open CORS
Bun.serve({
  fetch(req) {
    return new Response("data", {
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Credentials": "true", // Dangerous combo!
      },
    });
  },
});

// ✓ Explicit origin allowlist
const ALLOWED_ORIGINS = ["https://app.example.com"];

Bun.serve({
  fetch(req) {
    const origin = req.headers.get("Origin");
    const corsHeaders: Record<string, string> = {};
    
    if (origin && ALLOWED_ORIGINS.includes(origin)) {
      corsHeaders["Access-Control-Allow-Origin"] = origin;
      corsHeaders["Access-Control-Allow-Credentials"] = "true";
    }
    
    return new Response("data", { headers: corsHeaders });
  },
});
ホストのバインド
// ❌ Exposed to network (sometimes unintentional)
Bun.serve({
  hostname: "0.0.0.0", // Accessible from any network interface
  port: 3000,
  fetch(req) { /* ... */ },
});

// ✓ Localhost only for development
Bun.serve({
  hostname: "127.0.0.1", // Only local access
  port: 3000,
  fetch(req) { /* ... */ },
});

Bun.spawn() のコマンドインジェクション

// ❌ CRITICAL: User input in command array (can still be dangerous)
const filename = userInput; // Could be "--version" or other flags
Bun.spawn(["convert", filename, "output.png"]);

// ❌ CRITICAL: Shell execution with user input
Bun.spawn(["sh", "-c", `convert ${userInput} output.png`]);

// ✓ Validate input first
const filename = userInput;
if (!filename.match(/^[a-zA-Z0-9_-]+\.(jpg|png|gif)$/)) {
  throw new Error("Invalid filename");
}
Bun.spawn(["convert", filename, "output.png"]);

// ✓ Use -- to prevent flag injection
Bun.spawn(["convert", "--", filename, "output.png"]);

Bun.file() と Bun.write() のパストラバーサル

// ❌ HIGH: Path traversal
const userFile = req.query.file; // "../../etc/passwd"
const content = await Bun.file(`./uploads/${userFile}`).text();

// ❌ HIGH: Writing to arbitrary paths
await Bun.write(`./data/${userFile}`, content);

// ✓ Sanitize paths
import { join, basename, resolve } from "path";

const UPLOADS_DIR = resolve("./uploads");

function getSafePath(userInput: string): string {
  const safeName = basename(userInput);
  const fullPath = resolve(join(UPLOADS_DIR, safeName));
  
  if (!fullPath.startsWith(UPLOADS_DIR)) {
    throw new Error("Invalid path");
  }
  
  return fullPath;
}

const content = await Bun.file(getSafePath(userFile)).text();

Bun.password(安全ですが、使い方を確認してください)

// ✓ Bun.password.hash is secure by default (uses argon2)
const hash = await Bun.password.hash(password);

// ✓ Verify passwords
const isValid = await Bun.password.verify(password, hash);

// ⚠️ But check: is it actually being used?
// Common vibecoding mistake: storing plaintext anyway

// ❌ Storing plaintext
db.run("INSERT INTO users (password) VALUES (?)", [password]);

// ✓ Storing hash
const hash = await Bun.password.hash(password);
db.run("INSERT INTO users (password_hash) VALUES (?)", [hash]);

環境変数

// Bun.env is the same as process.env

// ❌ Secrets in client-facing code
// If using Bun with a bundler, check what gets bundled

// ✓ Server-only access
const apiKey = Bun.env.API_KEY;
if (!apiKey) {
  throw new Error("API_KEY not configured");
}

// Check bunfig.toml for any exposed variables
bunfig.toml のセキュリティ
# Check for suspicious configurations

[install]
# ❌ Disabling lockfile = supply chain risk
save-lockfile = false

# ❌ Allowing arbitrary registries
registry = "http://malicious-registry.com"

[run]
# ❌ Disabling sandbox (if applicable)

WebSocket のセキュリティ

Bun.serve({
  fetch(req, server) {
    if (req.headers.get("upgrade") === "websocket") {
      // ❌ No auth check before upgrade
      server.upgrade(req);
      return;
    }
  },
  websocket: {
    message(ws, message) {
      // ❌ Broadcasting without auth
      ws.publish("chat", message);
    },
  },
});

// ✓ Authenticate before upgrade
Bun.serve({
  fetch(req, server) {
    if (req.headers.get("upgrade") === "websocket") {
      const token = req.headers.get("Authorization");
      const user = await verifyToken(token);
      
      if (!user) {
        return new Response("Unauthorized", { status: 401 });
      }
      
      server.upgrade(req, { data: { user } });
      return;
    }
  },
  websocket: {
    message(ws, message) {
      // Access authenticated user
      const user = ws.data.user;
      // Now safe to process message
    },
  },
});

<severity_table>

よくある脆弱性の概要

問題検索するパターン重大度
シェルインジェクション(関数呼び出し)$(...) または $("...")CRITICAL
SQL インジェクション(関数呼び出し)sql(...)CRITICAL
SQL 文字列の補間SQL 内の `...${var}...`CRITICAL
引数インジェクション- で始まるユーザー入力HIGH
パストラバーサルBun.file(userInput)HIGH
コマンドインジェクションユーザー入力を使った Bun.spawnHIGH
オープンな CORSAccess-Control-Allow-Origin: *MEDIUM
ネットワークへの露出hostname: "0.0.0.0"MEDIUM
WebSocket 認証の欠如認証チェックなしの server.upgradeHIGH

</severity_table>

監査コマンド(クイック実行)

# Find dangerous shell usage (function call instead of tagged template)
rg '\$\s*\(' . -g "*.ts" -g "*.js"

# Find SQL function calls (should be tagged template)
rg 'sql\s*\(' . -g "*.ts" -g "*.js"

# Find string interpolation in queries
rg '(query|run|exec)\s*\(\s*`' . -g "*.ts" -g "*.js"

# Find Bun.spawn usage
rg 'Bun\.spawn' . -g "*.ts" -g "*.js" -A 2

# Find Bun.file with variables (potential path traversal)
rg 'Bun\.file\s*\([^"'\''`]' . -g "*.ts" -g "*.js"

# Find hostname binding
rg 'hostname.*0\.0\.0\.0' . -g "*.ts" -g "*.js"

# Find CORS headers
rg 'Access-Control-Allow-Origin' . -g "*.ts" -g "*.js"

# Find WebSocket upgrades
rg 'server\.upgrade' . -g "*.ts" -g "*.js" -B 5

強化チェックリスト

  • すべての $ シェル使用箇所がタグ付きテンプレートになっている(丸括弧なし)
  • すべての sql 使用箇所がタグ付きテンプレートになっている(丸括弧なし)
  • すべての bun:sqlite クエリでパラメーター化を使用している
  • シェル/spawn コマンドの前にユーザー入力を検証している
  • 該当する場合、引数インジェクションを防ぐために -- を使用している
  • basename() とパス検証でファイルパスをサニタイズしている
  • CORS を特定のオリジンに制限している
  • 開発環境では hostname が 127.0.0.1、本番環境では明示的に設定されている
  • WebSocket 接続をアップグレードする前に認証している
  • パスワードに Bun.password.hash を使用している(平文ではない)
  • bunfig.toml に不審な設定がないか確認している

コメント