This page provides detailed side-by-side comparisons of vulnerable and secure code implementations, highlighting the specific changes needed to fix each vulnerability.
SQL Injection
Login Authentication
Vulnerable (app.py:25-30)
Secure (app.py:54-63)
# VULNERABLE: SQL Injection
query = f "SELECT * FROM users WHERE username = ' { username } ' AND password = ' { password } '"
print ( f "Query ejecutada: { query } " )
try :
cursor.execute(query)
user = cursor.fetchone()
The Problem
The Solution
Key Differences
Vulnerable Code Issues :
String Interpolation : Uses f-string to build query
f "SELECT * FROM users WHERE username = ' { username } '"
Direct Password Comparison : Compares plaintext passwords in SQL
AND password = ' {password} '
Debug Output : Prints queries to console
print ( f "Query ejecutada: { query } " )
Attack Vector :Username: admin' OR '1'='1' --
Password: anything
# Results in:
SELECT * FROM users WHERE username = 'admin' OR '1'='1' --' AND password = 'anything'
# The -- comments out the rest, OR '1'='1' is always true
Secure Code Fixes :
Parameterized Query : Uses placeholder (%s) and tuple
cursor.execute( "SELECT * FROM users WHERE username = %s " , (username,))
Database driver handles escaping
Input treated as data, not code
Separated Password Check : Verify hash after query
if user and check_password_hash(user[ 'password' ], password):
Constant-time comparison
No SQL involved in password check
No Debug Output : Queries not logged
Prevents information disclosure
Production-safe
Aspect Vulnerable Secure Query building String concatenation Parameterized Password check In SQL query After query, hashed Input handling Direct insertion Driver escaping Debug info Queries logged Silent Attack surface Complete bypass None
Profile Lookup
Vulnerable (app.py:95-102)
Secure (app.py:147-170)
user_id = request.args.get( 'id' , session[ 'user_id' ])
connection = create_connection()
cursor = connection.cursor()
try :
cursor.execute( f "SELECT * FROM users WHERE id = { user_id } " )
user = cursor.fetchone()
Two Vulnerabilities Fixed :
SQL Injection - Parameterized query
IDOR - Authorization check before database query
Cross-Site Scripting (XSS)
Dashboard Message Display
Vulnerable (app.py:81-87)
Secure (app.py:124-137)
@app.route ( '/dashboard' )
def dashboard ():
if 'user_id' not in session:
flash( 'Debes iniciar sesión' , 'warning' )
return redirect( '/login' )
# VULNERABLE: XSS
message = request.args.get( 'message' , '' )
return render_template( 'dashboard.html' ,
username = session.get( 'username' ),
message = message,
role = session.get( 'role' ))
How escape() Works
Character Conversion
Attack Prevention
Template Comparison
The markupsafe.escape() function converts HTML special characters: Input Output Why <<Start of HTML tag >>End of HTML tag ""Attribute delimiter ''Attribute delimiter &&Entity start
Example :input : "<script>alert('XSS')</script>"
output: "<script>alert('XSS')</script>"
Rendered as harmless text: <script>alert('XSS')</script> Common XSS Attacks Prevented :
Input: < script > alert ( document . cookie ) </ script >
Output: < script > alert(document.cookie) < /script >
Result: Text displayed, script not executed
Input: < img src = x onerror = " alert ('XSS')" >
Output: < img src=x onerror= " alert('XSS') ">
Result: No image tag created, just text
Input: < a href = "javascript:alert('XSS')" > Click </ a >
Output: < a href= " javascript:alert('XSS') "> Click < /a >
Result: No clickable link, just text
Vulnerable Template :<div class="alert">
{{ message|safe }} {# ❌ Disables escaping #}
</div>
Secure Template :<div class="alert">
{{ message }} {# ✅ Automatically escaped #}
</div>
The |safe filter in Jinja2/Django tells the template engine “trust this HTML, don’t escape it.” This should only be used for content you control, never user input.
Password Storage
User Registration
Vulnerable (app.py:53-66)
Secure (app.py:77-108)
@app.route ( '/register' , methods = [ 'GET' , 'POST' ])
def register ():
if request.method == 'POST' :
username = request.form[ 'username' ]
password = request.form[ 'password' ] # Plaintext
email = request.form[ 'email' ]
connection = create_connection()
cursor = connection.cursor()
try :
query = "INSERT INTO users (username, password, email) VALUES ( %s , %s , %s )"
cursor.execute(query, (username, password, email)) # Stores plaintext
connection.commit()
Database Storage Comparison
Vulnerable Database
Secure Database
Hash Anatomy
Plaintext Password Storage :INSERT INTO users (username, password , email)
VALUES ( 'admin' , 'admin123' , '[email protected] ' )
Database Contents :Risks :
Database breach exposes all passwords immediately
Admins can see user passwords
No protection against rainbow tables
Violates GDPR and most compliance standards
Users reusing passwords across sites are compromised
Hashed Password Storage :admin_password = generate_password_hash( 'admin123' )
# Result: 'scrypt:32768:8:1$h3k2j1$abc123...'
INSERT INTO users (username, password, email)
VALUES ( 'admin' , admin_password, '[email protected] ' )
Database Contents :id | username | password | email
---+----------+---------------------------------------------------------------+------------------
1 | admin | scrypt:32768:8:1$h3k2j1$f8e2a9... | [email protected]
2 | usuario | scrypt:32768:8:1$k9m4n2$d6c1b8... | [email protected]
Benefits :
Original password cannot be recovered
Each password has unique salt (random data)
Computational cost prevents brute force
Industry standard (bcrypt/scrypt)
Compliant with security regulations
Understanding the Hash String :scrypt:32768:8:1$h3k2j1$f8e2a9b3c4d5e6...
^^^^^^ ^^^^^ ^ ^ ^^^^^^ ^^^^^^^^^^^^^^^^
| | | | | |
| | | | | +-- Hash (derived key)
| | | | +-------------- Salt (random)
| | | +------------------ Parallelization factor
| | +-------------------- Block size
| +------------------------ Memory cost factor (N)
+------------------------------- Algorithm
Parameters Explained :
Algorithm : scrypt or pbkdf2 (secure key derivation functions)
Memory Cost (32768): Makes attacks expensive
Salt (h3k2j1): Random data, unique per password
Hash : The actual derived key from password + salt
Same password, different hashes :generate_password_hash( 'password123' )
# → scrypt:32768:8:1$aBc123$...
generate_password_hash( 'password123' ) # Same input
# → scrypt:32768:8:1$xYz789$... # Different output!
Password Verification
Vulnerable Login
Secure Login
# Password checked in SQL query
query = f "SELECT * FROM users WHERE username = ' { username } ' AND password = ' { password } '"
cursor.execute(query)
user = cursor.fetchone()
if user:
# Login successful
How check_password_hash() Works :
stored_hash = "scrypt:32768:8:1$h3k2j1$f8e2a9..."
user_input = "admin123"
# Extract algorithm and salt from stored hash
algorithm, params, salt, original_hash = parse_hash(stored_hash)
# Re-hash the input with same salt and params
new_hash = scrypt(user_input, salt, params)
# Constant-time comparison (prevents timing attacks)
return secure_compare(new_hash, original_hash)
Insecure Direct Object Reference (IDOR)
Authorization Implementation
Vulnerable (app.py:89-110)
Secure (app.py:139-179)
@app.route ( '/profile' )
def profile ():
if 'user_id' not in session:
flash( 'Debes iniciar sesión' , 'warning' )
return redirect( '/login' )
# No authorization check!
user_id = request.args.get( 'id' , session[ 'user_id' ])
connection = create_connection()
cursor = connection.cursor()
try :
# SQL injection + IDOR
cursor.execute( f "SELECT * FROM users WHERE id = { user_id } " )
user = cursor.fetchone()
except sqlite3.Error as e:
flash( f 'Error: { str (e) } ' , 'danger' )
user = None
finally :
cursor.close()
connection.close()
return render_template( 'profile.html' , user = user)
Authorization Logic Breakdown
Vulnerable Flow
Secure Flow
Access Matrix
Problems :
❌ No permission check
❌ Any authenticated user can view any profile
❌ Returns password field
❌ SQL injection in ID parameter
Improvements :
✅ Type validation on ID
✅ Authorization logic
✅ Role-based access (admin can view all)
✅ Password field excluded
✅ Proper error handling
Who Can View Whose Profile :Requester Target Profile Vulnerable App Secure App User A User A’s ✅ Yes ✅ Yes User A User B’s ✅ Yes (IDOR ) ❌ No User A Admin’s ✅ Yes (IDOR ) ❌ No Admin User A’s ✅ Yes ✅ Yes Admin User B’s ✅ Yes ✅ Yes Anonymous Any ❌ No ❌ No
Secure App Logic :# Can view if:
# 1. It's your own profile, OR
# 2. You're an admin
can_view = (requested_id == session[ 'user_id' ]) or (session[ 'role' ] == 'admin' )
Data Minimization
Vulnerable Query
Secure Query
SELECT * FROM users WHERE id = 1
-- Returns: id, username, password, email, role, created_at
-- ❌ Password field exposed!
Principle of Least Privilege : Only query and return the data actually needed. Even with hashed passwords, there’s no reason to send them to the frontend.
from flask import Flask
app = Flask( __name__ )
app.secret_key = 'clave_super_secreta_123'
# No security headers configured ❌
@app.route ( '/' )
def index ():
return render_template( 'index.html' )
Purpose : Prevent MIME type sniffingWithout Header :HTTP / 1.1 200 OK
Content-Type : text/html
Browser might interpret text/html file as text/javascript if it contains code. With Header :HTTP / 1.1 200 OK
Content-Type : text/html
X-Content-Type-Options : nosniff
Browser strictly follows declared Content-Type. Attack Prevented : MIME confusion attacks where attackers upload malicious files disguised as safe types
Purpose : Prevent clickjacking attacksWithout Header :<!-- Attacker's site -->
< iframe src = "https://your-app.com/dashboard" >
<!-- Your app loads inside attacker's page -->
With Header :Browser refuses to load your site in any iframe. Options :
DENY - Never allow framing
SAMEORIGIN - Only allow same-domain framing
Attack Prevented : Clickjacking, where attackers overlay invisible iframes to trick users into clicking malicious links
Purpose : Control what resources can loadWithout Header :<!-- Any script can run -->
< script src = "http://evil.com/malware.js" ></ script > <!-- ✅ Loads -->
< script > eval ( userInput ) </ script > <!-- ✅ Runs -->
With Header :Content-Security-Policy : default-src 'self'
< script src = "http://evil.com/malware.js" ></ script > <!-- ❌ Blocked -->
< script src = "/static/app.js" ></ script > <!-- ✅ Allowed (same origin) -->
Common Directives :
default-src 'self' - Only load from same origin
script-src 'self' - Scripts only from same origin
style-src 'self' 'unsafe-inline' - Styles from same origin + inline
Attack Prevented : XSS exploitation, malicious script injection, data exfiltration to external domains
Purpose : Force HTTPS connectionsWithout Header :User types: http://your-app.com
→ Loads over HTTP (unencrypted)
→ Man-in-the-middle can intercept
With Header :Strict-Transport-Security : max-age=31536000; includeSubDomains
After first HTTPS visit: User types: http://your-app.com
→ Browser automatically changes to https://
→ For next 31536000 seconds (1 year)
→ Includes all subdomains
Attack Prevented : SSL stripping, man-in-the-middle attacks, protocol downgrade attacks
Configuration & Secrets Management
Secret Key Comparison
Vulnerable (app.py:6)
Secure (app.py:10-13)
app.secret_key = 'clave_super_secreta_123'
Environment Variable Setup
Vulnerable Approach
Secure Approach
Generate Secrets
Hardcoded in Source :# app.py (committed to git)
app.secret_key = 'clave_super_secreta_123'
DATABASE_URL = 'postgresql://admin:password@localhost/db'
API_KEY = 'sk_live_abc123xyz789'
Problems :
❌ Visible in version control history forever
❌ Same secrets in dev/staging/production
❌ Developers can see production secrets
❌ Leaked if repository becomes public
❌ No rotation without code changes
Environment Variables :# .env (NOT committed to git)
SECRET_KEY = a1b2c3d4e5f6... # Generated randomly
DATABASE_URL = postgresql://...
API_KEY = sk_live_...
# app.py (committed to git)
from dotenv import load_dotenv
import os
load_dotenv()
app.secret_key = os.getenv( 'SECRET_KEY' )
database_url = os.getenv( 'DATABASE_URL' )
api_key = os.getenv( 'API_KEY' )
# .gitignore (committed to git)
.env
.env.local
.env.*.local
Benefits :
✅ Secrets never in version control
✅ Different values per environment
✅ Easy rotation (change file, restart)
✅ Follows 12-factor app principles
✅ Compatible with deployment platforms
Creating Secure Random Keys :# Method 1: Python secrets module
import secrets
print (secrets.token_hex( 32 ))
# Output: 'a1b2c3d4e5f6...64_hexadecimal_characters'
# Method 2: OpenSSL
# openssl rand -hex 32
# Method 3: Python random (dev only)
import os
print (os.urandom( 32 ).hex())
Example .env file :# Flask configuration
SECRET_KEY = a8f2e9c6b1d4f7e3a2c8b5d9f1e4a7b3c6d2f8e5a1b7c4d9f2e6a3b8c5d1f4e7
FLASK_ENV = production
# Database
DATABASE_URL = sqlite:///./users.db
# Security settings
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = Lax
# CSRF
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = 3600
Vulnerable (app.py:53-56)
Secure (app.py:78-93)
username = request.form[ 'username' ] # No validation
password = request.form[ 'password' ] # No validation
email = request.form[ 'email' ] # No validation
# Directly inserted into database
cursor.execute(query, (username, password, email))
Validation Checklist
Check if fields exist and are not empty :# Bad - KeyError if field missing
username = request.form[ 'username' ]
# Good - Returns empty string if missing
username = request.form.get( 'username' , '' )
# Best - Also checks for empty
username = request.form.get( 'username' , '' ).strip()
if not username:
return error( 'Username is required' )
Enforce minimum and maximum lengths :if len (username) < 3 :
return error( 'Username too short' )
if len (username) > 50 :
return error( 'Username too long' )
# Or combined:
if not ( 3 <= len (username) <= 50 ):
return error( 'Username must be 3-50 characters' )
Why length limits matter :
Prevent database overflow
Limit resource consumption
Block certain attack patterns
Ensure correct data types :# Validate integer
user_id = request.args.get( 'id' )
try :
user_id = int (user_id)
except ( ValueError , TypeError ):
return error( 'Invalid ID format' )
# Validate email format
import re
email_pattern = r ' ^ [ a-zA-Z0-9._%+- ] + @ [ a-zA-Z0-9.- ] + \. [ a-zA-Z ] {2,} $ '
if not re.match(email_pattern, email):
return error( 'Invalid email format' )
Trim whitespace to prevent issues :# Without strip()
username = ' admin '
# Stored as ' admin ' (different from 'admin')
# Can bypass duplicate checks
# With strip()
username = request.form.get( 'username' , '' ).strip()
# Stored as 'admin' (normalized)
Summary Table: All Vulnerabilities
Vulnerability Vulnerable Code Secure Code Impact SQL Injection f"SELECT * FROM users WHERE id = {id}"cursor.execute("SELECT * FROM users WHERE id = %s", (id,))Critical - Full database compromise XSS {{ message|safe }}{{ escape(message) }}High - Account takeover, data theft IDOR No authorization check if id != session['user_id'] and role != 'admin': abort(403)High - Unauthorized data access Plaintext Passwords password = 'admin123'password = generate_password_hash('admin123')Critical - Mass password breach Hardcoded Secrets app.secret_key = 'secret'app.secret_key = os.getenv('SECRET_KEY')High - Session compromise Missing CSRF No protection csrf = CSRFProtect(app)Medium - Forced actions No Security Headers No headers @app.after_request middlewareMedium - Multiple attack vectors Poor Input Validation request.form['username']request.form.get('username', '').strip() + validationMedium - Data integrity, DoS
Testing Guide
Deploy Both Applications
# Terminal 1: Vulnerable app
cd vulnerable
python app.py # Runs on port 5000
# Terminal 2: Secure app
cd secure
python app.py # Runs on port 5001
Test SQL Injection
Vulnerable (port 5000) :
Username: admin' OR '1'='1' --
Password: anything
Result: ✅ Logged in (bypassed authentication)
Secure (port 5001) :
Username: admin' OR '1'='1' --
Password: anything
Result: ❌ “Credenciales incorrectas”
Test XSS
Vulnerable (port 5000) :http://localhost:5000/dashboard?message=<script>alert('XSS')</script>
Result: ⚠️ Alert popup (JavaScript executed)
Secure (port 5001) :http://localhost:5001/dashboard?message=<script>alert('XSS')</script>
Result: ✅ Text displayed: <script>alert('XSS')</script>
Test IDOR
Log in as regular user, then: Vulnerable (port 5000) :http://localhost:5000/profile?id=1
Result: ✅ Shows admin profile with password
Secure (port 5001) :http://localhost:5001/profile?id=1
Result: ❌ “No tienes permiso para ver este perfil”
Inspect Database
# Vulnerable database
sqlite3 vulnerable/users.db "SELECT * FROM users"
# Shows plaintext passwords
# Secure database
sqlite3 secure/users.db "SELECT * FROM users"
# Shows hashed passwords (scrypt:...)
All security improvements are demonstrated through working code. Run both applications side-by-side to see the difference in behavior.