
Security Bun
- 86 installs
- 125 repo stars
- Updated February 4, 2026
- igorwarzocha/opencode-workflows
Audit Bun runtime apps for shell injection, SQL injection, server security, and unsafe process spawning.
About
A security-audit skill for Bun-specific vulnerabilities. A developer uses it to review Bun shell $ usage, bun:sqlite queries, Bun.serve/Bun.spawn calls, and WebSocket auth.
- Flags raw-shell bypass of Bun's escaping $ tagged template
- Covers SQL injection, path traversal, and command injection footguns
Security Bun by the numbers
- 86 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,064 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/igorwarzocha/opencode-workflows --skill security-bunAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 86 |
|---|---|
| repo stars | ★ 125 |
| Last updated | February 4, 2026 |
| Repository | igorwarzocha/opencode-workflows ↗ |
What it does
Audit Bun runtime apps for shell injection, SQL injection, server security, and unsafe process spawning.
Files
<overview>
Security audit patterns for Bun runtime applications covering shell injection, SQL injection, server security, and Bun-specific vulnerabilities.
</overview>
<rules>
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 codeArgument 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);</rules>
<vulnerabilities>
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 variablesbunfig.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
},
},
});</vulnerabilities>
<severity_table>
Common Vulnerabilities Summary
| Issue | Pattern to Find | Severity |
|---|---|---|
| Shell injection (function call) | $(...) or $("...") | CRITICAL |
| SQL injection (function call) | sql(...) | CRITICAL |
| SQL string interpolation | ` ...${var}... ` in SQL | CRITICAL |
| Argument injection | User input starting with - | HIGH |
| Path traversal | Bun.file(userInput) | HIGH |
| Command injection | Bun.spawn with user input | HIGH |
| Open CORS | Access-Control-Allow-Origin: * | MEDIUM |
| Network exposure | hostname: "0.0.0.0" | MEDIUM |
| Missing WebSocket auth | server.upgrade without auth check | HIGH |
</severity_table>
<commands>
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</commands>
<checklist>
Hardening Checklist
- [ ] All
$shell usage is tagged template (no parentheses) - [ ] All
sqlusage 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
</checklist>
#!/usr/bin/env bash
# Bun Security Scanner - First-pass automated detection
# Usage: ./scan.sh [directory]
set -euo pipefail
DIR="${1:-.}"
FOUND=0
echo "=== BUN SECURITY SCAN ==="
echo "Directory: $DIR"
echo "Timestamp: $(date -Iseconds)"
echo ""
if ! command -v rg &> /dev/null; then
echo "[ERROR] ripgrep (rg) required. Install with: brew install ripgrep"
exit 1
fi
report() {
local severity="$1"
local title="$2"
local file="$3"
local line="$4"
local match="$5"
echo "[$severity] $title"
echo " File: $file:$line"
echo " Match: $match"
echo ""
FOUND=$((FOUND + 1))
}
echo "=== HIGH: Shell Usage Without Escaping ==="
echo ""
# Non-tagged $ usage (potentially unescaped)
while IFS=: read -r file line match; do
[[ -z "$file" ]] && continue
report "HIGH" "Non-template $ usage (verify escaping)" "$file" "$line" "$match"
done < <(rg -n --no-heading '\$\s*\(' "$DIR" -g "*.ts" -g "*.js" -g "*.tsx" 2>/dev/null || true)
echo "=== HIGH: Nested Shell Usage ==="
echo ""
# Spawning a new shell can re-enable injection
while IFS=: read -r file line match; do
[[ -z "$file" ]] && continue
report "HIGH" "Nested shell invocation (verify input is not user-controlled)" "$file" "$line" "$match"
done < <(rg -n --no-heading 'bash\\s+-c|sh\\s+-c' "$DIR" -g "*.ts" -g "*.js" -g "*.tsx" 2>/dev/null || true)
echo "=== CRITICAL: SQL Injection ==="
echo ""
# SQL function call - CRITICAL
while IFS=: read -r file line match; do
[[ -z "$file" ]] && continue
report "CRITICAL" "sql() used as function call (injection risk)" "$file" "$line" "$match"
done < <(rg -n --no-heading 'sql\s*\(' "$DIR" -g "*.ts" -g "*.js" 2>/dev/null || true)
# String interpolation in database queries
while IFS=: read -r file line match; do
[[ -z "$file" ]] && continue
report "HIGH" "String interpolation in query/run/exec" "$file" "$line" "$match"
done < <(rg -n --no-heading '(query|run|exec)\s*\(\s*`' "$DIR" -g "*.ts" -g "*.js" 2>/dev/null || true)
echo "=== HIGH: Command Injection ==="
echo ""
# Bun.spawn checks
while IFS=: read -r file line match; do
[[ -z "$file" ]] && continue
report "MEDIUM" "Bun.spawn usage (verify input validation)" "$file" "$line" "$match"
done < <(rg -n --no-heading 'Bun\.spawn' "$DIR" -g "*.ts" -g "*.js" 2>/dev/null || true)
echo "=== HIGH: Path Traversal ==="
echo ""
# Bun.file with variable (not string literal)
while IFS=: read -r file line match; do
[[ -z "$file" ]] && continue
# Skip if it's a string literal
if ! echo "$match" | grep -qE 'Bun\.file\s*\(["'\''`]'; then
report "HIGH" "Bun.file() with variable (path traversal risk)" "$file" "$line" "$match"
fi
done < <(rg -n --no-heading 'Bun\.file\s*\(' "$DIR" -g "*.ts" -g "*.js" 2>/dev/null || true)
echo "=== MEDIUM: Network Exposure ==="
echo ""
# 0.0.0.0 binding
while IFS=: read -r file line match; do
[[ -z "$file" ]] && continue
report "MEDIUM" "Server bound to 0.0.0.0 (network exposed)" "$file" "$line" "$match"
done < <(rg -n --no-heading 'hostname.*0\.0\.0\.0' "$DIR" -g "*.ts" -g "*.js" 2>/dev/null || true)
echo "=== MEDIUM: CORS Configuration ==="
echo ""
# Wildcard CORS
while IFS=: read -r file line match; do
[[ -z "$file" ]] && continue
report "MEDIUM" "CORS Allow-Origin header found (verify not wildcard)" "$file" "$line" "$match"
done < <(rg -n --no-heading 'Access-Control-Allow-Origin' "$DIR" -g "*.ts" -g "*.js" 2>/dev/null || true)
echo "=== MEDIUM: WebSocket Auth ==="
echo ""
# WebSocket upgrade without apparent auth
while IFS=: read -r file line match; do
[[ -z "$file" ]] && continue
report "MEDIUM" "server.upgrade() found (verify auth before upgrade)" "$file" "$line" "$match"
done < <(rg -n --no-heading 'server\.upgrade' "$DIR" -g "*.ts" -g "*.js" 2>/dev/null || true)
echo "=== SUMMARY ==="
if [[ $FOUND -gt 0 ]]; then
echo "[!] Found $FOUND potential issues. Review above."
exit 1
else
echo "[✓] No obvious Bun security issues detected"
exit 0
fi