Skip to content

CWE-209: Generation of Error Message Containing Sensitive Information - Python

Overview

CWE-209 in Python applications commonly occurs when exception details, stack traces, or debug information are returned to users through web responses, API outputs, or error pages. Python tracebacks are detailed enough to be worth reading while developing, and that same detail hands an attacker file paths, code structure, library versions, and the text of failing SQL queries.

Primary Defence: Return generic error messages to users while logging detailed exceptions server-side, set DEBUG = False in Django production, and use custom error handlers in Flask.

Common Vulnerable Patterns

Returning Raw Exception Messages to Users

# VULNERABLE - Exposes database errors and SQL queries
from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/user/<int:user_id>')
def get_user(user_id):
    try:
        user = db.execute(f"SELECT * FROM users WHERE id = {user_id}")
        return jsonify(user)
    except Exception as e:
        # Returns: "no such table: users" or SQL syntax errors
        return jsonify({'error': str(e)}), 500

Why this is vulnerable:

  • Exposes table and column names, SQL syntax errors, and the relationships between tables.
  • Reveals database vendor details and constraint names.
  • Enables targeted SQL injection planning.

Exposing Full Stack Traces in Responses

# VULNERABLE - Returns complete traceback with file paths
import traceback
from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/process')
def process_data():
    try:
        result = perform_complex_operation()
        return jsonify(result)
    except Exception as e:
        # Exposes: file paths, code structure, library versions
        return jsonify({
            'error': str(e),
            'traceback': traceback.format_exc()
        }), 500

Why this is vulnerable:

  • Exposes file paths, function names, and line numbers.
  • Reveals module imports and package versions, pointing attackers at specific dependencies.
  • Maps internal code flow and structure.

Debug Mode Enabled in Production

# VULNERABLE - Flask debug mode shows interactive debugger
from flask import Flask

app = Flask(__name__)
app.config['DEBUG'] = True  # NEVER in production!

if __name__ == '__main__':
    app.run(debug=True)  # Interactive debugger with code execution!

Why this is vulnerable:

  • The exposed Werkzeug debugger lets an attacker run commands from the browser if its PIN is compromised, bypassed, or disabled.
  • Exposes environment variables and database access.
  • Any unhandled exception is enough to put that debugger page in front of whoever triggered it.

Verbose Error Messages with Internal Details

# VULNERABLE - Reveals file paths and internal logic
from flask import Flask, request, jsonify

app = Flask(__name__)
@app.route('/upload', methods=['POST'])
def upload_file():
    try:
        file = request.files['document']
        file.save(f'/var/www/uploads/{file.filename}')
        return jsonify({'status': 'success'})
    except Exception as e:
        # Returns: "Permission denied: /var/www/uploads/file.txt"
        return jsonify({'error': f'Failed to save file: {str(e)}'}), 500

Why this is vulnerable:

  • Leaks the upload directory's absolute path, and with it the web root and filesystem layout.
  • Exposes permission details and upload handling logic.
  • Helps identify traversal or overwrite targets.

Logging Sensitive Data to User-Accessible Locations

# VULNERABLE - Logs passwords and tokens
import logging
from flask import Flask, request, jsonify

app = Flask(__name__)

logging.basicConfig(filename='app.log', level=logging.DEBUG)

@app.route('/login', methods=['POST'])
def login():
    username = request.json['username']
    password = request.json['password']

    # Logs plaintext password!
    logging.info(f'Login attempt: {username}, {password}')

    if authenticate(username, password):
        return jsonify({'token': generate_token()})

Why this is vulnerable:

  • Logs plaintext passwords, tokens, and API keys.
  • Anyone who can read the log file gets those credentials, including through log aggregation and backups.
  • Enables account takeover without alerts.

Secure Patterns

Generic Error Messages with Server-Side Logging

# SECURE - Generic user errors, detailed server logs
from flask import Flask, jsonify
import logging

app = Flask(__name__)

logging.basicConfig(
    filename='/var/log/myapp/errors.log',
    level=logging.ERROR,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)

@app.route('/user/<int:user_id>')
def get_user(user_id):
    try:
        user = db.session.get(User, user_id)
        if not user:
            return jsonify({'error': 'Resource not found'}), 404
        return jsonify(user.to_dict())
    except Exception as e:
        # Log diagnostic details server-side
        logging.error(f'Error fetching user {user_id}: {str(e)}', exc_info=True)
        # Return generic message to user
        return jsonify({'error': 'An error occurred processing your request'}), 500

