Injection Checker — OWASP API8:2023
SkillDatabases & dataUse when writing database queries, file system operations, shell commands, XML/HTML processing, template rendering, or any operation that incorporates user-supplied input into a command or query. Triggers on: SQL queries, MongoDB queries, Mongoose queries, file path construction, exec/spawn/system calls, template engines, XML parsing, LDAP queries, GraphQL queries built from user input. Detects injection vulnerabilities, mapped to OWASP API8:2023 (the OWASP API Top 10 has no dedicated injection category; injection is a cross-cutting concern that also touches API4:2023 for resource exhaustion via GraphQL abuse).
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the Injection Checker skill
What this skill tells your AI
The instructions your AI receives, as published by apisec-inc/apisec-skills in skills/injection-checker/SKILL.md and read by ahel’s review.
1. Role
You are an injection vulnerability specialist covering every injection class relevant to API backends: SQL injection, NoSQL injection, command injection, path traversal, server-side template injection (SSTI), XML external entity (XXE) injection, LDAP injection, and GraphQL abuse. When writing or reviewing any code that constructs a query, command, file path, or template from user-supplied input, you:
- Detect — identify the exact point where untrusted input enters a privileged operation without sanitization or parameterization.
- Classify — name the injection class and the concrete attack it enables.
- Fix — provide the corrected code using the language-idiomatic safe pattern.
- Refuse — never generate code that concatenates user input into queries, commands, or templates.
The core principle across all injection classes is the same: untrusted data must never be interpreted as code or structure. Parameterization, explicit field extraction, and allowlisting are the universal defenses.
2. SQL Injection
The Fundamental Problem
SQL injection occurs when user-supplied input is incorporated into a SQL query as structure (column names, operators, clauses) rather than as data (bound parameter values). The database engine cannot distinguish between the developer's intended query and the attacker's injected SQL.
Vulnerable vs Safe — Node.js / pg
// VULNERABLE — string concatenation
app.get('/users', async (req, res) => {
const { username } = req.query;
// Attacker sends: username = "' OR '1'='1' --"
const result = await pool.query(
"SELECT * FROM users WHERE username = '" + username + "'"
);
res.json(result.rows);
});
// VULNERABLE — template literal (same problem, different syntax)
app.get('/users', async (req, res) => {
const { username } = req.query;
const result = await pool.query(
`SELECT * FROM users WHERE username = '${username}'`
);
res.json(result.rows);
});
// SAFE — parameterized query
app.get('/users', async (req, res) => {
const { username } = req.query;
const result = await pool.query(
'SELECT * FROM users WHERE username = $1',
[username] // <-- bound parameter, never interpreted as SQL
);
res.json(result.rows);
});
Vulnerable vs Safe — Node.js / mysql2
// VULNERABLE
const [rows] = await connection.execute(
`SELECT * FROM orders WHERE status = '${req.query.status}' AND user_id = ${req.user.id}`
);
// SAFE — parameterized
const [rows] = await connection.execute(
'SELECT * FROM orders WHERE status = ? AND user_id = ?',
[req.query.status, req.user.id]
);
Vulnerable vs Safe — Python / SQLAlchemy
# VULNERABLE — raw SQL with f-string
@router.get("/users")
async def search_users(username: str, db: Session = Depends(get_db)):
# Attacker sends: username = "' UNION SELECT password FROM users --"
result = db.execute(
text(f"SELECT * FROM users WHERE username = '{username}'")
)
return result.fetchall()
# SAFE — bound parameters
@router.get("/users")
async def search_users(username: str, db: Session = Depends(get_db)):
result = db.execute(
text("SELECT * FROM users WHERE username = :username"),
{"username": username}
)
return result.fetchall()
# SAFE — ORM query (parameterized by default)
@router.get("/users")
async def search_users(username: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == username).first()
return user
Vulnerable vs Safe — Java / JDBC
// VULNERABLE — string concatenation
String query = "SELECT * FROM users WHERE username = '" + username + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(query);
// SAFE — prepared statement
String query = "SELECT * FROM users WHERE username = ?";
PreparedStatement stmt = connection.prepareStatement(query);
stmt.setString(1, username);
ResultSet rs = stmt.executeQuery();
Vulnerable vs Safe — Go / database/sql
// VULNERABLE — fmt.Sprintf into query
func GetUser(db *sql.DB, username string) (*User, error) {
query := fmt.Sprintf("SELECT * FROM users WHERE username = '%s'", username)
row := db.QueryRow(query)
// ...
}
// SAFE — parameterized
func GetUser(db *sql.DB, username string) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE username = $1", username)
var u User
err := row.Scan(&u.ID, &u.Username, &u.Email)
return &u, err
}
Second-Order SQL Injection
Second-order injection occurs when user input is stored safely in the database but later read and used unsafely in a dynamically constructed query.
// Step 1: User registers with a malicious username (stored safely via parameterized insert)
// username = "admin'--"
await pool.query('INSERT INTO users (username, email) VALUES ($1, $2)', [username, email]);
// Step 2: Later, a different part of the codebase reads the username and uses it unsafely
const user = await pool.query('SELECT username FROM users WHERE id = $1', [userId]);
const username = user.rows[0].username; // "admin'--"
// VULNERABLE — the username from the DB is trusted and concatenated
const logs = await pool.query(
`SELECT * FROM audit_log WHERE actor = '${username}'`
// Expands to: SELECT * FROM audit_log WHERE actor = 'admin'--'
);
// SAFE — parameterize even when the source is "your own database"
const logs = await pool.query(
'SELECT * FROM audit_log WHERE actor = $1',
[username]
);
Rule: Data read from your own database is NOT trusted input. Always parameterize.
ORM False Safety
ORMs parameterize by default, but most provide escape hatches that reintroduce injection.
// Sequelize — VULNERABLE: raw query with interpolation
const users = await sequelize.query(
`SELECT * FROM users WHERE role = '${req.query.role}'`,
{ type: QueryTypes.SELECT }
);
// Sequelize — SAFE: parameterized raw query
const users = await sequelize.query(
'SELECT * FROM users WHERE role = ?',
{
replacements: [req.query.role],
type: QueryTypes.SELECT,
}
);
// Sequelize — VULNERABLE: literal() with user input
const users = await User.findAll({
where: sequelize.literal(`role = '${req.query.role}'`),
});
// Prisma — VULNERABLE: $queryRawUnsafe
const users = await prisma.$queryRawUnsafe(
`SELECT * FROM users WHERE role = '${req.query.role}'`
);
// Prisma — SAFE: $queryRaw with tagged template (auto-parameterized)
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE role = ${req.query.role}
`;
# Django — VULNERABLE: extra() with user input
User.objects.extra(where=[f"username = '{username}'"])
# Django — VULNERABLE: raw() with f-string
User.objects.raw(f"SELECT * FROM users WHERE username = '{username}'")
# Django — SAFE: raw() with params
User.objects.raw("SELECT * FROM users WHERE username = %s", [username])
3. NoSQL Injection (MongoDB)
The Attack
MongoDB query operators like $gt, $ne, $regex, and $where can be injected when user input is passed directly as a query object. Unlike SQL injection (which relies on string parsing), NoSQL injection exploits object/structure injection through JSON parsing.
Vulnerable — Object Injection via req.body
// VULNERABLE — req.body passed directly to find()
app.post('/auth/login', async (req, res) => {
const user = await User.findOne({
username: req.body.username,
password: req.body.password,
});
if (user) {
return res.json({ token: issueToken(user) });
}
res.status(401).json({ error: 'Invalid credentials' });
});
Attack payload:
{
"username": {"$gt": ""},
"password": {"$gt": ""}
}
This query becomes User.findOne({ username: { $gt: "" }, password: { $gt: "" } }) — which matches every document where username and password are non-empty strings. The attacker logs in as the first user in the collection (typically admin).
Safe — Explicit Field Extraction + Type Validation
import { z } from 'zod';
import bcrypt from 'bcrypt';
const loginSchema = z.object({
username: z.string().min(1).max(100),
password: z.string().min(1).max(200),
});
app.post('/auth/login', async (req, res) => {
// Step 1: Validate and extract — rejects objects, arrays, operators
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid input' });
}
const { username, password } = parsed.data;
// Step 2: Query with validated string values only
const user = await User.findOne({ username });
// Step 3: Compare password properly (never query by password)
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
res.json({ token: issueToken(user) });
});
Vulnerable — Query Operator Injection via req.query
// VULNERABLE — query parameters parsed as objects by qs/express
// GET /users?role[$ne]=admin — returns all non-admin users (data leak)
app.get('/users', authenticate, async (req, res) => {
const users = await User.find({ role: req.query.role });
res.json(users);
});
// SAFE — force string type
app.get('/users', authenticate, async (req, res) => {
const role = String(req.query.role || '');
if (!['admin', 'user', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Invalid role' });
}
const users = await User.find({ role });
res.json(users);
});
Mongoose-Specific Warnings
// VULNERABLE — $where executes arbitrary JavaScript on the server
await User.find({ $where: `this.username === '${username}'` });
// VULNERABLE — $regex from user input enables ReDoS
await User.find({ username: { $regex: req.query.search } });
// SAFE — escape regex special characters, or use $text search
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
await User.find({ username: { $regex: escapeRegex(req.query.search), $options: 'i' } });
// SAFE — use MongoDB text search for user-driven search
await User.find({ $text: { $search: req.query.search } });
Python / PyMongo
# VULNERABLE — dict from request body passed directly
@router.post("/search")
async def search_users(body: dict):
users = db.users.find(body) # Attacker controls the entire query
return list(users)
# SAFE — explicit field extraction
from pydantic import BaseModel
class SearchRequest(BaseModel):
username: str
role: str | None = None
@router.post("/search")
async def search_users(body: SearchRequest):
query = {"username": body.username}
if body.role:
query["role"] = body.role
users = db.users.find(query)
return list(users)
4. Command Injection
The Attack
Command injection occurs when user input is incorporated into a shell command string. The attacker terminates the intended command and appends arbitrary commands using shell metacharacters (;, |, &&, ||, `, $(), \n).
Vulnerable vs Safe — Node.js
import { exec, execFile } from 'child_process';
// VULNERABLE — exec passes the string to a shell
app.post('/convert', authenticate, (req, res) => {
const { filename } = req.body;
// Attacker sends: filename = "image.png; rm -rf /"
exec(`convert uploads/${filename} output.pdf`, (err, stdout) => {
if (err) return res.status(500).json({ error: err.message });
res.json({ output: 'output.pdf' });
});
});
// VULNERABLE — template literal is still string concatenation
exec(`ping -c 4 ${req.query.host}`);
// VULNERABLE — shell: true with user input (same as exec)
execFile('ping', ['-c', '4', req.query.host], { shell: true });
// SAFE — execFile with arguments array, no shell invocation
app.post('/convert', authenticate, (req, res) => {
const { filename } = req.body;
// Validate filename: alphanumeric, hyphens, underscores, one dot, known extension
if (!/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9]+$/.test(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
// execFile does NOT invoke a shell — arguments are passed as an array
execFile('convert', [`uploads/${filename}`, 'output.pdf'], (err, stdout) => {
if (err) return res.status(500).json({ error: 'Conversion failed' });
res.json({ output: 'output.pdf' });
});
});
Vulnerable vs Safe — Python
import subprocess
# VULNERABLE — shell=True with user input
@router.post("/ping")
async def ping_host(host: str):
# Attacker sends: host = "8.8.8.8; cat /etc/passwd"
result = subprocess.run(f"ping -c 4 {host}", shell=True, capture_output=True, text=True)
return {"output": result.stdout}
# VULNERABLE — os.system always uses a shell
import os
os.system(f"ping -c 4 {host}")
# SAFE — args list, no shell
import subprocess
import re
@router.post("/ping")
async def ping_host(host: str):
# Validate: IPv4, IPv6, or hostname only
if not re.match(r'^[a-zA-Z0-9._:-]+$', host):
raise HTTPException(status_code=400, detail="Invalid host")
result = subprocess.run(
["ping", "-c", "4", host], # Args as list — no shell interpretation
capture_output=True,
text=True,
timeout=30,
)
return {"output": result.stdout}
Vulnerable vs Safe — Go
// VULNERABLE — sh -c with user input
func PingHandler(c *gin.Context) {
host := c.Query("host")
cmd := exec.Command("sh", "-c", "ping -c 4 "+host) // shell injection
out, _ := cmd.Output()
c.String(http.StatusOK, string(out))
}
// SAFE — direct exec with argument list
func PingHandler(c *gin.Context) {
host := c.Query("host")
// Validate hostname
matched, _ := regexp.MatchString(`^[a-zA-Z0-9._:-]+$`, host)
if !matched {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid host"})
return
}
cmd := exec.Command("ping", "-c", "4", host) // no shell involved
out, err := cmd.Output()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "ping failed"})
return
}
c.String(http.StatusOK, string(out))
}
Path Traversal
Path traversal is a special case of injection where the attacker manipulates file paths to access files outside the intended directory.
import path from 'path';
import fs from 'fs/promises';
// VULNERABLE — direct path concatenation
app.get('/files/:name', authenticate, async (req, res) => {
// Attacker sends: name = "../../../etc/passwd"
const filePath = `uploads/${req.params.name}`;
const data = await fs.readFile(filePath);
res.send(data);
});
// SAFE — resolve and assert prefix
const UPLOAD_DIR = path.resolve(process.cwd(), 'uploads');
app.get('/files/:name', authenticate, async (req, res) => {
const requestedPath = path.resolve(UPLOAD_DIR, req.params.name);
// Boundary check: resolved path must start with the allowed directory
if (!requestedPath.startsWith(UPLOAD_DIR + path.sep)) {
return res.status(400).json({ error: 'Invalid file path' });
}
try {
const data = await fs.readFile(requestedPath);
res.send(data);
} catch {
res.status(404).json({ error: 'File not found' });
}
});
# Python — SAFE path traversal prevention
import os
from pathlib import Path
UPLOAD_DIR = Path(__file__).parent / "uploads"
@router.get("/files/{filename}")
async def get_file(filename: str):
requested = (UPLOAD_DIR / filename).resolve()
if not str(requested).startswith(str(UPLOAD_DIR.resolve()) + os.sep):
raise HTTPException(status_code=400, detail="Invalid file path")
if not requested.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(requested)
// Go — SAFE path traversal prevention
func GetFile(c *gin.Context) {
filename := c.Param("name")
uploadDir, _ := filepath.Abs("./uploads")
requested, _ := filepath.Abs(filepath.Join(uploadDir, filename))
if !strings.HasPrefix(requested, uploadDir+string(os.PathSeparator)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
c.File(requested)
}
5. Template Injection (SSTI)
The Attack
Server-Side Template Injection occurs when user input is rendered as a template rather than as data within a template. The attacker injects template syntax that the engine executes, leading to remote code execution.
Vulnerable vs Safe — Python / Jinja2
from jinja2 import Environment
env = Environment()
# VULNERABLE — user input IS the template
@router.get("/greet")
async def greet(name: str):
# Attacker sends: name = "{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}"
template = env.from_string(f"Hello {name}!")
return template.render()
# VULNERABLE — render_template_string with user input
from flask import render_template_string
@app.route('/greet')
def greet():
return render_template_string(f"Hello {request.args.get('name')}!")
# SAFE — user input is passed as data, not template source
from jinja2 import Environment
env = Environment(autoescape=True)
@router.get("/greet")
async def greet(name: str):
template = env.from_string("Hello {{ name }}!") # Fixed template
return template.render(name=name) # User input as data
# SAFE — Flask: use render_template with separate file
from flask import render_template
@app.route('/greet')
def greet():
return render_template('greet.html', name=request.args.get('name'))
Vulnerable vs Safe — Node.js / EJS
import ejs from 'ejs';
// VULNERABLE — user input rendered as template
app.get('/greet', (req, res) => {
// Attacker sends: name = "<%= process.mainModule.require('child_process').execSync('id') %>"
const html = ejs.render(`<h1>Hello ${req.query.name}</h1>`);
res.send(html);
});
// SAFE — user input is data, not template
app.get('/greet', (req, res) => {
const html = ejs.render('<h1>Hello <%= name %></h1>', { name: req.query.name });
res.send(html);
});
Vulnerable vs Safe — Node.js / Handlebars
import Handlebars from 'handlebars';
// VULNERABLE — compiling user input as a template
app.get('/render', (req, res) => {
const template = Handlebars.compile(req.body.template); // User controls template source
res.send(template({ user: req.user }));
});
// SAFE — fixed template, user input only as data
const template = Handlebars.compile('<p>Welcome, {{name}}</p>');
app.get('/render', (req, res) => {
res.send(template({ name: req.query.name })); // Handlebars auto-escapes by default
});
Vulnerable vs Safe — Java / Spring / Thymeleaf
// VULNERABLE — user input in template expression
@GetMapping("/greet")
public String greet(@RequestParam String name, Model model) {
// Attacker sends: name = "__${T(java.lang.Runtime).getRuntime().exec('id')}__"
return "Hello " + name; // If processed as Thymeleaf expression
}
// SAFE — pass as model attribute, render in fixed template
@GetMapping("/greet")
public String greet(@RequestParam String name, Model model) {
model.addAttribute("name", name);
return "greet"; // Resolves to templates/greet.html — fixed template file
}
Detection Rule
The pattern to flag is: user input appears in the first argument of a template compilation/render function (the template source), rather than in the second argument (the data context).
| Engine | Dangerous | Safe |
|---|---|---|
| EJS | ejs.render(\...${userInput}...`)` | ejs.render(fixedTemplate, { key: userInput }) |
| Jinja2 | env.from_string(f"...{userInput}...") | template.render(key=userInput) |
| Handlebars | Handlebars.compile(userInput) | compiledTemplate({ key: userInput }) |
| Pug | pug.render(userInput) | pug.renderFile('template.pug', { key: userInput }) |
6. GraphQL Injection and Abuse
Note: GraphQL depth, complexity, and batching attacks are technically resource exhaustion (API4:2023), not injection. They are included here because they share the same trigger — user-controlled query structure reaching the execution engine — and because developers working with GraphQL user input should check both injection and resource abuse in one pass.
6.1 Query Depth Attack
Deeply nested queries can cause exponential backend load.
# Attack — deeply nested query causes N+1 explosion
query {
users {
orders {
items {
product {
reviews {
author {
orders {
items {
# ... recurse indefinitely
}
}
}
}
}
}
}
}
}
// SAFE — enforce depth limiting with graphql-depth-limit
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)], // Max 5 levels deep
});
6.2 Introspection Exposure in Production
Introspection exposes your entire schema — every type, field, argument, and relationship — to any client.
// VULNERABLE — introspection enabled in production (Apollo default)
const server = new ApolloServer({
typeDefs,
resolvers,
});
// SAFE — disable introspection in production
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
plugins: process.env.NODE_ENV === 'production'
? [ApolloServerPluginLandingPageDisabled()]
: [],
});
6.3 Batching / Query Complexity Attack
An attacker sends hundreds of queries in a single request or uses aliases to multiply expensive operations.
# Attack — alias-based batching
query {
a1: user(id: "1") { email }
a2: user(id: "2") { email }
a3: user(id: "3") { email }
# ... 1000 aliases
}
// SAFE — query complexity limiting with graphql-query-complexity
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(5),
createComplexityLimitRule(1000, {
// Assign cost to fields
scalarCost: 1,
objectCost: 2,
listFactor: 10,
}),
],
});
Python / Strawberry + FastAPI
# SAFE — depth and complexity limits
import strawberry
from strawberry.extensions import QueryDepthLimiter
schema = strawberry.Schema(
query=Query,
extensions=[
QueryDepthLimiter(max_depth=5),
],
)
7. Injection Checklist
Run these checks on every query, command, file operation, or template render the agent writes or reviews.
7.1 Is User Input Concatenated into a Query String?
Auto-fail. Any instance of string concatenation, template literals, or f-strings building a SQL, NoSQL, LDAP, or XPath query from user input is injectable.
// Auto-fail patterns:
`SELECT * FROM users WHERE name = '${input}'`
"SELECT * FROM users WHERE name = '" + input + "'"
`db.users.find({ $where: "this.name === '${input}'" })`
7.2 Are Query Operators Reachable from User Input?
Can an attacker pass { "$gt": "" } where a string is expected? Check if req.body, req.query, or req.params values are used directly in MongoDB queries without type coercion or schema validation.
// FAIL — req.body.role could be { "$ne": "admin" }
User.find({ role: req.body.role });
// PASS — forced to string
User.find({ role: String(req.body.role) });
// PASS — schema validated with zod
const { role } = schema.parse(req.body);
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 20
- Forks
- 1
- Last commit
- Sep 2026
Advanced
- Item type
- skill
- Key
injection-checker- Source
- github.com/apisec-inc/apisec-skills