Injection Attacks
How a search box, a file name or a log line turns into code: SQL, command, template, NoSQL and log injection — performed safely in your browser, then fixed for real in Go and JavaScript.
The 30-second version
Your app has a search box: "Find a user by name". Someone types bob and gets Bob. Then someone types this:
' OR '1'='1…and the page lists every user in the database. No password was guessed and no server was hacked in the movie sense. The app did exactly what its code told it to. The problem is that part of its code was written by the person typing into the search box.
That's every injection bug in this topic, from SQL to shell commands to web pages. Each time, a program builds a piece of text for an interpreter by gluing its own instructions together with someone else's data. The interpreter then reads the whole thing as instructions, because to it, it's all just one string.
The fix, in one sentence
Send code and data through separate channels, so the interpreter never has to guess which is which. For SQL that's a placeholder like $1 with the value passed separately (§03). For commands it's a list of arguments instead of one string (§08). For HTML it's a template engine or API that treats values as text (§09).
| Interpreter | What gets injected | Section |
|---|---|---|
| SQL database | ' OR '1'='1, '; DROP TABLE users; -- | §02–§07 |
Shell (sh -c) | notes.txt; rm -rf ~ | §08 |
| HTML / template engine | <img src=x onerror=…>, {{.Config.DBPassword}} | §09 |
| MongoDB filter | {"$ne": null} | §10 |
| Log file, HTTP headers | a newline in a user name | §10 |
SQL injection, one quote at a time
The query the app runs
Our search box sends the name to the server, which asks the database with one line of SQL:
SELECT name, email FROM users WHERE name = 'bob'Read it as: "give me the name and email of every row in the users table whose name equals 'bob'". The single quotes around bob make it a string literal: they tell the database where the text value starts and ends. Everything outside the quotes is SQL code; everything inside is data.
The vulnerable app builds that line by gluing strings together:
const sql = "SELECT name, email FROM users WHERE name = '" + name + "'"; // ⚠ the bugThe app writes the opening quote, pastes whatever the user typed, and writes the closing quote. It's assuming the user's text contains no quote of its own.
The attacker's three moves
Almost every SQL injection payload does the same three things:
- Close the string early. Type a
'. The database now thinks the data has ended, and whatever follows is code. - Add your own logic.
OR '1'='1adds a condition that is true for every row. - Deal with the leftover quote. The app still adds its closing
'at the end. Either balance it ('1'='1ends without its last quote, so the app's quote completes it) or switch it off with--, which starts a SQL comment so the rest of the line is ignored.
Put together, ' OR '1'='1 turns the query into:
SELECT name, email FROM users WHERE name = '' OR '1'='1'
-- ^^ empty name ^^^^^^^^^ always truename = '' is false for every row, but '1'='1' is true, and false OR true is true. So every row matches.
Try it
The playground below is a small simulated Postgres (a popular SQL database) with four users. Type into the search box or click a preset. Watch two things: the underlined characters you typed, and whether any of them turn red, meaning the database read them as code rather than as part of a name.
SELECT name, email FROM users WHERE name = 'bob'
| name | |
|---|---|
| bob | bob@example.com |
Things to try:
O'Brien. A real customer, no attacker. The quote in their name ends the string early and the query breaks withsyntax error at or near "Brien". That's the exact error real Postgres 16 gave us. Injection bugs are ordinary bugs too: this customer simply can't be found.' OR is_admin --returns only the admins: the attacker can now ask the database any yes/no question about the rows.' UNION SELECT key, 'leaked' FROM api_keys --reads a different table through the search box. §05 explains how attackers get there.- Now flip the toggle to placeholder $1 and try all of them again. Every input, however nasty, is just a name that nobody has.
The fix: placeholders in Go and JavaScript
$1) where each value goes, and hand the values to the database driver (the library that talks to the database for you) separately. The user's text can then only ever be a value.Here is the search from §02 for real: vulnerable and fixed side by side, against a local Postgres 16 holding the same four users. Go uses the standard database/sql package with the pgx driver; JavaScript uses pg (node-postgres) through a Pool, which is a connection pool: a few open database connections that queries take turns using. If Go is new to you, the five-minute primer in Async / Await §02 covers everything used here.
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" driver
)
// ⚠ VULNERABLE: the name is glued into the SQL text.
func findUserUnsafe(db *sql.DB, name string) ([]string, error) {
query := "SELECT name, email FROM users WHERE name = '" + name + "'"
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
return collect(rows) // reads each row into "name <email>"
}
// ✓ FIXED: the SQL text is constant; the name travels separately as $1.
func findUser(db *sql.DB, name string) ([]string, error) {
rows, err := db.Query("SELECT name, email FROM users WHERE name = $1", name)
if err != nil {
return nil, err
}
defer rows.Close()
return collect(rows)
}What they print
We ran both versions with the same inputs. Go first:
$ go run ./search "bob"
unsafe: ["bob <bob@example.com>"] err=<nil>
safe: ["bob <bob@example.com>"] err=<nil>
$ go run ./search "' OR '1'='1"
unsafe: ["alice <alice@example.com>" "bob <bob@example.com>" "carol <carol@example.com>" "O'Brien <ob@example.com>"] err=<nil>
safe: [] err=<nil>
$ go run ./search "O'Brien"
unsafe: [] err=ERROR: syntax error at or near "Brien" (SQLSTATE 42601)
safe: ["O'Brien <ob@example.com>"] err=<nil>(SQLSTATE 42601 is Postgres's SQLSTATE code for "syntax error".)
And Node, with one extra input: a semicolon followed by a whole second statement.
$ node search.mjs "' OR '1'='1"
unsafe: [ { name: 'alice', email: 'alice@example.com' }, … all 4 users ]
safe: []
$ node search.mjs "'; UPDATE users SET is_admin = true WHERE name = 'bob'; --"
unsafe: undefined
safe: []That last undefined is the scariest line on this page. The unsafe query didn't fail: node-postgres ran both statements, returned an array of two results (so rows was undefined), and afterwards Bob was an admin in our database. The fixed version looked for a user with that whole odd string as their name and found none. §04 shows exactly which calls let a second statement through (in Go, too).
Notice also that the fix makes O'Brien work. Placeholders aren't a security tax on correct code; they make the code correct.
Placeholder spelling by database
The idea is the same everywhere; only the marker changes. In Go's database/sql you write whatever your driver expects.
| Database | Placeholder | Example |
|---|---|---|
| PostgreSQL | $1, $2, … | WHERE name = $1 AND age > $2 |
| MySQL, SQLite | ? | WHERE name = ? AND age > ? |
| SQL Server | @p1, @name | WHERE name = @p1 |
Why placeholders work, and why escaping isn't enough
What actually goes over the network
A database driver (pgx in Go, pg in Node) talks to Postgres using Postgres's wire protocol: a stream of small messages, each starting with a one-letter type. To see them, we put a tiny proxy between Node and our local Postgres 16 that prints every message the client sends. Here is the real capture for the two calls from §03:
--- pool.query(gluedString) (no values array)
Q "SELECT name, email FROM users WHERE name = '' OR '1'='1'"
--- pool.query("… WHERE name = $1", [name]) (with a values array)
P "SELECT name, email FROM users WHERE name = $1" ← Parse: the SQL, with a hole
B "' OR '1'='1" ← Bind: the value, as raw bytes
D ← Describe: "what columns will I get?"
E ← Execute
S ← Sync: "that's the end of this batch"The first call uses the simple query protocol: one Query message with all the text. The second uses the extended query protocol: the SQL goes in a Parse message, and the value goes in a separate Bind message. Step through both:
1const name = "' OR '1'='1"; // from the request2const sql =3 "SELECT name, email FROM users " +4 "WHERE name = '" + name + "'"; // glued5await pool.query(sql);
' OR '1'='1The Bind message carries the value as a length followed by bytes. Postgres doesn't scan it for quotes, because it isn't looking for where the value ends; the length already says. That's why no escaping is needed: there's nothing to escape from.
Postgres's own statement log shows the same split. With log_statement = 'all' switched on, our two calls were logged like this:
LOG: statement: SELECT name, email FROM users WHERE name = '' OR '1'='1'
LOG: execute <unnamed>: SELECT name, email FROM users WHERE name = $1
DETAIL: parameters: $1 = ''' OR ''1''=''1'(The log displays the parameter in quoted form so you can read it. On the wire it was raw bytes.)
Does "; UPDATE …" run? It depends on the call
Remember the Node run in §03, where a second statement smuggled in after ; actually ran? Whether that works (it's called stacked queries) depends on which protocol the call uses. Postgres refuses to Parse two statements at once, so stacking only works when the query goes as a simple Query message. We sent the same glued input, '; UPDATE users SET is_admin = true WHERE name = 'carol'; --, five ways and checked afterwards whether carol had become an admin:
db.Query, no args: err=ERROR: cannot insert multiple commands into a prepared statement (SQLSTATE 42601) carol is admin: false
db.Exec, no args: err=<nil> carol is admin: true
db.Exec, with an arg: err=ERROR: cannot insert multiple commands into a prepared statement (SQLSTATE 42601) carol is admin: false
pool.query(text) err=null carol is admin: true
pool.query(text, values) err=cannot insert multiple commands into a prepared statement carol is admin: false| Call | Protocol it used | Smuggled UPDATE ran? |
|---|---|---|
Go db.Query(glued) | Parse/Bind/Execute (pgx prepares every Query) | no |
Go db.Exec(glued), no args | simple Query | yes |
Go db.Exec(glued, arg) | Parse/Bind/Execute | no |
Node pool.query(glued) | simple Query | yes |
Node pool.query(glued, values) | Parse/Bind/Execute | no |
(SQLSTATE 42601 is the SQLSTATE code for "syntax error".) The dangerous rows are exactly the ones where the glued query has no values at all, which is what glued code usually looks like. In Go, a glued UPDATE or DELETE is normally run with db.Exec, the call that lets stacking through.
The driver won't save you; placeholders will. And even the "no" rows are still badly vulnerable: ' OR '1'='1 and the UNION trick from §05 need only one statement, so a refused second statement just means the attacker uses the first. Driver settings (pgx has a "simple protocol" mode) and other databases change the table again. The only fix that doesn't depend on any of this is a constant SQL text with the value in $1.
"Can't I just escape the quotes?"
Escaping means rewriting the user's text so that every special character is marked as "just a character": in Postgres, a ' inside a string is written as ''. It's what placeholders make unnecessary, and people still reach for it. It goes wrong in three ordinary ways. We ran the first two on our Postgres 16:
Wrong rules for this database
MySQL-style escaping writes \'. In Postgres (with the default standard_conforming_strings = on), a backslash is just a backslash, so \' is a backslash followed by a string-ending quote. Input ' OR 1=1 -- escaped this way still returned all 4 users.
No quotes to escape
"… WHERE id = " + escape(id) with id = "1 OR 1=1". There's no quote anywhere, so escaping changes nothing, and again all 4 users came back. Numbers, column names and LIMIT values aren't inside quotes.
Forgetting once
Escaping must happen at every query, by every developer, forever. One missed call, or one value escaped twice, or escaped for HTML instead of SQL, and you're back to §02. A placeholder is the default path, not an extra step.
const naiveEscape = (s) => s.replaceAll("'", "\\'"); // ⚠ MySQL style, wrong for Postgres
`SELECT name FROM users WHERE name = '${naiveEscape("' OR 1=1 --")}'`
// → SELECT name FROM users WHERE name = '\' OR 1=1 --' → [ 'alice', 'bob', 'carol', "O'Brien" ]
`SELECT name FROM users WHERE id = ${naiveEscape("1 OR 1=1")}`
// → SELECT name FROM users WHERE id = 1 OR 1=1 → [ 'alice', 'bob', 'carol', "O'Brien" ]What an attacker does next: UNION and blind injection
It's worth seeing why, so you never rank an injection bug as "low, it's only the search page". Everything below ran against our own local database: the UNION examples through the vulnerable Go search from §03, the blind and timing examples through a small vulnerable Node check shown below.
Reading other tables with UNION
UNION glues the rows of a second SELECT onto the first. The search query returns two text columns (name, email), so an attacker who can add SQL just needs a second SELECT that also returns two text columns:
unsafe: ["sk_test_bob_9Xk4 <leaked>" "sk_test_alice_7Hq2 <leaked>"] err=<nil>
safe: [] err=<nil>The search page just displayed API keys from a table it was never meant to touch. (You can do the same in the §02 playground.)
How did the attacker know there were two columns, both text, and a table called api_keys? By reading your error messages. Each wrong guess comes back with a precise answer from Postgres. These are real:
' UNION SELECT key FROM api_keys -- → ERROR: each UNION query must have the same number of columns
' UNION SELECT user_id, key FROM api_keys -- → ERROR: UNION types text and integer cannot be matched
' UNION SELECT key, 'x' FROM secrets -- → ERROR: relation "secrets" does not existThat's one reason never to send database errors to the client: log them, and return a generic message with a request id. (Verbose errors are a weakness of their own.) Postgres also keeps a built-in list of every table (information_schema.tables), readable by any login, and the same UNION trick can read it, so hiding table names isn't a real defence. The fix is still a placeholder.
Blind injection: twenty questions
Now make it harder. Suppose the vulnerable query only powers a "does this user exist?" check that shows exists or missing. No rows, no errors. That's blind injection, and it still leaks everything, one yes/no question at a time:
const r = await pool.query(`SELECT 1 FROM users WHERE name = '${name}'`); // ⚠
return r.rowCount > 0 ? "exists" : "missing";The attacker sends bob (who exists) followed by AND and a question. If the question is true, the page says exists; if false, missing. Real runs:
bob → exists 1.8 ms
bob' AND (SELECT is_admin FROM users WHERE name = 'alice') -- → exists 0.5 ms (alice is an admin)
bob' AND (SELECT is_admin FROM users WHERE name = 'carol') -- → missing 0.3 ms (carol isn't)To steal a whole value, the script asks about one character at a time, and it halves the possibilities with each question (the same trick as guessing a number between 1 and 100 by asking "bigger than 50?"). ascii(substring(key, 5, 1)) means "the character code of the 5th character of key". Run the script:
— press “ask one question” —
About 7 questions per character, so a few hundred requests for a long secret. A script does that in well under a second.
And if the page shows the same thing either way? Then the attacker makes the response time the answer: "if the first character's code is above 100, sleep 2 seconds". Two new SQL pieces do that: CASE WHEN condition THEN a ELSE b END is SQL's if/else expression, and pg_sleep(2) makes Postgres wait 2 seconds. Our real run:
bob' AND (SELECT CASE WHEN ascii(substring(key, 1, 1)) > 100
THEN (SELECT 1 FROM pg_sleep(2)) ELSE 1 END
FROM api_keys WHERE user_id = 1) = 1 -- → exists 2005.0 ms (yes: 's' is 115)Where a $1 can't go: names, sort order, IN lists, LIKE
DESC, can't be a placeholder. For those, let the user choose from options you wrote, and never paste their text.This is where most real-world SQL injection lives today: in the sort and filter code that someone wrote with + because $1 "didn't work there". Click each highlighted part:
SELECT FROM
WHERE name =
AND id
AND name LIKE
ORDER BY
LIMIT
Table and column names are part of the query's shape, which Postgres fixes at Parse time, before any values arrive.
Safe pattern: Map the user's choice to a table name you wrote (allowlist). If names are truly dynamic, quote them with pgx.Identifier{…}.Sanitize() or pg's escapeIdentifier(), after validating.
The pattern behind most of those fixes is an allowlist: a fixed map from what the API accepts to SQL you wrote yourself. The user's string is only ever used as a lookup key. It never reaches the query.
Sorting safely
A typical endpoint is GET /users?sort=email&dir=desc. Table and column names are identifiers, and ASC/DESC are keywords, so neither can be $1:
// The only columns a user may sort by. Keys are what the API accepts;
// values are SQL we wrote ourselves.
var sortColumns = map[string]string{
"name": "name",
"email": "email",
"id": "id",
}
func listUsers(db *sql.DB, sortBy, dir string) (*sql.Rows, error) {
col, ok := sortColumns[sortBy] // ← look it up; never paste sortBy itself
if !ok {
return nil, fmt.Errorf("invalid sort column %q", sortBy)
}
order := "ASC"
if dir == "desc" {
order = "DESC"
}
// Every piece of this string is one of OUR constants.
return db.Query("SELECT name FROM users ORDER BY " + col + " " + order)
}listUsers("email", "desc") → [O'Brien carol bob alice]
listUsers("name; DROP TABLE users", "asc") → invalid sort column "name; DROP TABLE users"Yes, that's string concatenation in SQL, after a whole page saying not to. The difference is what gets concatenated: only strings that appear literally in your source code. A reviewer can check every possible query by reading the map.
If table or column names really must come from data (say, a generic admin tool), validate them against the database's own list of tables first, then quote them with the driver's identifier escaper: pgx.Identifier{"audit", "events 2026"}.Sanitize() gives "audit"."events 2026" in Go, and pg.escapeIdentifier("events 2026") gives "events 2026" in Node. The double quotes are how SQL quotes a name, the way single quotes quote a value.
IN lists: one parameter, one array
WHERE id IN ($1) with "1,2" fails, because one placeholder is one value: Postgres tried to read "1,2" as a single integer (invalid input syntax for type integer: "1,2"). In Postgres, pass the whole list as one array and use = ANY($1):
rows, err := db.Query("SELECT name FROM users WHERE id = ANY($1)", []int{1, 3})
// → [alice carol]For MySQL or SQLite, which don't have arrays, generate one ? per item (IN (?, ?, ?)) and pass the items as separate values. The number of ? comes from your code; the values still travel separately.
LIKE: safe from injection, not from wildcards
WHERE name LIKE '%' || $1 || '%' is safe from SQL injection: $1 is a value, and || glues strings together inside the database. But inside a LIKE pattern, % and _ are wildcards. A user who searches for _ (meaning "names containing an underscore") gets every row, because _ matches any single character. Patterns full of % can also be slow to match. Escape them before adding your own %:
// Makes \ % and _ match literally. Postgres's LIKE uses \ as its escape character.
func escapeLike(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
db.QueryRow("SELECT count(*) FROM users WHERE name LIKE '%' || $1 || '%'", escapeLike(q))
// q = "_" → 0 (without escaping: 4)ORMs, raw-query escape hatches and second-order injection
ORMs: safe by default, with sharp corners
(If ORMs are new, N+1 Queries §03 introduces them.) Here's GORM, the most popular Go ORM, against our local database, with its SQL logging switched on. Each call is followed by the SQL it logged and the rows it got back:
input := "' OR '1'='1"
sortParam := r.URL.Query().Get("sort") // the attacker sends the CASE expression shown in the log below
db.Where("name = ?", input).Find(&a) // ✓ ? is a placeholder
db.Where("name = '" + input + "'").Find(&b) // ⚠ string glued into the condition
db.Order(sortParam).Find(&c) // ⚠ Order() takes raw SQL[rows:0] SELECT * FROM "users" WHERE name = ''' OR ''1''=''1'
[rows:4] SELECT * FROM "users" WHERE name = '' OR '1'='1'
[rows:4] SELECT * FROM "users" ORDER BY (CASE WHEN (SELECT is_admin FROM users WHERE name = 'alice') THEN name ELSE email END)Where("name = ?", input)is safe. The log shows the value quoted for readability, but it was sent as a separate parameter: 0 rows.Where("name = '" + input + "'")is the §02 bug wearing an ORM costume: 4 rows.Order(sortParam)is the sneaky one.Orderexpects a piece of SQL, because sort clauses can't be placeholders (§06). Pass it a user's?sort=and they can write aCASEexpression that sorts one way if a secret condition is true and another way if not: a blind-injection yes/no question (§05) asked through the sort order. Use the §06 allowlist.
JavaScript ORMs and query builders have the same split. Prisma's documentation describes two raw-query methods: prisma.$queryRaw`… ${x}` sends x as a parameter, while prisma.$queryRawUnsafe(string) runs whatever string you give it (and so do $executeRawUnsafe and SQL built with Prisma.raw()). Knex's whereRaw("name = ?", [x]) is safe; whereRaw(`name = '${x}'`) isn't. The rule doesn't change: the method with "raw" or "unsafe" in its name must receive a constant string plus values.
Tagged templates: the same ${}, the opposite meaning
Some JS libraries make the safe way the only way, with a trick worth understanding. Here is Postgres.js (the postgres package on npm), which we ran against the same database:
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL);
const name = "' OR '1'='1";
await sql`SELECT name FROM users WHERE name = ${name}`; // ✓ 0 rows
await sql.unsafe(`SELECT name FROM users WHERE name = '${name}'`); // ⚠ 4 rowsThe first line looks like the vulnerable template literal from §03, but it starts with sql right before the backtick. That makes it a tagged template: JavaScript doesn't build a string at all. It calls the function sql with the fixed text pieces and the values as separate arguments:
function show(strings, ...values) { return { strings, values }; }
show`SELECT name FROM users WHERE name = ${name} AND id > ${7}`;
// → { strings: [ 'SELECT name FROM users WHERE name = ', ' AND id > ', '' ],
// values: [ "' OR '1'='1", 7 ] }The library receives your SQL and the user's values in different arrays, so it can send them as $1, $2 (that's what happened in our run). The second line, sql.unsafe(…), takes a plain string that was built before the call, so the library never sees where the values were.
Second-order injection
Here is a bug that passes every code review of the sign-up form. A new user registers with the name mallory' OR '1'='1. The sign-up code uses a placeholder, so the name is stored correctly, quote and all. Weeks later, a "log this user out everywhere" job reads the name back and builds a DELETE:
// Step 1: sign-up. Parameterized, so the odd name is stored as plain text. ✓
await db.query("INSERT INTO users (id, name, email) VALUES (5, $1, $2)", [evilName, email]);
// Step 2: weeks later, a cleanup job reads the name back…
const { rows: [user] } = await db.query("SELECT name FROM users WHERE id = 5");
// …and trusts it, because "it came from our own database". ⚠
const r = await db.query(`DELETE FROM sessions WHERE user_name = '${user.name}'`);
console.log("sessions deleted:", r.rowCount);sessions deleted: 4Every user was logged out, because the WHERE became user_name = 'mallory' OR '1'='1'. This is second-order injection: the input was handled safely on the way in, and unsafely on the way out.
mallory' OR '1'='1 is saved with $1. Safe: it's stored as plain text.DELETE FROM sessions WHERE user_name = '…', because "it came from our own DB".Command injection: when a shell reads your string
;, $( ), |, > and spaces as instructions. Pass the program a list of arguments instead, and no shell ever sees the input.How a shell reads a line
When you type wc -l notes.txt in a terminal, the shell splits the line into words at spaces, and starts the program wc with an argument list of ["wc", "-l", "notes.txt"]. The program itself never sees the original line. But the shell does far more than split at spaces: ; ends one command and starts another, $(…) runs a command and pastes its output in, > redirects output into a file, * expands to matching file names.
Our server feature counts the lines in a file the user names. Type a file name and watch how sh would read it:
wc -l notes.txt; echo INJECTED
Vulnerable and fixed, for real
import "os/exec"
// ⚠ VULNERABLE: builds one string and hands it to a shell.
func countLinesUnsafe(name string) (string, error) {
out, err := exec.Command("sh", "-c", "wc -l "+name).CombinedOutput()
return string(out), err
}
// ✓ FIXED: no shell; the name is one argument; "--" ends the options.
func countLines(name string) (string, error) {
out, err := exec.Command("wc", "-l", "--", name).CombinedOutput()
return string(out), err
}We ran both in a folder holding notes.txt (3 lines), my notes.txt (2 lines) and secret.txt:
## notes.txt; echo INJECTED
unsafe: "3 notes.txt\nINJECTED\n"
safe: "wc: 'notes.txt; echo INJECTED': No such file or directory\n"
## notes.txt $(whoami)
unsafe: "3 notes.txt\nwc: root: No such file or directory\n3 total\n"
safe: "wc: 'notes.txt $(whoami)': No such file or directory\n"
## my notes.txt
unsafe: "wc: my: No such file or directory\n3 notes.txt\n3 total\n"
safe: "2 my notes.txt\n"echo INJECTEDran. It could have been any command the server's user is allowed to run.$(whoami)ranwhoami, and its output (root) became an extra argument.- The Node version behaved the same way (when a command fails, its promise rejects and the message is in
err.stderr). - And again the fix also fixes a plain bug: a file name with a space only works in the safe version.
Argument injection: no shell, still a bug
Removing the shell stops new commands. It doesn't stop the program itself from misreading an argument. Programs treat arguments that start with - as options. A user who names their "file" --files0-from=secret.txt is passing wc an option that means "read the list of file names from secret.txt". We ran it:
$ wc -l "--files0-from=secret.txt" # no shell tricks at all
wc: 'top secret'$'\n': No such file or directory ← the secret file's contents, in an error
$ wc -l -- "--files0-from=secret.txt" # with --
wc: '--files0-from=secret.txt': No such file or directoryThat's argument injection. The fix is the "--" in our safe versions: by a convention almost every Unix tool follows, a bare -- means "no more options; everything after this is a plain argument". Some tools have their own rules (for git, check its docs for the command you call), so also validate that names look like names: no leading -, no / if you expect a bare file name. In the widget above, switch to execFile(args) and untick -- to see the difference.
Template injection and HTML injection
Shape 1: data inside a template
A web page is code for the browser. If a user's display name is pasted into HTML as-is, a name like <script>…</script> becomes part of the page's code, and runs in every visitor's browser. The fix is output encoding: turning < into the HTML entity < so the browser shows it instead of parsing it.
The catch is that "the right encoding" depends on where in the page the value lands. Go ships two template packages with identical syntax: text/template, which pastes values unchanged (it's meant for plain text like emails and config files), and html/template, which does contextual escaping: it works out, for each {{.}}, whether it's in text, an attribute, a URL or a script, and encodes for that place.
import (
htmltpl "html/template"
texttpl "text/template"
)
const page = `<p>Hello, {{.}}</p>
<a title="{{.}}">profile</a>
<a href="/search?q={{.}}">search</a>
<a href="{{.}}">website</a>
<script>const name = {{.}};</script>`
texttpl.Must(texttpl.New("p").Parse(page)).Execute(os.Stdout, input) // ⚠ pastes as-is
htmltpl.Must(htmltpl.New("p").Parse(page)).Execute(os.Stdout, input) // ✓ escapes per contextPick an input and flip between the two packages. Every line is real output from Go 1.24:
Look at the javascript:alert(1) input in the whole URL row: html/template didn't just encode it, it replaced it with #ZgotmplZ, a deliberately meaningless marker. A link whose target is javascript: code runs that code when clicked, and no amount of encoding fixes that, so html/template refuses the value. If you see ZgotmplZ in your HTML, some value failed this check.
The same bug in the browser: innerHTML
Front-end JavaScript has the same choice. element.innerHTML = "…" hands a string to the browser's HTML parser; element.textContent = "…" sets plain text. A template literal (`<p>Hello, ${name}</p>`) is gluing, not a template engine: it does no escaping at all.
out.innerHTML =
`<p>Hello, ${name}</p>`; // ⚠ parsed as HTMLTry the last preset: no script at all, just a fake "session expired" password form that posts to another site. HTML injection is dangerous even when scripts are blocked. The full story of XSS (stored, reflected and DOM-based XSS, frameworks' auto-escaping, and a Content Security Policy as a second layer) is in OWASP Top 10. The injection lesson is the same as everywhere else: use the API that takes text (textContent, setAttribute, a framework's {name} in JSX) and keep innerHTML for strings your own code built.
Shape 2: the user's text is the template
Some apps let users customise messages: "Write your own welcome email, use {name} for the customer's name". The tempting implementation feeds the user's text straight into the template engine. Now the user is writing template code, and template code can read whatever data you pass in:
import (
"html/template" // this file uses only html/template
"io"
"strings"
)
type Page struct {
Name string
Config *Config // ← handy for our own templates, dangerous in a user's
}
// ⚠ VULNERABLE: the user's text IS the template.
func renderGreetingUnsafe(w io.Writer, userTemplate string, p Page) error {
t, err := template.New("greeting").Parse(userTemplate)
if err != nil {
return err
}
return t.Execute(w, p)
}
// ✓ FIXED: the user's text is data; only our placeholders get replaced.
func renderGreeting(w io.Writer, userTemplate string, p Page) error {
r := strings.NewReplacer("{name}", template.HTMLEscapeString(p.Name))
_, err := io.WriteString(w, r.Replace(template.HTMLEscapeString(userTemplate)))
return err
}template: Welcome back, {{.Name}}! (Go)
Welcome back, Bob!
Welcome back, {{.Name}}!
template: Hi {{.Config.DBPassword}} (Go)
Hi hunter2-local-demo ← a field the user was never meant to see
Hi {{.Config.DBPassword}}
template: Hi ${process.env.DB_PASSWORD} (Node, DB_PASSWORD set in the environment)
Hi hunter2-local-demo ← any JavaScript at all runs here
Hi ${process.env.DB_PASSWORD}Go's templates can only read fields and call methods of the data you passed in, call functions you registered with Funcs (a FuncMap), and use a few built-ins (call, index, printf, len…). So the damage is limited to what's reachable from your data and your registered functions, which, as above, may be a lot. Engines that compile templates into JavaScript or Python code, such as EJS, Pug, lodash's _.template or Jinja2, can give a user-written template a path to running arbitrary code on your server. Never let users write templates for a full template engine. Offer named placeholders that you replace yourself, as in the fixed versions, or use a deliberately limited, sandboxed language.
NoSQL, logs, headers: the same bug elsewhere
NoSQL operator injection
A MongoDB query is not a string; it's an object. users.findOne({ resetToken: token }) means "find a user whose resetToken equals token". There's no quote to break out of, so it looks immune. But MongoDB filters have query operators: keys starting with $, like { $ne: null } ("not equal to null") or { $gt: "" } ("greater than the empty string").
Now look at a password-reset endpoint that reads a JSON body. JSON.parse happily produces objects, not only strings:
const body = JSON.parse(`{"token": {"$ne": null}, "newPassword": "pwned"}`);
console.log(body.token, typeof body.token);
// → { '$ne': null } objectSo findOne({ resetToken: body.token }) becomes findOne({ resetToken: { $ne: null } }): "find any user with a reset token". The attacker didn't need to know a token at all. Try it (the matcher below is a small simulation of MongoDB's rules, running in your browser):
const { token } = JSON.parse(body);
const user = await users.findOne(
{ resetToken: token }); // ⚠{"resetToken":{"$ne":null}}findOne returned the first user it matched: bob@example.com. The attacker just set their password without knowing any token.Both fixes work, and they're worth combining:
- Check the type. A token is a string.
if (typeof token !== "string") return badRequest(), or better, validate the whole body with a schema library such as zod or ajv (that's Input validation). - Use
$eq.{ resetToken: { $eq: token } }tells MongoDB "compare for equality, whatever this value is". An object passed to$eqis compared as a literal value, not read as operators, and no stored token equals an object.
Go gets the first fix almost for free, if you decode into a typed struct:
type ResetRequest struct {
Token string `json:"token"` // ← a string, and only a string
NewPassword string `json:"newPassword"`
}
body := []byte(`{"token": {"$ne": null}, "newPassword": "pwned"}`)
var loose map[string]any
json.Unmarshal(body, &loose) // loose["token"] → map[string]interface {}{"$ne":interface {}(nil)} ⚠
var req ResetRequest
err := json.Unmarshal(body, &req)
// err → json: cannot unmarshal object into Go struct field ResetRequest.token of type string ✓The object doesn't have to come from JSON. Some query-string parsers (the qs library behind Express 4's default req.query, for example) turn ?token[$ne]=x into { token: { $ne: "x" } }. Express 5 changed its default to a simple parser that doesn't do this, and Node's built-in URLSearchParams doesn't either: there, token[$ne] is just a strange key name.
Log injection
Logs are read by people and by tools, and both split them into lines. If a user name containing a newline is written into a plain-text log, the attacker can add a fake line to your log. That's log forging. We logged a failed login for this "user name":
bob\n2026/10/30 12:00:01 login ok user=adminlog.Printf("login failed user=%s", user) // ⚠ newline goes straight into the log
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Warn("login failed", "user", user) // ✓ a field; the newline is escaped2026/10/30 17:20:24 login failed user=bob
2026/10/30 12:00:01 login ok user=admin ← forged: never happened
{"time":"2026-10-30T17:20:24.333799285Z","level":"WARN","msg":"login failed","user":"bob\n2026/10/30 12:00:01 login ok user=admin"}The fix is Structured logging: values are fields in a JSON object, and JSON encodes the newline as the two characters \n, so it can't start a new record. Observability §03 covers structured logs properly. (Also watch for terminal escape sequences in values: a log viewer that renders colours can be tricked into hiding or rewriting text.)
Header injection (CRLF)
HTTP headers are lines of text, each ended by CRLF (\r\n). If a user's value is copied into a response header and contains \r\n, it could start a new header, say Set-Cookie: admin=1. We tried exactly that against both runtimes, with ?lang=en%0d%0aSet-Cookie:%20admin=1 copied into a Content-Language header:
Go net/http: Content-Language: en Set-Cookie: admin=1 ← CR and LF replaced with spaces; still one header
Node http: ERR_INVALID_CHAR - Invalid character in header content ["Content-Language"]Modern Go and Node both refuse to send a raw newline in a header. You're still exposed wherever you write protocol text by hand: building raw email messages (a Subject: containing \r\nBcc: everyone@…), writing to a raw socket, generating CSV files, or using an old framework. Reject \r and \n in any value headed for a header.
LDAP, XPath and the rest
The pattern repeats for every query language. An LDAP login that builds the filter (&(uid= + user + )(password=…)) lets a user named *)(uid=*))(|(uid=* rewrite the filter. XPath queries over XML documents have the same problem. The fixes are the familiar ones: the library's escaping function for that language (the go-ldap package has ldap.EscapeFilter), or better, an API that takes values separately.
Staff-level: layers, review and tooling
That's Defense in depth: independent layers, so one mistake isn't a breach. Here they are in rough order of value.
1. Least privilege for the database login
An injected query runs with exactly the permissions of your app's database login. If that login is the database owner or a superuser (it often is, because that's what the tutorial used), one injectable search box can read every table, change any row and drop anything.
Give each service a database role with only what it needs. Here's a role for the search feature that may read two columns of one table, and the still vulnerable §03 search code running as that role, on our Postgres 16:
CREATE ROLE search_app LOGIN PASSWORD '…';
GRANT SELECT (name, email) ON users TO search_app; -- two columns, read-only' OR '1'='1 → 4 rows
'; UPDATE users SET is_admin = true WHERE name = 'bob'; -- → ERROR: permission denied for table users
' UNION SELECT key, 'leaked' FROM api_keys -- → ERROR: permission denied for table api_keysThe bug is still there (the first line still dumps the user list), but the attacker can't make themselves admin or read the API keys. That's Least privilege doing its job: turning a breach into an incident.
2. Validation as a second layer
Input validation checks that each input has the expected type, format and range: an id is a positive integer, a sort field is one of three names, a token is 32 hex characters. Reject everything else at the edge of your API.
It's a good second layer, because many payloads simply don't look like a valid id or token. But it can't be the first layer: valid data contains quotes (O'Brien), semicolons (a street address) and dashes (any free-text field). And "validation" that tries to strip dangerous characters (a Blocklist) always misses something. Validate to reject what's malformed; use parameters to make everything else harmless.
3. What a WAF can and can't do
A WAF sits in front of your app and blocks requests that match known attack patterns. It's useful as an early warning, and as a stop-gap ("virtual patch") while a real fix ships. But it guesses from the outside:
- SQL has endless spellings for "always true":
' OR '1'='1,' or 2>1 --,'||', comments in place of spaces ('/**/OR/**/1=1), and many more. Pattern rules miss some and block real customers named O'Brien. - It never sees second-order payloads (they come out of your own database), values arriving through queues or batch jobs, or a JSON body whose danger is its type (
{"$ne": null}).
Treat it as a smoke detector, not a fire door.
4. Find it with tools: static analysis
SAST tools read your code without running it. The good ones use taint analysis: follow data from where it enters (os.Args, r.URL.Query()) to where it's dangerous (db.Query, exec.Command). We ran gosec v2.30.0 over this topic's Go examples. (Each finding names a CWE: a standard number for a kind of weakness. CWE-89 is SQL injection, CWE-78 is OS command injection.)
search/main.go:14 - G202 (CWE-89): SQL string concatenation (Confidence: HIGH, Severity: MEDIUM)
search/main.go:15 - G701 (CWE-89): SQL injection via taint analysis: (*database/sql.DB).Query (Confidence: HIGH, Severity: HIGH)
cmdinj/main.go:11 - G702 (CWE-78): Command injection via taint analysis: os/exec.Command (Confidence: HIGH, Severity: HIGH)
cmdinj/main.go:17 - G702 (CWE-78): Command injection via taint analysis: os/exec.Command (Confidence: HIGH, Severity: HIGH)
cmdinj/main.go:17 - G204 (CWE-78): Subprocess launched with variable (Confidence: HIGH, Severity: MEDIUM)It caught both vulnerable functions, and it did not flag the parameterized findUser. It did flag our fixed exec.Command("wc", "-l", "--", name) on line 17: it can see user data reaching a subprocess, but not that -- and the missing shell make it safe. That's normal. Review the finding, and if it's safe, record why right in the code so the next reader knows: // #nosec G702 G204 -- no shell; "--" ends options (we checked: gosec then reports it as suppressed).
JavaScript's options are weaker. We ran ESLint 10 with eslint-plugin-security 4.2.1 over the JS examples, and the results are a fair warning about pattern-matching linters:
- It did flag
exec(`wc -l ${name}`)when called directly (security/detect-child-process). - It missed the same
execonce it was wrapped inpromisify(exec), and it said nothing about the template-literal SQL infindUserUnsafe. - It flagged our safe allowlist lookup (
allowed[key]afterObject.hasOwn) as a possible "object injection sink".
Taint-tracking tools such as Semgrep and CodeQL follow data through wrappers better (we didn't run them here). Whatever you use, run it in CI on every pull request and treat it as a reviewer's assistant, not a guarantee.
5. Find it by reading: a code review checklist
Most injection bugs are visible in a diff if you know what to look for. Search for these and ask "can any part of this string come from outside?":
| Look for | In |
|---|---|
+, fmt.Sprintf, `${…}` building SQL; Query(sql) with no values | Go, JS |
Raw(, Exec(, Order(, $queryRawUnsafe, sql.unsafe, whereRaw( | ORMs, query builders |
exec(, shell: true, "sh", "-c", "bash", "-c" | subprocesses |
user values in exec.Command/execFile args without -- | argument injection |
text/template in a web handler; template.HTML(; innerHTML =; new Function(, eval( | templates, HTML |
findOne(req.body…), filters built from map[string]any | MongoDB |
6. Test and watch
- Test with nasty input. One test per query with
O'Brien,' OR '1'='1and a{"$ne": null}body. They pass for free with parameters, and fail loudly the day someone "simplifies" a query. - Watch for probing. A burst of SQL syntax errors (SQLSTATE
42601) from your app's own role means either a bug or someone exploring your queries. Both are worth an alert. Log the details server-side and return a generic error (§05).
Pitfalls gallery
Parameters for the WHERE, glue for everything else
The value is a placeholder, but the sort column, table name or LIMIT is pasted in "because $1 didn't work there". That leftover concatenation is where the injection lives (§06).
pool.query(`SELECT * FROM orders WHERE user_id = $1
ORDER BY ${req.query.sort}`, [userId]);const SORTS = new Map([["newest", "created_at DESC"], ["total", "total DESC"]]);
const col = SORTS.get(req.query.sort) ?? "created_at DESC";
pool.query(`SELECT * FROM orders WHERE user_id = $1 ORDER BY ${col}`, [userId]);(SORTS.get(key) returns undefined for any key you didn't put in the Map, and ?? means "use the right side if the left is null or undefined", so unknown input falls back to the default.)
fmt.Sprintf is concatenation in a suit
fmt.Sprintf("… WHERE name = '%s'", name) looks like a placeholder, but %s is filled in by Go, before the database sees anything. Same for `…${name}…` in JS and "…" + name. Only the database driver's placeholders ($1, ?) keep the value separate.
db.Query(fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", name))db.Query("SELECT * FROM users WHERE name = $1", name)Escaping by hand
replaceAll("'", "\\'") is wrong for Postgres, and any escaping does nothing for a number or a column name (§04). Use placeholders, or the driver's own escaper where placeholders can't go.
Trusting your own database
A value that was stored safely is still user data when you read it back. Glue it into the next query and you have second-order injection (§07).
The ORM's raw door
db.Raw(…), db.Order(…), $queryRawUnsafe, sql.unsafe, whereRaw all take SQL text. Feed them constants and placeholders only (§07).
A shell you didn't notice
exec(…) in Node, { shell: true }, or exec.Command("sh", "-c", …) in Go. And without a shell, user arguments that start with - are still read as options: put -- first (§08).
spawn("grep", ["-n", pattern, file], { shell: true });spawn("grep", ["-n", "--", pattern, file]);(Check each tool's docs for how it handles --; a library call instead of a subprocess is better still.)
Turning the escaping off
template.HTML(userInput) in Go, innerHTML = …, dangerouslySetInnerHTML in React, or text/template for a web page. Each one tells the system "this is already safe code". Only say that about strings your own code produced (§09).
Decoding JSON into 'anything'
map[string]any in Go or a raw req.body in JS lets {"$ne": null} reach a MongoDB filter. Decode into a typed struct, or check typeof x === "string", or validate with a schema (§10).
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
users.FindOne(ctx, bson.M{"resetToken": body["token"]})var body struct{ Token string `json:"token"` }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { … }
users.FindOne(ctx, bson.M{"resetToken": body.Token})(bson.M is the MongoDB Go driver's map type for writing filters.)
Check yourself
Predict the answer before you click. Q1–Q2 are covered in §03–§04, Q3 in §06, Q4–Q5 in §08, Q6 in §07 and Q7 in §10.
id is a string read from the URL.
// a) db.Query("SELECT * FROM users WHERE id = " + id)
// b) db.Query(fmt.Sprintf("SELECT * FROM users WHERE id = '%s'", id))
// c) db.Query("SELECT * FROM users WHERE id = $1", id)
// d) db.Query("SELECT * FROM users WHERE id = '" + strings.ReplaceAll(id, "'", "''") + "'")The search uses WHERE name = $1. A user types ' OR '1'='1. The table has alice, bob and carol.
const sort = url.searchParams.get("sort"); // e.g. "name"
await pool.query("SELECT * FROM users ORDER BY $1", [sort]);import { exec, execFile, spawn } from "node:child_process";
// a) exec(`wc -l ${name}`)
// b) execFile("wc", ["-l", "--", name])
// c) spawn("wc", ["-l", "--", name], { shell: true })
// d) spawn("wc", ["-l", "--", name])// name comes from the user: "--files0-from=secret.txt"
out, err := exec.Command("wc", "-l", name).CombinedOutput()Sign-up stores the user's name with a placeholder. A reporting job later runs "... WHERE owner = '" + row.name + "'" with a name it read from the database. Is that safe?
type ResetRequest struct {
Token string `json:"token"`
}
var req ResetRequest
err := json.Unmarshal([]byte(`{"token": {"$ne": null}}`), &req)
fmt.Println(err)Cheat sheet
db.Query("… WHERE name = $1", name) / pool.query("… $1", [name]). Values travel in their own message and never meet the parser.?sort= to column names you wrote; DESC comes from your code.= ANY($1) with one array. Escape \ % _ before adding your own %.exec.Command("wc", "-l", "--", name) / execFile("wc", ["-l", "--", name]). Never exec(string) or shell: true with input.html/template, textContent. A user's text is never parsed as a template; offer {name} placeholders you replace.typeof x === "string" or a schema in JS; $eq for equality.O'Brien.| Interpreter | ⚠ Vulnerable | ✓ Safe |
|---|---|---|
| SQL value | "… = '" + x + "'", fmt.Sprintf, `${x}` | $1 / ? with values passed separately |
| SQL identifier / sort | ORDER BY + x | allowlist map → your own string |
| ORM | Where("a = " + x), Order(x), $queryRawUnsafe | Where("a = ?", x), $queryRaw`…${x}` |
| Shell | exec(`cmd ${x}`), sh -c, shell: true | execFile / exec.Command with args, -- first |
| HTML | innerHTML, text/template, template.HTML(x) | textContent, html/template, JSX {x} |
| Template | Parse(userText), new Function(userText) | fixed templates; replace {name} yourself |
| MongoDB | findOne({ token: body.token }) | type check, typed struct, or { $eq: token } |
| Logs | "user=" + x in plain-text logs | structured (JSON) fields |
| Headers / email | raw \r\n in values | framework APIs; reject CR/LF |
When reviewing code, ask one question about every string that reaches an interpreter: "could any part of this have been typed by someone else?" If yes, it must travel as data.