Why this works:

  • Full tracebacks are logged server-side only.
  • Clients receive generic messages with no internals.
  • Logs are stored outside the web root.
  • ORM lookups avoid SQL injection and noisy errors.

Custom Error Handler with Error Codes

# SECURE - Error codes for tracking, no sensitive details
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException
import uuid
import logging

app = Flask(__name__)

@app.errorhandler(HTTPException)
def handle_http_exception(error):
    # 404, 405, 413, 429 and friends: keep the status the router chose.
    # error.name is Werkzeug's generic reason phrase - no app internals in it.
    return jsonify({'error': error.name}), error.code

@app.errorhandler(Exception)
def handle_error(error):
    # Generate unique error ID for tracking
    error_id = str(uuid.uuid4())

    # Log with error ID and diagnostic details
    logging.error(f'Error ID {error_id}: {str(error)}', exc_info=True)

    # Return generic message with tracking ID
    return jsonify({
        'error': 'An error occurred',
        'error_id': error_id,
        'message': 'Please contact support with this error ID'
    }), 500

Why this works:

  • The Exception handler catches everything the application did not expect, logs it whole, and returns one generic body with a correlation ID.
  • Full tracebacks stay server-side with exc_info=True.
  • The HTTPException handler is what keeps that from being too greedy, and it has to be there. @app.errorhandler(Exception) matches by walking the exception's MRO, and every routing error Werkzeug raises - NotFound, MethodNotAllowed, RequestEntityTooLarge - is an Exception. Registering only the Exception handler and a 404 one turns every other client error into a 500: measured on Flask 3.1.3, POST to a GET-only route answers 500 {"error": "An error occurred", "error_id": ...} instead of 405. That breaks conditional-request and rate-limit handling in clients, and it fills the error log with 500s that are nothing of the kind. Flask consults the more specific registration first, so HTTPException wins for those and Exception keeps the rest.

Django Production Settings

# SECURE - Django production configuration
# settings.py

DEBUG = False  # CRITICAL: Must be False in production
ALLOWED_HOSTS = ['yourdomain.com']

# Custom error handlers
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{levelname} {asctime} {module} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'file': {
            'level': 'ERROR',
            'class': 'logging.FileHandler',
            'filename': '/var/log/django/error.log',
            'formatter': 'verbose',
        },
    },
    'loggers': {
        'django': {
            'handlers': ['file'],
            'level': 'ERROR',
            'propagate': True,
        },
    },
}

# Custom error views
# views.py
from django.http import JsonResponse

def custom_500(request):
    return JsonResponse({
        'error': 'Internal server error',
        'message': 'Please try again later'
    }, status=500)

# urls.py
handler500 = 'myapp.views.custom_500'

Why this works:

  • DEBUG = False disables detailed error pages.
  • ALLOWED_HOSTS blocks host header abuse.
  • Errors are logged to /var/log/django/error.log, outside the web root.
  • handler500 returns a generic 500 body for anything that reaches it, so an unexpected traceback is never what the client sees.

FastAPI Exception Handlers

# SECURE - FastAPI with custom exception handlers
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
# Starlette's, not fastapi.HTTPException - the latter is a subclass and would
# miss the routing errors Starlette raises directly. See below.
from starlette.exceptions import HTTPException as StarletteHTTPException
import logging
import uuid

app = FastAPI()

logging.basicConfig(
    filename='/var/log/fastapi/app.log',
    level=logging.ERROR,
    format='%(asctime)s - %(levelname)s - %(message)s'
)

@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
    error_id = str(uuid.uuid4())

    # Log diagnostic error details
    logging.error(
        f'Error ID {error_id}: {str(exc)}',
        exc_info=True,
        extra={'path': request.url.path}
    )

    # Return generic error
    return JSONResponse(
        status_code=500,
        content={
            'error': 'Internal server error',
            'error_id': error_id
        }
    )

@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request, exc):
    # Only raise HTTPException with user-safe detail strings.
    # headers= matters: a 401 without WWW-Authenticate is a broken 401.
    return JSONResponse(
        status_code=exc.status_code,
        content={'detail': exc.detail},
        headers=getattr(exc, 'headers', None)
    )

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
    # Log which fields failed; never echo what was submitted.
    logging.warning('Validation failed for %s', [e['loc'] for e in exc.errors()])
    return JSONResponse(
        status_code=422,
        content={'detail': 'Invalid input provided'}
    )

