Series Navigation
← Part 2: Secure Coding Fundamentals
→ Part 4: Authentication and Authorization
A01 — Broken Access Control
Most common vulnerability. Users can access data or actions they shouldn't.
Attack Scenario — IDOR (Insecure Direct Object Reference)
GET /api/orders/1042 ← user's own order
GET /api/orders/1043 ← another user's order — should be forbidden
Vulnerable code:
@app.route('/api/orders/<int:order_id>')
@login_required
def get_order(order_id):
order = Order.query.get(order_id) # No ownership check!
return jsonify(order.to_dict())
# Any logged-in user can access any order by changing the ID
Secure code:
@app.route('/api/orders/<int:order_id>')
@login_required
def get_order(order_id):
order = Order.query.filter_by(
id=order_id,
user_id=current_user.id # Always scope to current user
).first_or_404()
return jsonify(order.to_dict())
Attack Scenario — Privilege Escalation
# ❌ Role set by client
@app.route('/register', methods=['POST'])
def register():
user = User(
email=request.json['email'],
role=request.json.get('role', 'user') # Attacker sends role='admin'
)
# ✅ Role set by server only
@app.route('/register', methods=['POST'])
def register():
user = User(
email=request.json['email'],
role='user' # Always set server-side
)
Detection:
# Automated IDOR detection with ffuf
# Enumerate order IDs as different user
ffuf -u https://app.com/api/orders/FUZZ \
-H "Cookie: session=USER_B_SESSION" \
-w numbers.txt \
-mc 200 # 200 = access granted (should be 403)
A02 — Cryptographic Failures
Sensitive data exposed due to weak or missing encryption.
Attack — Weak Password Hashing
import hashlib, md5
# ❌ MD5 — cracked instantly with rainbow tables
stored_hash = hashlib.md5(password.encode()).hexdigest()
# ❌ SHA-256 without salt — still vulnerable to rainbow tables
stored_hash = hashlib.sha256(password.encode()).hexdigest()
# ❌ SHA-256 with salt — better, but still too fast
import os
salt = os.urandom(16)
stored_hash = hashlib.sha256(salt + password.encode()).hexdigest()
# ✅ bcrypt — designed for passwords, slow by design
import bcrypt
hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(rounds=12))
# Verify
bcrypt.checkpw(password.encode('utf-8'), hashed) # returns True/False
# ✅ Argon2 — winner of Password Hashing Competition (PHC), even better
from argon2 import PasswordHasher
ph = PasswordHasher(time_cost=2, memory_cost=65536, parallelism=2)
hashed = ph.hash(password)
ph.verify(hashed, password) # raises exception on mismatch
Attack — Sensitive Data in Transit
# ❌ HTTP (plaintext) — anyone on network sees everything
requests.get('http://api.example.com/user/profile')
# ✅ Always HTTPS — enforce it
import ssl
import requests
# Enforce TLS verification (never disable in production)
response = requests.get(
'https://api.example.com/user/profile',
verify=True # verify=False is a critical vulnerability
)
# Force HTTPS redirect
server {
listen 80;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
ssl_protocols TLSv1.2 TLSv1.3; # disable TLS 1.0, 1.1
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
Attack — Encryption at Rest
# ❌ Sensitive data stored plaintext in database
class User(db.Model):
ssn = db.Column(db.String(11)) # '123-45-6789' in plaintext
# ✅ Encrypt at application level before storing
from cryptography.fernet import Fernet
import base64, os
class EncryptionService:
def __init__(self, key: bytes):
self.fernet = Fernet(key)
def encrypt(self, plaintext: str) -> str:
return self.fernet.encrypt(plaintext.encode()).decode()
def decrypt(self, ciphertext: str) -> str:
return self.fernet.decrypt(ciphertext.encode()).decode()
encryption = EncryptionService(key=os.environ['ENCRYPTION_KEY'].encode())
class User(db.Model):
_ssn_encrypted = db.Column('ssn', db.Text)
@property
def ssn(self):
return encryption.decrypt(self._ssn_encrypted)
@ssn.setter
def ssn(self, value):
self._ssn_encrypted = encryption.encrypt(value)
A03 — Injection
User input is interpreted as code or commands.
SQL Injection
# ❌ Vulnerable
def search_users(search_term):
query = f"SELECT * FROM users WHERE name LIKE '%{search_term}%'"
return db.execute(query)
# search_term = "' UNION SELECT username,password FROM admin_users --"
# Returns admin credentials!
# ✅ Parameterised
def search_users(search_term):
return db.execute(
"SELECT * FROM users WHERE name LIKE %s",
(f"%{search_term}%",)
)
Manual SQL injection test:
# Test for SQL injection using sqlmap
sqlmap -u "https://app.com/search?q=test" \
--dbs \ # enumerate databases
--tables \ # enumerate tables
--batch # auto answer prompts
# Manual test payloads:
' OR '1'='1
' OR 1=1--
'; SELECT sleep(5)-- # time-based blind injection test
Command Injection
import subprocess, shlex
# ❌ Command injection
@app.route('/ping')
def ping():
host = request.args.get('host')
result = os.system(f"ping -c 1 {host}")
# host = "google.com; cat /etc/passwd" → runs both commands
# ✅ Use subprocess with list form — arguments never passed to shell
@app.route('/ping')
def ping():
host = request.args.get('host', '')
# Whitelist: only allow valid hostnames
if not re.match(r'^[a-zA-Z0-9.-]+$', host):
return jsonify({'error': 'Invalid host'}), 400
result = subprocess.run(
['ping', '-c', '1', host], # list form — no shell interpretation
capture_output=True,
text=True,
timeout=5
)
return jsonify({'output': result.stdout})
LDAP Injection
# ❌ LDAP injection
def authenticate_ldap(username, password):
ldap_filter = f"(&(uid={username})(userPassword={password}))"
# username = "*)(uid=*))(|(uid=*" → bypasses authentication
# ✅ Escape special LDAP characters
from ldap3.utils.conv import escape_filter_chars
def authenticate_ldap(username, password):
safe_username = escape_filter_chars(username)
safe_password = escape_filter_chars(password)
ldap_filter = f"(&(uid={safe_username})(userPassword={safe_password}))"
A04 — Insecure Design
Security not considered in the design phase.
Example — Password Reset Design Flaw
❌ Insecure Design:
User requests reset → App emails reset link → Link works for 7 days
Problems:
- Link in email history forever
- 7 days is too long — attacker has time to use stolen email access
- No confirmation the link was used
✅ Secure Design:
User requests reset → App emails time-limited token (15 min)
→ Token is single-use (invalidated after use)
→ Old token invalidated if new reset requested
→ User notified of reset request via separate channel
import secrets
from datetime import datetime, timedelta
class PasswordResetToken(db.Model):
token_hash = db.Column(db.String(64), unique=True) # store hash, not raw token
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
expires_at = db.Column(db.DateTime)
used_at = db.Column(db.DateTime, nullable=True)
def create_reset_token(user_id: int) -> str:
# Invalidate any existing tokens for this user
PasswordResetToken.query.filter_by(
user_id=user_id, used_at=None
).delete()
raw_token = secrets.token_urlsafe(32) # cryptographically secure
reset = PasswordResetToken(
token_hash=hashlib.sha256(raw_token.encode()).hexdigest(),
user_id=user_id,
expires_at=datetime.utcnow() + timedelta(minutes=15) # short expiry
)
db.session.add(reset)
db.session.commit()
return raw_token # send this to user
def use_reset_token(raw_token: str, new_password: str) -> bool:
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
reset = PasswordResetToken.query.filter_by(
token_hash=token_hash,
used_at=None # not already used
).first()
if not reset:
return False
if datetime.utcnow() > reset.expires_at:
return False
# Mark as used immediately (prevent replay)
reset.used_at = datetime.utcnow()
change_password(reset.user_id, new_password)
db.session.commit()
return True
A05 — Security Misconfiguration
Systems running with insecure default settings.
# ❌ Common misconfigurations
DEBUG = True # exposes stack traces to users
SECRET_KEY = 'dev' # weak, predictable secret key
SQLALCHEMY_ECHO = True # logs all SQL to stdout (may contain data)
# ❌ Default admin credentials
# admin/admin, admin/password, root/root
# ❌ Unused features enabled
SWAGGER_UI = True # exposes API docs in production
CORS_ORIGINS = '*' # allows any origin
# ✅ Production configuration
import os
class ProductionConfig:
DEBUG = False
TESTING = False
SECRET_KEY = os.environ['SECRET_KEY'] # 32+ random bytes
SQLALCHEMY_ECHO = False
CORS_ORIGINS = ['https://app.example.com'] # explicit whitelist
SESSION_COOKIE_SECURE = True # HTTPS only
SESSION_COOKIE_HTTPONLY = True # no JS access
SESSION_COOKIE_SAMESITE = 'Lax' # CSRF protection
# Detect misconfigurations with Scout Suite (AWS/Azure/GCP)
pip install scoutsuite
scout aws --report-dir ./scout-report
# Detect web server misconfigs with nikto
nikto -h https://your-app.com
# Check TLS configuration
sslyze --regular your-app.com:443
A06 — Vulnerable and Outdated Components
Using libraries with known CVEs.
# Python — check with safety
pip install safety
safety check
# Node.js — built-in audit
npm audit
npm audit fix
# Java — OWASP Dependency Check
mvn org.owasp:dependency-check-maven:check
# All languages — Snyk (free for open source)
npm install -g snyk
snyk test
snyk monitor # continuous monitoring
# Container images — Trivy
trivy image python:3.9
trivy image node:16-alpine
A07 — Identification and Authentication Failures
Broken login, weak passwords, no MFA.
# ❌ No brute force protection
@app.route('/login', methods=['POST'])
def login():
user = User.query.filter_by(email=request.json['email']).first()
if user and user.check_password(request.json['password']):
return jsonify({'token': generate_token(user)})
return jsonify({'error': 'Invalid credentials'}), 401
# Attacker tries 1,000,000 passwords — no lockout
# ✅ Rate limiting + account lockout
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(app, key_func=get_remote_address)
@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute") # rate limit per IP
def login():
email = request.json.get('email', '')
password = request.json.get('password', '')
# Check account lockout
user = User.query.filter_by(email=email).first()
if user and user.locked_until and user.locked_until > datetime.utcnow():
return jsonify({'error': 'Account temporarily locked'}), 429
if user and user.check_password(password):
user.failed_attempts = 0
db.session.commit()
return jsonify({'token': generate_token(user)})
else:
if user:
user.failed_attempts += 1
if user.failed_attempts >= 5:
user.locked_until = datetime.utcnow() + timedelta(minutes=15)
db.session.commit()
# Same error for wrong user OR wrong password (prevents user enumeration)
return jsonify({'error': 'Invalid credentials'}), 401
A08 — Software and Data Integrity Failures
Unsigned code, compromised CI/CD pipelines.
# ❌ Insecure CI/CD — anyone can push malicious code
- name: Deploy
run: curl https://raw.githubusercontent.com/random-user/script.sh | bash
# This runs arbitrary code from the internet
# ✅ Pin dependencies to exact versions and verify hashes
# package-lock.json (Node) or requirements.txt with hashes (Python)
# pip install --require-hashes -r requirements.txt
# requirements.txt with cryptographic hashes
Django==4.2.0 \
--hash=sha256:c36e2... \
--hash=sha256:f8a3b...
# Install — fails if hash doesn't match
pip install --require-hashes -r requirements.txt
A09 — Security Logging and Monitoring Failures
Not detecting or logging security events.
# What MUST be logged for security:
# 1. All authentication events
def login(email, password):
result = authenticate(email, password)
if result.success:
security_log.info("auth.login.success", user_id=result.user.id,
ip=request.remote_addr, timestamp=datetime.utcnow().isoformat())
else:
security_log.warning("auth.login.failure", email=email,
ip=request.remote_addr, reason=result.reason)
# 2. Access control failures
def get_document(doc_id):
doc = Document.query.get(doc_id)
if doc.owner_id != current_user.id:
security_log.warning("authz.access.denied",
user_id=current_user.id,
resource=f"document:{doc_id}",
ip=request.remote_addr)
abort(403)
# 3. Admin actions
def delete_user(user_id):
security_log.info("admin.user.delete",
admin_id=current_user.id,
target_user_id=user_id,
ip=request.remote_addr)
A10 — Server-Side Request Forgery (SSRF)
Server fetches a URL provided by an attacker, reaching internal services.
# ❌ SSRF vulnerability
import requests
@app.route('/fetch-url')
def fetch_url():
url = request.args.get('url')
response = requests.get(url) # attacker provides: http://169.254.169.254/latest/meta-data/
return response.text
# On AWS: returns IAM credentials! Full account compromise.
# Attacker payloads:
# http://169.254.169.254/latest/meta-data/iam/security-credentials/ (AWS metadata)
# http://localhost:8080/admin (internal admin panel)
# http://internal-db:5432 (internal database)
# file:///etc/passwd (local file read)
# ✅ Validate and whitelist URLs
from urllib.parse import urlparse
import ipaddress
ALLOWED_DOMAINS = {'api.trusted.com', 'cdn.trusted.com'}
def is_safe_url(url: str) -> bool:
try:
parsed = urlparse(url)
# Must be HTTPS
if parsed.scheme != 'https':
return False
# Must be in whitelist
if parsed.hostname not in ALLOWED_DOMAINS:
return False
# Resolve hostname and check it's not private
import socket
ip = socket.gethostbyname(parsed.hostname)
ip_obj = ipaddress.ip_address(ip)
# Block private/loopback/link-local IPs
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local:
return False
return True
except Exception:
return False
@app.route('/fetch-url')
def fetch_url():
url = request.args.get('url')
if not is_safe_url(url):
return jsonify({'error': 'URL not allowed'}), 400
response = requests.get(url, timeout=5)
return response.text
OWASP Top 10 — Interview Quick Reference
| Vulnerability | One-Line Definition | Key Defence |
|---|---|---|
| A01 Broken Access Control | Users access resources they shouldn't | Check ownership on every request |
| A02 Crypto Failures | Sensitive data unprotected | HTTPS, bcrypt/Argon2, encrypt at rest |
| A03 Injection | Input interpreted as code | Parameterised queries, avoid shell commands |
| A04 Insecure Design | Security not in architecture | Threat model, secure design patterns |
| A05 Misconfiguration | Default/insecure settings | Harden configs, disable debug in prod |
| A06 Vulnerable Components | Outdated deps with CVEs | npm audit, Snyk, dependency scanning |
| A07 Auth Failures | Weak login, no MFA | Rate limiting, lockout, MFA |
| A08 Integrity Failures | Unsigned code, compromised pipeline | Hash pinning, signed artifacts |
| A09 Logging Failures | Attacks not detected | Log all security events, SIEM |
| A10 SSRF | Server fetches attacker URLs | URL whitelist, block private IPs |
What's Next
In Part 4 we go deep on authentication and authorisation — OAuth 2.0 flows, JWT vulnerabilities and secure implementation, session management, MFA, and the most common auth bugs that lead to account takeovers.
Discussion
Loading...Leave a Comment
All comments are reviewed before appearing. No links please.