Skip to main content

CSRF & Injection Attacks

Cross-Site Request Forgery (CSRF) and injection attacks exploit different trust boundaries. CSRF abuses the browser's automatic cookie-sending behavior. Injection abuses the server's trust in user-supplied data. Both are preventable with well-known patterns.

Cross-Site Request Forgery (CSRF)

How It Works

When a user is logged into bank.com, their browser automatically sends cookies with every request to that domain. An attacker can trick the user into making a request they did not intend:

<!-- Attacker's page at evil.com -->
<!-- The victim visits this page while logged into bank.com -->
<form actio="https://bank.com/transfer" metho="POST" i="csrf-form">
  <input typ="hidden" nam="to" valu="attacker" />
  <input typ="hidden" nam="amount" valu="10000" />
</form>
<script>document.getElementById("csrf-form").submit();</script>

<!-- The browser sends the victim's bank.com cookies automatically. -->
<!-- The server sees a valid session and processes the transfer. -->

Prevention: CSRF Tokens

Include a unique, unpredictable token in every state-changing form. The server validates it before processing the request.

import crypto from "crypto";

// Generate a CSRF token and store it in the session
function generateCsrfToken(session: SessionData): string {
  const token = crypto.randomBytes(32).toString("hex");
  session.csrfToken = token;
  return token;
}

// Validate the token on form submission
function validateCsrfToken(session: SessionData, token: string): boolean {
  if (!session.csrfToken || !token) return false;
  return crypto.timingSafeEqual(
    Buffer.from(session.csrfToken),
    Buffer.from(token)
  );
}
<!-- Include the token in the form -->
<form action="/transfer" method="POST">
  <input type="hidden" name="_csrf" value="{{csrfToken}}" />
  <input type="text" name="to" />
  <input type="number" name="amount" />
  <button type="submit">Transfer</button>
</form>

Prevention: SameSite Cookies

The SameSite attribute tells the browser when to include cookies in cross-origin requests.

// SECURE: SameSite=Strict prevents the cookie from being sent on ANY cross-site request
const headers = new Headers();
headers.append(
  "Set-Cookie",
  "session=abc123; HttpOnly; Secure; SameSite=Strict; Path=/"
);

// SameSite=Lax (default in modern browsers) allows cookies on top-level
// navigations (GET requests) but blocks them on cross-origin POST requests.
// This stops the CSRF form attack above.
SameSite ValueCross-Site GETCross-Site POSTBest For
StrictBlockedBlockedBanking, sensitive actions
LaxAllowedBlockedGeneral-purpose sessions
NoneAllowedAllowedCross-site APIs (requires Secure)

Prevention: Verify Origin Headers

// Check the Origin or Referer header matches your domain
function verifyCsrfOrigin(request: Request, allowedOrigin: string): boolean {
  const origin = request.headers.get("origin");
  const referer = request.headers.get("referer");

  if (origin) return origin === allowedOrigin;
  if (referer) return referer.startsWith(allowedOrigin);

  // No origin or referer — reject the request to be safe
  return false;
}

SQL Injection

How It Works

SQL injection occurs when user input is concatenated directly into a SQL query.

// VULNERABLE: string concatenation builds a dynamic query
async function getUser(username: string) {
  const query = `SELECT * FROM users WHERE username = '${username}'`;
  return db.query(query);
}

// Attacker input: ' OR '1'='1' --
// Resulting query: SELECT * FROM users WHERE username = '' OR '1'='1' --'
// This returns ALL users in the database.

// Worse: ' ; DROP TABLE users; --
// Resulting query: SELECT * FROM users WHERE username = ''; DROP TABLE users; --'

Prevention: Parameterized Queries

Never build SQL strings with concatenation. Use parameterized queries (also called prepared statements).

// SECURE: parameterized query — the database treats $1 as data, never as SQL
async function getUser(username: string) {
  const query = "SELECT * FROM users WHERE username = $1";
  return db.query(query, [username]);
}

// Even if username is "' OR '1'='1' --", the database searches for
// a literal username with that exact string. No injection is possible.

This works with every database library:

// Prisma (ORM) — parameterized by default
const user = await prisma.user.findUnique({
  where: { username },
});

// Drizzle ORM — parameterized by default
const user = await db
  .select()
  .from(users)
  .where(eq(users.username, username));

// Raw SQL with tagged template literals (e.g., postgres.js)
const user = await sql`SELECT * FROM users WHERE username = ${username}`;

Command Injection

How It Works

If your server executes shell commands with user input, attackers can chain additional commands.

// VULNERABLE: user input goes directly into a shell command
import { exec } from "child_process";

app.get("/ping", (req, res) => {
  const host = req.query.host;
  exec(`ping -c 4 ${host}`, (error, stdout) => {
    res.send(stdout);
  });
});

// Attacker input: 127.0.0.1; cat /etc/passwd
// Executed: ping -c 4 127.0.0.1; cat /etc/passwd

Prevention

// SECURE: use execFile with an argument array (no shell interpretation)
import { execFile } from "child_process";

app.get("/ping", (req, res) => {
  const host = req.query.host;

  // Validate input first
  if (!/^[\w.\-]+$/.test(host)) {
    return res.status(400).send("Invalid hostname");
  }

  // execFile does NOT spawn a shell — arguments are passed directly
  execFile("ping", ["-c", "4", host], (error, stdout) => {
    res.send(stdout);
  });
});

Rules of thumb:

  1. Avoid exec — prefer execFile or spawn with explicit argument arrays.
  2. Validate strictly — allowlist acceptable characters.
  3. Avoid shell commands entirely when a library can do the job (e.g., use dns.resolve instead of ping).

NoSQL Injection

NoSQL databases like MongoDB can also be injected if query operators come from user input.

// VULNERABLE: attacker sends { "username": { "$gt": "" }, "password": { "$gt": "" } }
app.post("/login", async (req, res) => {
  const user = await db.collection("users").findOne({
    username: req.body.username,
    password: req.body.password,
  });
  // This matches ANY user because $gt: "" is true for all non-empty strings.
});

// SECURE: explicitly cast to string
app.post("/login", async (req, res) => {
  const user = await db.collection("users").findOne({
    username: String(req.body.username),
    password: String(req.body.password), // Also hash passwords — see lesson 04
  });
});

Key Takeaways

  • CSRF exploits the browser's automatic cookie behavior. Defend with tokens, SameSite, and origin checks.
  • SQL injection exploits string concatenation in queries. Always use parameterized queries.
  • Command injection exploits shell execution. Use execFile with argument arrays and strict validation.
  • The common thread: never trust user input. Validate, sanitize, and use APIs that separate code from data.