Application code still raises fastapi.HTTPException as usual - it subclasses StarletteHTTPException, so the handler above catches it.

Why this works:

  • The Exception handler sanitizes anything unexpected, with an error ID correlating the client's report to a full server-side traceback.
  • Unlike Flask, FastAPI's Exception handler does not swallow routing errors: Starlette dispatches an HTTPException to the handler registered for its type rather than walking up to Exception, so status codes survive.
  • Register Starlette's HTTPException, not fastapi.HTTPException, which is what the FastAPI documentation says and what running it confirms. Starlette raises the bare parent class for a route that does not exist and for a method that is not allowed; a handler registered for the subclass does not match those, and they fall through to FastAPI's built-in default instead. Measured on FastAPI 0.141.1: with fastapi.HTTPException registered, GET /nope and POST to a GET-only route both returned FastAPI's default body and never entered the handler, while an HTTPException raised by application code did. The difference is invisible in testing unless you assert on the body, because the status codes are identical either way and the default body is itself harmless - what you lose is the single point of control, so a later change to the error shape silently applies to some 4xx responses and not others.
  • headers=exc.headers keeps whatever the raising code attached. Dropping it is easy to miss because the status code still looks right: measured on FastAPI 0.141.1, a handler without it turned HTTPException(401, headers={'WWW-Authenticate': 'Bearer'}) into a 401 with no WWW-Authenticate at all, which is what FastAPI's own OAuth2PasswordBearer flow raises and what an RFC 9110 401 requires.
  • The validation handler closes a leak that is otherwise on by default and is easy to overlook, because it lives in the framework rather than in your code. FastAPI's built-in RequestValidationError response returns each failure with its loc and its input - the value that was submitted. Post a too-short password and the default 422 body contains {"loc":["body","password"],"msg":"String should have at least 12 characters","input":"hunter2"}, so the endpoint hands the submitted password back to whoever sent it, and into every access log, proxy and error tracker on the way. Verified on Pydantic 2.13.4. Logging loc server-side keeps the diagnostic value; the client gets a fixed string.

Keep the body key as detail. FastAPI's default handlers use it, its generated OpenAPI schema documents it, and renaming it to error is a breaking change for every client that reads the field - a real one, since the handler applies to every HTTPException in the application, not just new code.

Structured Logging with Redaction

# SECURE - Redacting formatter, attached to the handler
import logging
import logging.handlers
import re
from flask import Flask, request, jsonify

app = Flask(__name__)

