CWE-201: Insertion of Sensitive Information Into Sent Data - Python
Overview
In Python web applications, CWE-201 usually means sensitive information reaching an API response, an error message, or a log file. Django, Flask and FastAPI all make it a one-liner to serialize an entire object or return a detailed error trace, which can leak passwords, tokens, internal paths, PII, and system configuration details.
The exposure usually arrives through a route that returns a Django or SQLAlchemy model - or a __dict__ - without filtering its fields, an exception handler that returns the stack trace in production, an endpoint that reports environment variables, or a line written through Python's logging module that carries a whole request object with it. Django's DEBUG mode, Flask's debug mode, and improper exception handling in FastAPI each expose detailed system information to callers who should not see it.
Modern frameworks provide protections, but developers must explicitly configure them and use Data Transfer Objects (DTOs) or serializers to control exactly what data is transmitted.
Primary Defence: Control what a response contains with an explicit field allowlist - a Data Transfer Object (DTO), a Django REST Framework serializer, or a Pydantic response model - instead of serializing ORM models directly. Handle errors centrally: return a generic message and log the full detail server-side with exc_info=True, registering a handler for the framework's own HTTP exceptions as well as for Exception so client errors keep their status. Redact sensitive fields with a logging Filter on the handler and a Formatter that also covers the traceback. Run production with DEBUG=False, and with admin panels and debug toolbars unreachable by untrusted callers.
The patterns below are shown for Flask, Django REST Framework and FastAPI.
Common Vulnerable Patterns
Direct ORM Model Serialization
# VULNERABLE - Exposing entire database model including sensitive fields
from flask import Flask, jsonify
from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
username = Column(String)
email = Column(String)
password_hash = Column(String) # SENSITIVE!
reset_token = Column(String) # SENSITIVE!
api_key = Column(String) # SENSITIVE!
is_admin = Column(Integer) # INTERNAL!
app = Flask(__name__)
@app.route('/api/user/<int:user_id>')
def get_user(user_id):
user = session.query(User).get(user_id)
# Returns ALL fields including password_hash, reset_token, api_key!
return jsonify(user.__dict__)
# Attack result:
# {
# "id": 123,
# "username": "john",
# "email": "john@example.com",
# "password_hash": "$2b$12$abc123...", ← EXPOSED!
# "reset_token": "secret-token-12345", ← EXPOSED!
# "api_key": "sk-1234567890abcdef", ← EXPOSED!
# "is_admin": 1 ← INTERNAL INFO!
# }
Why this is vulnerable: The response is defined by the database schema rather than by the API, so it changes whenever a migration does. Adding a reset_token column ships that token to every client of this endpoint without anyone editing the route, and no test that asserts on the fields it expects will notice a field it does not.
user.__dict__ makes it worse than a serializer would: it also carries SQLAlchemy's _sa_instance_state, so internal ORM detail is exposed alongside the columns. The fix is an explicit output shape - a schema, a dataclass, a dict literal - where adding a column is invisible until someone chooses to expose it.
Stack Traces in Production Error Responses
# VULNERABLE - Exposing internal paths, code structure, and environment details
from flask import Flask, jsonify
import traceback
app = Flask(__name__)
@app.route('/api/process')
def process_data():
try:
# Some complex operation
result = perform_database_query()
return jsonify(result)
except Exception as e:
# Exposes full stack trace with file paths, code, environment
return jsonify({
'error': str(e),
'type': type(e).__name__,
'traceback': traceback.format_exc() # DANGEROUS!
}), 500
# Attack result when error occurs:
# {
# "error": "FATAL: password authentication failed for user 'admin'",
# "type": "OperationalError",
# "traceback": "Traceback (most recent call last):
# File '/home/deploy/myapp/app.py', line 45, in process_data
# result = perform_database_query()
# File '/home/deploy/myapp/db.py', line 123, in perform_database_query
# conn = psycopg2.connect('host=10.0.1.5 user=admin password=secret123')
# ..."
# }
# ← Exposes internal paths, database credentials, IP addresses!
Why this is vulnerable: A traceback is a description of the server's internals written for whoever asked. The frames name absolute paths, which give the deployment layout and the username; the source lines quoted alongside them can contain a literal connection string, as in the example; and the exception message from a database driver often carries the host, port and account it failed to authenticate.
The reconnaissance value outlasts the bug. An attacker who triggers one error learns the framework, its version, the internal hostnames and the directory structure, and uses that to choose the next thing to try - so the finding is not resolved by fixing whatever caused the exception.
Detailed Login Error Messages
# VULNERABLE - Enables user enumeration and reveals password validation logic
from flask import Flask, request, jsonify
from werkzeug.security import check_password_hash
app = Flask(__name__)
@app.route('/api/login', methods=['POST'])
def login():
username = request.json.get('username')
password = request.json.get('password')
user = User.query.filter_by(username=username).first()
# Reveals whether username exists
if not user:
return jsonify({
'error': f'No user found with username: {username}' # USER ENUMERATION!
}), 404
# Reveals password validation details
if not check_password_hash(user.password_hash, password):
return jsonify({
'error': f'Invalid password for user {username}',
'hash': user.password_hash, # EXPOSES PASSWORD HASH!
'hint': 'Password must be at least 8 characters'
}), 401
return jsonify({'token': generate_token(user)})
# Attack result for enumeration:
# Try: {"username": "admin"}
# Response: "No user found with username: admin" vs "Invalid password"
# Attacker now knows which usernames exist!
Why this is vulnerable: Two distinct failures are reported distinguishably, which turns the login form into an oracle for whether an account exists - and a username list is what makes credential stuffing worth running against this host rather than another. Note that the status codes differ as well, 404 against 401, so making the message identical without making the status identical leaves the same signal.
Returning the stored hash goes further than enumeration: it moves the attack offline, where the rate limit and the lockout do not apply and the only remaining cost is the KDF's work factor. Nothing in the flow needs the hash to be visible to the caller, so this is a leak with no accompanying feature.
Sensitive Data in Logs
# VULNERABLE - Logging sensitive data that can be accessed by attackers
import logging
from flask import Flask, request, jsonify
app = Flask(__name__)
logging.basicConfig(level=logging.DEBUG, filename='app.log')
@app.route('/api/payment', methods=['POST'])
def process_payment():
data = request.json
# Logs sensitive payment information
logging.info(f'Processing payment: {data}') # LOGS CREDIT CARDS!
# Logs user credentials
logging.debug(f'User: {data["username"]}, Password: {data["password"]}') # DANGEROUS!
try:
result = charge_card(data['card_number'], data['cvv'])
return jsonify({'status': 'success'})
except Exception as e:
# Logs full request with sensitive data
logging.error(f'Payment failed for request: {request.json}') # LOGS SENSITIVE DATA!
return jsonify({'error': 'Payment processing failed'}), 500
# app.log will contain:
# INFO: Processing payment: {'username': 'john', 'password': 'secret123',
# 'card_number': '4111111111111111', 'cvv': '123'}
# ← All sensitive data exposed in log files!
Why this is vulnerable: Logs travel further than responses. They are shipped to an aggregator, indexed for search, replicated, and retained under a policy set for operations - so a secret written once is readable by everyone with dashboard access, for as long as the retention window, in a system that usually has weaker access control than the database it came from.
The pattern to look for is whole-object logging rather than named fields. Logging a request, a config object or a model instance captures whatever it happens to hold today, which means the leak is introduced later by a change somewhere else entirely.
Environment Variables in Error Responses
# VULNERABLE - Exposing configuration and secrets in debug pages
from flask import Flask, jsonify
import os
app = Flask(__name__)
app.config['DEBUG'] = True # DANGEROUS in production!
@app.route('/api/config')
def get_config():
# Exposes all environment variables including secrets
return jsonify({
'env': dict(os.environ), # EXPOSES ALL SECRETS!
'config': dict(app.config) # EXPOSES APP SECRETS!
})
# Attack result:
# {
# "env": {
# "DATABASE_URL": "postgres://admin:password123@db.internal.com:5432/prod",
# "SECRET_KEY": "super-secret-key-12345",
# "AWS_ACCESS_KEY": "AKIAIOSFODNN7EXAMPLE",
# "AWS_SECRET_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
# ...
# }
# }
# ← All credentials and secrets exposed!
Why this is vulnerable: The environment is where the deployment keeps its credentials, so dumping it is not an information leak in degree - it is handing over the database password, the signing key and any cloud credential in one response.
This shape usually arrives as debugging help that was never removed, which is why it is worth checking for directly rather than waiting for a scanner: it is guarded by a flag, a header or a query parameter far more often than it is unconditional, so the endpoint looks harmless in a code read and is one request away from complete disclosure.
Secure Patterns
DTO Pattern with Explicit Field Allowlist
# SECURE - Using Data Transfer Objects to control exposed fields
from flask import Flask, jsonify
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import declarative_base, sessionmaker
from dataclasses import dataclass
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
username = Column(String)
email = Column(String)
password_hash = Column(String)
reset_token = Column(String)
api_key = Column(String)
is_admin = Column(Integer)
# DTO with only safe fields
@dataclass
class UserDTO:
id: int
username: str
email: str
# NEVER include: password_hash, reset_token, api_key, is_admin
@classmethod
def from_model(cls, user: User):
return cls(
id=user.id,
username=user.username,
email=user.email
)
def to_dict(self):
return {
'id': self.id,
'username': self.username,
'email': self.email
}
app = Flask(__name__)
@app.route('/api/user/<int:user_id>')
def get_user(user_id):
# Session.get(), not session.query(User).get() - the latter is the legacy
# 1.x form, deprecated since SQLAlchemy 1.4
user = session.get(User, user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
# Convert to DTO - only returns safe fields
user_dto = UserDTO.from_model(user)
return jsonify(user_dto.to_dict())
Why this works: The Data Transfer Object (DTO) is an explicit allowlist of the fields the API exposes. Where user.__dict__ hands back whatever the instance happens to hold, a migration that adds a sensitive column here widens the table and not the response: exposing it takes a deliberate edit to UserDTO. @dataclass declares that field list in one place where a type checker such as mypy or pyright can flag a mismatch and editors can autocomplete - the annotations are not enforced at runtime, so the checker has to be part of CI for that to hold. from_model() is the single place ORM-to-DTO conversion happens, so confirming that password_hash, reset_token and api_key never reach a client means reading one method rather than every route that returns a user.
Generic Error Handling with Server-Side Logging
# SECURE - Generic user errors with detailed server-side logging
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException
import logging
import uuid
app = Flask(__name__)
# Configure logging to file only (not to response)
logging.basicConfig(
level=logging.INFO,
filename='/var/log/app/application.log',
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class ErrorResponse:
"""Standardized error response format"""
@staticmethod
def create(error_code: str, message: str, status_code: int = 500):
return jsonify({
'error': message,
'error_code': error_code
}), status_code
@app.route('/api/process')
def process_data():
try:
result = perform_database_query()
return jsonify(result)
except Exception:
# Log full error details server-side (with stack trace), under an id
# the client is also given so support can correlate the two
error_id = uuid.uuid4().hex
logger.error(
'error_id=%s database query failed',
error_id,
exc_info=True, # Includes full traceback in log
extra={'user_id': get_current_user_id()}
)
# Generic in both the message and the code: neither names a subsystem
return ErrorResponse.create(
error_code='INTERNAL_ERROR',
message=f'An unexpected error occurred (ref {error_id})',
status_code=500
)
# Error handling for different exception types
@app.errorhandler(ValueError)
def handle_value_error(e):
logger.warning(f'Invalid input: {e}', exc_info=True)
return ErrorResponse.create(
error_code='INVALID_INPUT',
message='Invalid request data',
status_code=400
)
@app.errorhandler(HTTPException)
def handle_http_exception(e):
# Required, and easy to leave out. Werkzeug's routing errors are all
# Exception subclasses, so without this the handler below answers 500 to
# a plain 404 or 405. error.name is a generic reason phrase.
return ErrorResponse.create(
error_code=f'HTTP_{e.code}',
message=e.name,
status_code=e.code
)
@app.errorhandler(Exception)
def handle_generic_error(e):
logger.error('Unhandled exception', exc_info=True)
return ErrorResponse.create(
error_code='INTERNAL_ERROR',
message='An unexpected error occurred',
status_code=500
)
Why this works: Error visibility is split into two channels. exc_info=True puts the full traceback - file paths, line numbers, the driver's own message - into the log, where operations can already read it; the response carries a fixed string and a random error_id that appears in the same log line, so support can find the exact record without the response describing anything.
Registering HTTPException is not optional, and leaving it out is the usual mistake. Every routing error Werkzeug raises is an Exception subclass, and Flask resolves handlers by walking the exception's MRO, so an application with only an Exception handler answers 500 to a plain 404 or 405. Nothing leaks and a re-scan passes; what is lost is the caller's ability to tell "you asked for something that is not there" from "the server broke". The HTTPException handler is more specific, so Flask prefers it and the status survives.
Keep the subsystem out of both the message and the code. 'Database operation failed' with the code DB_ERR_001 is the tempting middle ground. Both read as sanitized and neither is: they confirm a database sits behind the endpoint and that a payload reaching it produced a server-side failure rather than a validation rejection. The test to apply to anything client-visible is whether it describes the caller's situation or your architecture - Invalid request data and Resource not found are facts about the request and can be stated plainly; anything a reader could use to sketch the stack belongs in the log with an error ID standing in for it in the response. CWE-209 covers this channel in more depth.
Secure Login with Generic Error Messages
# SECURE - Preventing user enumeration with generic error messages
from flask import Flask, request, jsonify
from werkzeug.security import check_password_hash, generate_password_hash
import logging
import secrets
app = Flask(__name__)
logger = logging.getLogger(__name__)
# Must match the method and parameters used when storing passwords, or the
# unknown-username path costs measurably less than a real verification
PASSWORD_HASH_METHOD = 'scrypt:32768:8:1'
# Real hash of a value no account can hold, generated at startup
DUMMY_PASSWORD_HASH = generate_password_hash(secrets.token_urlsafe(32),
method=PASSWORD_HASH_METHOD)
@app.route('/api/login', methods=['POST'])
def login():
username = request.json.get('username')
password = request.json.get('password')
# Generic error message for all failure cases
GENERIC_ERROR = {'error': 'Invalid credentials'}
# Input validation
if not username or not password:
return jsonify(GENERIC_ERROR), 401
user = User.query.filter_by(username=username).first()
# Log attempt server-side (for security monitoring)
if not user:
logger.warning(f'Login attempt for non-existent user: {username}')
# Same work as a genuine check, so timing does not separate the cases
check_password_hash(DUMMY_PASSWORD_HASH, password)
return jsonify(GENERIC_ERROR), 401
# Check password
if not check_password_hash(user.password_hash, password):
logger.warning(f'Failed login attempt for user: {username}')
return jsonify(GENERIC_ERROR), 401
# Success - return only safe data
token = generate_token(user.id)
logger.info(f'Successful login for user: {username}')
return jsonify({
'token': token,
'user': {
'id': user.id,
'username': user.username
# NO password_hash, api_key, or other sensitive fields
}
})
Why this works: Every failure path returns the same body ("Invalid credentials") under the same 401, so the login form no longer answers the question of whether an account exists. The dummy check when the user doesn't exist verifies against a hash Werkzeug generated itself, so the "user not found" path costs what "wrong password" costs and timing no longer separates them - but only while the dummy is built with the same method and parameters as the stored hashes, since check_password_hash takes both from the hash string it is given. A scrypt dummy standing in for pbkdf2 user hashes reintroduces the difference it was added to remove, so derive both from one constant. The hash has to come from generate_password_hash rather than being a literal: Werkzeug parses method$salt$hash and supports scrypt and pbkdf2, so a bcrypt-shaped string such as $2b$12$... raises ValueError: Invalid hash method '' - a 500 on every unknown username, which is a louder enumeration signal than the one being closed. The distinction the client no longer sees is still recorded: logger.warning() writes which of the two failures occurred, so monitoring can still spot an account being probed. The successful response returns only id, username and the token, never password_hash, api_key, is_admin, or other sensitive attributes.
Secure Logging with Sensitive Data Filtering
# SECURE - Filtering sensitive data from logs
from flask import Flask, request, jsonify
import logging
import re
from typing import Any, Dict
app = Flask(__name__)
class SensitiveDataFilter(logging.Filter):
"""Custom filter to redact sensitive information from logs"""
SENSITIVE_PATTERNS = {
'password': re.compile(r'(["\']?password["\']?\s*[:=]\s*["\']?)([^"\'}\s,]+)', re.IGNORECASE),
'token': re.compile(r'(["\']?token["\']?\s*[:=]\s*["\']?)([^"\'}\s,]+)', re.IGNORECASE),
'api_key': re.compile(r'(["\']?api_?key["\']?\s*[:=]\s*["\']?)([^"\'}\s,]+)', re.IGNORECASE),
'credit_card': re.compile(r'\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b'),
'cvv': re.compile(r'(["\']?cvv["\']?\s*[:=]\s*["\']?)(\d{3,4})', re.IGNORECASE),
}
@classmethod
def redact_sensitive_data(cls, message: str) -> str:
"""Redact sensitive data from log message"""
redacted = message
for pattern_name, pattern in cls.SENSITIVE_PATTERNS.items():
if pattern_name == 'credit_card':
redacted = pattern.sub(r'XXXX-XXXX-XXXX-XXXX', redacted)
else:
redacted = pattern.sub(r'\1[REDACTED]', redacted)
return redacted
def filter(self, record):
# Render first. %-style arguments are substituted by the handler after
# every filter has run, so rewriting record.msg alone leaves
# logger.info('card: %s', pan) untouched. Collapsing the record to one
# rendered string covers the arguments too.
record.msg = self.redact_sensitive_data(record.getMessage())
record.args = ()
return True
class SensitiveDataFormatter(logging.Formatter):
"""Redacts the traceback, which the filter above cannot reach.
exc_info is rendered by the FORMATTER, not from record.getMessage(), so a
filter that rewrites the message leaves the exception text untouched.
"""
def formatException(self, ei):
return SensitiveDataFilter.redact_sensitive_data(super().formatException(ei))
# Configure logging with sensitive data filtering.
# The filter covers the message and its arguments; the formatter covers the
# traceback. Both are needed - see the note under this example.
handler = logging.FileHandler('/var/log/app/application.log')
handler.addFilter(SensitiveDataFilter())
handler.setFormatter(SensitiveDataFormatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
logger = logging.getLogger(__name__)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
def sanitize_for_logging(data: Dict[str, Any]) -> Dict[str, Any]:
"""Remove sensitive fields before logging"""
SENSITIVE_FIELDS = {
'password', 'password_hash', 'token', 'api_key',
'secret', 'credit_card', 'card_number', 'cvv', 'ssn'
}
return {
k: '[REDACTED]' if k.lower() in SENSITIVE_FIELDS else v
for k, v in data.items()
}
@app.route('/api/payment', methods=['POST'])
def process_payment():
data = request.json
# Sanitize before logging
safe_data = sanitize_for_logging(data)
logger.info(f'Processing payment: {safe_data}')
try:
result = charge_card(data['card_number'], data['cvv'])
return jsonify({'status': 'success', 'transaction_id': result['id']})
except Exception as e:
# Log error without sensitive data
logger.error(f'Payment failed for user {data.get("user_id")}', exc_info=True)
return jsonify({'error': 'Payment processing failed'}), 500
Why this works: sanitize_for_logging() is the control that matters: the sensitive values never reach the log call, so nothing downstream has to recognise them in rendered text. It is a denylist of field names and carries that weakness - a newly added sensitive field is logged until someone adds it to SENSITIVE_FIELDS - so it belongs next to the code that builds the payload, where the two are changed together. It also only inspects the top level, so a payload shaped {'card': {'number': ..., 'cvv': ...}} passes straight through; recurse into nested dicts if request bodies are nested.
SensitiveDataFilter is a backstop for strings assembled somewhere you do not control. It redacts record.getMessage() and then clears record.args, because a filter that only rewrites record.msg misses logger.info('card: %s', pan) entirely: the arguments are substituted by the handler once every filter has already run. Clearing the arguments is the trade-off - structured handlers that read record.args (JSON formatters, log shippers) see one rendered string instead of a message and its fields.
Attach the filter to the handler, not the logger. A filter on a logger sees only records logged directly on that logger - not records from its children, and not records that reach it by propagation - so a redactor registered with logger.addFilter() silently covers a fraction of what is written.
The filter cannot see the traceback, which is why the formatter is there too. exc_info=True is rendered by the formatter, from the exception object, not from record.getMessage() - so a filter that redacts the message leaves the exception verbatim. Measured on CPython 3.13 with the filter alone, logger.error('Payment failed for user %s', uid, exc_info=True) wrote:
Payment failed for user u-1
Traceback (most recent call last):
...
ValueError: charge declined for password=secret123 card 4111111111111111
The message line is clean and the exception is not. This is not an unusual case: driver, HTTP-client and validation exceptions routinely quote the value that caused them, and the page's own payment route logs with exc_info=True. Overriding formatException closes it. The same gap exists in Java's logback, where %safemsg filters the message while PatternLayout appends the throwable through a separate converter.
Logs remain useful for debugging - transaction IDs, user IDs, timestamps and error context all survive - while credit cards become "XXXX-XXXX-XXXX-XXXX" and passwords become "[REDACTED]" on both the message and the traceback.
Django REST Framework Serializer Pattern
# SECURE - Using DRF serializers for field control
from rest_framework import serializers, viewsets
from rest_framework.response import Response
from django.contrib.auth.models import User
# Public serializer - only safe fields
class UserPublicSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'date_joined']
# Explicitly exclude: password, is_staff, is_superuser, last_login
read_only_fields = ['id', 'date_joined']
# Admin serializer - more fields for authorized users
class UserAdminSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'date_joined', 'is_active', 'last_login']
# Still exclude: password, is_staff, is_superuser
read_only_fields = ['id', 'date_joined', 'last_login']
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
def get_serializer_class(self):
# Use different serializers based on permission level
if self.request.user.is_staff:
return UserAdminSerializer
return UserPublicSerializer
def retrieve(self, request, pk=None):
user = self.get_object()
serializer = self.get_serializer(user)
return Response(serializer.data)
# settings.py - Production security settings
DEBUG = False # Never True in production!
SECRET_KEY = os.environ.get('SECRET_KEY') # Never hardcode
# Custom exception handler for production
from rest_framework.views import exception_handler
def custom_exception_handler(exc, context):
response = exception_handler(exc, context)
if response is not None:
# Remove detailed error messages in production
response.data = {
'error': 'An error occurred',
'error_code': f'ERR_{response.status_code}'
}
return response
REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'myapp.custom_exception_handler',
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
}
Why this works: Django REST Framework's ModelSerializer with an explicit Meta.fields list is a strict allowlist: a column added to the model stays out of the response until someone names it there. Selecting between UserPublicSerializer and UserAdminSerializer by permission level means the administrative fields are absent from the response a regular user gets, rather than present and filtered later. DEBUG=False keeps Django's detailed error pages - SQL queries, file paths, settings, environment variables - away from whoever triggered the exception, and custom_exception_handler replaces DRF's own detailed error bodies with a generic message and a status-derived code. read_only_fields stops a client modifying the fields it lists even if those names appear in a request body.
FastAPI with Pydantic Models
# SECURE - Using Pydantic for response validation
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse # NOT exported from `fastapi`
from pydantic import BaseModel, ConfigDict, EmailStr
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import Session, declarative_base
import logging
import uuid
Base = declarative_base()
app = FastAPI()
logger = logging.getLogger(__name__)
# Database model (ORM)
class UserDB(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
username = Column(String)
email = Column(String)
password_hash = Column(String) # NEVER expose this
api_key = Column(String) # NEVER expose this
# Response model - only public fields
class UserResponse(BaseModel):
id: int
username: str
email: EmailStr
# Explicitly exclude: password_hash, api_key
model_config = ConfigDict(from_attributes=True)
@app.get('/api/user/{user_id}', response_model=UserResponse)
async def get_user(user_id: int, db: Session = Depends(get_db)):
# Session.get(), not the legacy Query.get() - the latter is deprecated
# in SQLAlchemy 1.4 and retained only for compatibility in 2.x
user = db.get(UserDB, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found' # Generic message
)
# response_model builds the body from UserResponse, so password_hash and
# api_key cannot reach the client even though the ORM object carries them
return user
# Validation errors: FastAPI's default body echoes the rejected VALUE.
# Replace it, or every 422 hands the submitted data back to the caller.
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
# Log which fields failed; never log or echo what was submitted
logger.warning('Validation failed for %s', [e['loc'] for e in exc.errors()])
return JSONResponse(
status_code=422,
content={'detail': 'Invalid input provided'}
)
# Global exception handler
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
# Log full error server-side, under a correlation id the client also gets
error_id = str(uuid.uuid4())
logger.error('error_id=%s unhandled exception', error_id, exc_info=True)
return JSONResponse(
status_code=500,
content={
'error': f'Internal server error (ref {error_id})',
'error_code': 'INTERNAL_ERROR'
}
)
Why this works: response_model=UserResponse makes FastAPI build the response body from the declared schema rather than from the object the handler returned, so returning the ORM instance directly is safe here - password_hash and api_key are on the object and are not in the schema, so they are not in the body. That is the inversion worth having: exposure requires adding a field to UserResponse, and no amount of change to UserDB widens the response on its own.
Import JSONResponse explicitly. It is not re-exported from the fastapi top-level namespace, so a handler written with the import list above minus that line raises NameError: name 'JSONResponse' is not defined at the moment it is needed - which is while handling an exception. Measured on FastAPI 0.141.1, the handler logged correctly and then died, and the caller received a 500 with an empty body rather than the generic JSON the handler was written to send. The failure only appears on the error path, which is the path least likely to be exercised.
FastAPI's default validation response returns the value that was rejected. This one is not in your code at all, which is why it survives review. Post a body missing a required field and the stock 422 body is:
{"detail":[{"type":"missing","loc":["body","password"],"msg":"Field required","input":{"username":"alice"}}]}
The input key holds what the caller sent. On a login or registration route that means the submitted password comes straight back in the response, and travels into every access log, proxy and error tracker on the way. Measured on FastAPI 0.141.1 with Pydantic 2.13.4. Registering a RequestValidationError handler that logs loc and returns a fixed string keeps the diagnostic value and closes the channel. CWE-209 covers the same handler set from the error-message side, including why Starlette's HTTPException rather than FastAPI's is the one to register.
Testing
A re-scan confirms the reported line changed; it cannot confirm any of the assertions below, because each one either passes silently while the endpoint still leaks or fails only on traffic the scanner never sends. Test the serialized response and the written log, not the source.
- The response field set is exactly the DTO's.
assert set(data) == {'id', 'username', 'email'}, notassert 'password_hash' not in data- absence passes for a field dropped by accident and says nothing when a new model field starts being serialized. - Two failing logins are indistinguishable. Same status, same body, same headers for an unknown username and a wrong password, and neither response contains the submitted username.
- A validation failure does not echo what was submitted. Post a body with a bad password field and assert the submitted value is absent from the response. This is the assertion that catches FastAPI's default
inputkey, which no code of yours produced. - Every status the framework can produce is still itself. An unknown path returns 404 and a bad method 405, not 500 - the assertion that catches an
Exceptionhandler registered without anHTTPExceptionone. - A 500 body contains no frame, path or module name, and the log contains the traceback. Asserting both is what separates "suppressed" from "lost".
- Canary values do not reach the log, in the message or the traceback. Log through the exception path as well as the message path; the traceback is rendered by the formatter and a message-level redactor does not touch it.
Framework-specific considerations:
- Django: Test
serializers.ModelSerializerfields, verify admin panel security, check DEBUG=False in production - Flask: Test response schemas, validate error handlers, verify session cookie security
- FastAPI: Test Pydantic
response_modeleffectiveness, validate automatic docs exclusions - SQLAlchemy: Verify hybrid properties don't leak sensitive data, test relationship lazy loading
The following examples write those assertions out against a Flask test client; adapt the fixtures to your framework, ORM and domain objects.
# SECURE - Comprehensive tests for information disclosure prevention
import json
import logging
# The logging filter and formatter from the section above, as their own module
from log_redaction import SensitiveDataFilter, SensitiveDataFormatter
def test_user_response_field_set_is_exact():
"""The response contains the DTO's fields and nothing else.
Equality, not absence. A list of `assert 'api_key' not in data` lines
passes for a field that was dropped by accident, and says nothing at all
about a field nobody has thought of yet - `phone`, `last_login`, or
whatever the next migration adds. This assertion fails on that field the
first time it is serialized, which is the point of having the test.
"""
# Arrange
response = client.get('/api/user/123')
data = response.get_json()
# Assert - the whole contract in one line
assert set(data.keys()) == {'id', 'username', 'email'}
def test_error_response_no_stack_trace():
"""Test that error responses don't expose stack traces"""
# Arrange - trigger an error
response = client.post('/api/process', json={'invalid': 'data'})
data = response.get_json()
# Assert - no sensitive error details
assert 'traceback' not in json.dumps(data).lower()
assert 'stack' not in json.dumps(data).lower()
assert '/home/' not in json.dumps(data) # No file paths
assert '.py' not in json.dumps(data) # No Python filenames
# Assert - has generic error
assert 'error' in data
assert 'error_code' in data
def test_login_no_user_enumeration():
"""Test that login doesn't reveal which usernames exist"""
# Test with non-existent user
response1 = client.post('/api/login', json={
'username': 'nonexistent_user_12345',
'password': 'anything'
})
# Test with existing user but wrong password
response2 = client.post('/api/login', json={
'username': 'existing_user',
'password': 'wrong_password'
})
# Both should return same generic error
assert response1.status_code == response2.status_code == 401
assert response1.get_json() == response2.get_json()
assert 'Invalid credentials' in response1.get_json()['error']
def test_logs_no_sensitive_data(tmp_path):
"""Submitted secrets appear nowhere in what the handler actually writes.
Reads the handler's own output rather than caplog: the redaction lives on
a handler, and caplog attaches a separate one to the root logger, so a
caplog-based assertion can pass or fail for reasons unrelated to the fix.
Assert on distinctive canary values, never on short strings like the CVV -
'123' occurs in timestamps and would make the test pass by accident.
"""
# Arrange - point the redacting handler at a file this test can read
log_file = tmp_path / 'application.log'
handler = logging.FileHandler(log_file)
handler.addFilter(SensitiveDataFilter())
handler.setFormatter(SensitiveDataFormatter('%(levelname)s %(message)s'))
app_logger = logging.getLogger('myapp')
app_logger.handlers = [handler]
app_logger.setLevel(logging.INFO)
# Act
client.post('/api/payment', json={
'username': 'testuser',
'password': 'Canary-PW-8f2a',
'card_number': '4111111111111111',
'cvv': '987',
})
handler.flush()
log_output = log_file.read_text()
# Assert - no submitted secret survives, in the message or the traceback
assert 'Canary-PW-8f2a' not in log_output
assert '4111111111111111' not in log_output
# Assert - the log is still useful: the redaction ran rather than the
# record simply being dropped
assert '[REDACTED]' in log_output or 'XXXX-XXXX-XXXX-XXXX' in log_output
assert 'testuser' in log_output
def test_serializer_field_allowlist():
"""Serializer output is exactly the declared field set, nothing more.
Asserting equality rather than absence is the point: `'password' not in
data` also passes for a field that was dropped by accident, and says
nothing when a new model field starts being serialized.
"""
# Arrange - only fields the model actually declares. Django's Model
# __init__ raises TypeError on an unknown keyword, so a test written
# against invented field names errors before it asserts anything.
from myapp.serializers import UserPublicSerializer
from django.contrib.auth.models import User
user = User(
id=1,
username='testuser',
email='test@example.com',
password='pbkdf2_sha256$600000$abc123', # the real field name
is_staff=True,
is_superuser=True,
)
# Act
serializer = UserPublicSerializer(user)
data = serializer.data
# Assert - the field set is exactly what Meta.fields declares
assert set(data.keys()) == {'id', 'username', 'email', 'date_joined'}
assert 'password' not in data
assert 'is_staff' not in data and 'is_superuser' not in data
def test_dto_conversion_safe():
"""Test that DTO conversion filters sensitive fields"""
# Arrange
from myapp.dto import UserDTO
user = MockUser(
id=1,
username='test',
email='test@example.com',
password_hash='hash123',
reset_token='token456'
)
# Act
dto = UserDTO.from_model(user)
data = dto.to_dict()
# Assert - exact set again, for the same reason as above: to_dict() is
# hand-written, so it is the place a field gets added without thought
assert set(data.keys()) == {'id', 'username', 'email'}
assert data['username'] == 'test'
def test_environment_variables_not_exposed():
"""Test that environment variables are not exposed in responses"""
# Arrange - set sensitive env var
import os
os.environ['SECRET_KEY'] = 'super-secret-value'
# Act - call various endpoints
response = client.get('/api/config')
# Assert - secret not in response
response_text = json.dumps(response.get_json())
assert 'super-secret-value' not in response_text
assert 'SECRET_KEY' not in response_text or 'REDACTED' in response_text
# Test cases cover:
# 1. Field filtering in API responses
# 2. Error message sanitization
# 3. User enumeration prevention
# 4. Log redaction
# 5. Serializer field control
# 6. DTO conversion safety
# 7. Environment variable protection
Common Pitfalls
- Serializing
__dict__orvars()instead of a schema:jsonify(user.__dict__)orvars(user)returns every attribute the ORM instance happens to have, including SQLAlchemy's own internal_sa_instance_stateand any column added later - there is no allowlist, only whatever the object contains at that moment. - A DRF/Pydantic serializer field list edited in one place but not the other:
UserPublicSerializerandUserAdminSerializer(or two Pydantic response models) drift apart over time if a new field is added to one and forgotten in the other - review both whenever the underlying model changes, not just the one you're currently touching. DEBUG = True(orFLASK_DEBUG=1) surviving in a shared settings file or environment default: Django's and Flask's debug pages expose full stack traces, local variables, and settings values to anyone who can trigger an unhandled exception - confirm the production deployment's actual environment, not just the value insettings.py.- An f-string or
%-formatted log message built before the filter runs:logging.info(f'Processing payment: {data}')has already rendered the sensitive values into the message string by the time alogging.Filtersees it - filters that regex-match the rendered text can still miss fields whose format wasn't anticipated, so prefer sanitizing the dict before it is ever passed to the logger.