mcpField
S
security-bun

security-bun

github.com/igorwarzochaSecurity122104 installsUpdated 38w ago
Repository

About security-bun

A security-audit skill for Bun application developers, covering command and SQL injection, path traversal, server configuration, and Bun-specific risks. It includes search commands and a hardening checklist.

Key features

  1. 01Check shell and command injection risks
  2. 02Identify SQL interpolation and parameterization issues
  3. 03Review request validation, CORS, and host binding
  4. 04Check path traversal and WebSocket authentication
  5. 05Review environment variables and bunfig.toml
  6. 06Provide audit commands and a hardening checklist

Use cases

  1. 01Audit user input handling in Bun shell and Bun.spawn
  2. 02Check bun:sqlite queries for injection risks
  3. 03Assess path, CORS, and network exposure in Bun.serve

Install

Copy the command and run it in a terminal.

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

After installing, type/security-bunin Claude Code to confirm it loaded.

Bundled filesscripts/scan.sh

Details

From the project README

Security audit patterns for Bun runtime applications covering shell injection, SQL injection, server security, and Bun-specific vulnerabilities.

The #1 Bun Footgun: Shell Escaping vs Raw Shell

Bun's shell $ is a tagged template that escapes by default. If you bypass escaping (via raw mode), user input can become command injection.

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
Argument Injection (Even with Escaping)

Even the safe tagged template is vulnerable to argument injection:

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 Injection

sql is a tagged template that parameterizes values. If you build SQL strings manually, you can still be vulnerable.

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 Class
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() Security

Missing Request Validation
// ❌ 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));
  },
});
Request Size Limits (DoS Protection)
// ❌ 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 Configuration
// ❌ 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 });
  },
});
Host Binding
// ❌ 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() Command Injection

// ❌ 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() and Bun.write() Path Traversal

// ❌ 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 (Secure, but check usage)

// ✓ 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]);

Environment Variables

// 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 Security
# 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 Security

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>

Common Vulnerabilities Summary

IssuePattern to FindSeverity
Shell injection (function call)$(...) or $("...")CRITICAL
SQL injection (function call)sql(...)CRITICAL
SQL string interpolation`...${var}...` in SQLCRITICAL
Argument injectionUser input starting with -HIGH
Path traversalBun.file(userInput)HIGH
Command injectionBun.spawn with user inputHIGH
Open CORSAccess-Control-Allow-Origin: *MEDIUM
Network exposurehostname: "0.0.0.0"MEDIUM
Missing WebSocket authserver.upgrade without auth checkHIGH

</severity_table>

Quick Audit Commands

# 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

Hardening Checklist

  • All $ shell usage is tagged template (no parentheses)
  • All sql usage is tagged template (no parentheses)
  • All bun:sqlite queries use parameterization
  • User input validated before shell/spawn commands
  • -- used to prevent argument injection where applicable
  • File paths sanitized with basename() and path validation
  • CORS restricted to specific origins
  • hostname is 127.0.0.1 for dev, explicit for prod
  • WebSocket connections authenticated before upgrade
  • Bun.password.hash used for passwords (not plaintext)
  • bunfig.toml reviewed for suspicious settings

Comments

Markdown supported