SENSITIVE_PATTERNS = [
    (re.compile(r'(password["\']?\s*[:=]\s*["\']?)[^"\'}\s]+', re.IGNORECASE), r'\1***REDACTED***'),
    (re.compile(r'(token["\']?\s*[:=]\s*["\']?)[^"\'}\s]+', re.IGNORECASE), r'\1***REDACTED***'),
    (re.compile(r'\b\d{13,19}\b'), r'***CARD***'),  # Credit card numbers
    (re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b'), r'***EMAIL***'),
]


class RedactingFormatter(logging.Formatter):
    """Redact the fully rendered line, traceback included."""

    def format(self, record):
        text = super().format(record)
        for pattern, replacement in SENSITIVE_PATTERNS:
            text = pattern.sub(replacement, text)
        return text


# Attach to the HANDLER, not to a logger - see below.
handler = logging.handlers.RotatingFileHandler('/var/log/myapp/app.log')
handler.setFormatter(RedactingFormatter(
    '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
))
logging.getLogger().addHandler(handler)
logging.getLogger().setLevel(logging.INFO)

logger = logging.getLogger('myapp')

# Usage
@app.route('/process', methods=['POST'])
def process_payment():
    try:
        data = request.json
        # Log request (sensitive data will be redacted)
        logger.info(f'Processing payment: {data}')
        return jsonify({'status': 'success'})
    except Exception as e:
        logger.error(f'Payment processing failed: {str(e)}', exc_info=True)
        return jsonify({'error': 'Payment processing failed'}), 500

Why this works:

  • The formatter runs at the last point before a line reaches the file, so it covers every record arriving at this handler - from the root logger, from logging.getLogger(__name__) in any module, and from any library that logs through the standard hierarchy.
  • Because it rewrites the rendered line rather than record.msg, the traceback is covered too. That is the field that matters most here: the message is a string you wrote, while exc_info=True appends the exception's own text, which is where a connection string or a driver's echo of a parameter actually shows up.
  • Case-insensitive matching covers Password, PASSWORD and password.

Attaching this to a logger rather than a handler is the mistake to avoid, and it is the more natural-looking of the two. logging.getLogger('myapp').addFilter(...) reads as "filter everything under myapp" and does not do that. A logger's filters run only for calls made on that exact logger: records propagating up from myapp.payments are not filtered, records from the root logger are not filtered, and exc_info tracebacks are never filtered because the traceback is rendered by the formatter from record.exc_info, which a filter rewriting record.msg never touches. Measured on Python 3.13, a filter registered that way redacted the one call the example made on myapp and let a plaintext password through from a child logger, from a root-logger call, and from inside a traceback - so the example demonstrating the control was the only place in the application protected by it.

As on any page suggesting pattern redaction, treat it as a backstop for something that got past review rather than a licence to log secrets. It matches the shapes it was told about; db_password in a dict rendered with %r, or a value whose key sits in a different field, both go through unchanged.

Environment-Aware Error Handling

# SECURE - Different error handling for dev vs production
import os
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException
import logging

app = Flask(__name__)

# Opt IN to verbosity. An unset, misspelled or empty ENV yields False, so the
# quiet response is what a deployment gets by forgetting to configure anything.
IS_DEVELOPMENT = os.getenv('ENV') == 'development'

@app.errorhandler(HTTPException)
def handle_http_exception(error):
    # As above: without this, every 4xx becomes a 500.
    return jsonify({'error': error.name}), error.code

@app.errorhandler(Exception)
def handle_exception(error):
    # Log diagnostic details in all environments
    logging.error(f'Error: {str(error)}', exc_info=True)

    if IS_DEVELOPMENT:
        # Detailed error in development only
        return jsonify({
            'error': str(error),
            'type': type(error).__name__
        }), 500

    # Everywhere else, including any environment that did not identify itself
    return jsonify({
        'error': 'An error occurred',
        'message': 'Please try again later'
    }), 500

Why this works:

  • Production responses are generic, carrying no exception text or type name.
  • Development responses retain details for debugging.
  • Full errors are always logged server-side, in both environments, so the production path is never the only one that has been exercised.
  • The check tests for development, not for production, and that direction is the whole point. Written the other way round - IS_PRODUCTION = os.getenv('ENV') == 'production' - the branch that ships stack traces is the one an unconfigured deployment takes. Every way of getting the variable wrong is a way of getting the verbose response: unset, empty, Production with a capital P, prod, or a container that never inherited the app's env block. Testing for development reverses which mistakes are dangerous, so the failure mode of a misconfigured deployment is a developer wondering why their local errors are terse rather than a production endpoint printing tracebacks. Whenever a security control reads an environment variable, ask which branch the empty string selects.

Common Pitfalls

  • @app.errorhandler(500) where you meant @app.errorhandler(Exception): the two look interchangeable and behave differently under debug=True. Flask propagates an unhandled exception to the Werkzeug debugger before the 500 handler is consulted, so a 500 handler is bypassed in development and first runs in production - where any bug in it, or in the environment check gating it, shows up for the first time. An Exception handler is resolved earlier, in handle_user_exception, and runs in both. Measured on Flask 3.1.3 against a live app.run(debug=True) server: the Exception handler returned its sanitized 61-byte 500, while the 500 handler was skipped and Werkzeug served a 15.8 KB interactive debugger page carrying the exception message. Register Exception and test with debug=True at least once, so you have seen the handler run.
  • HTTPException(detail=str(exc)) treated as already-sanitized: wrapping a caught lower-level exception's message into an HTTPException's detail field looks like deliberate, safe error handling, but the custom handler passes detail straight through to the client - the raw driver or library text is still there, just relocated.
  • A custom Django handler500 view defined, but DEBUG=True still set in the environment that serves traffic: Django only invokes custom error views when DEBUG=False; a settings override file, a .env value, or a deploy script that leaves DEBUG=True in what is otherwise the production environment means the custom view was never actually reachable.
  • A traceback embedded inside a nested object or nonstandard field name that a RedactingFormatter pattern wasn't written to match: the redaction filter matches string patterns in the rendered log message - a dict logged via %r/repr(), a custom object's __str__, or a field the pattern list doesn't anticipate (db_password, authToken) can carry sensitive text past a filter that looks comprehensive but is really an incomplete allowlist of known formats.

Additional Resources