Cross-site scripting (XSS) from unescaped response content via Flask make_response

High Risk Cross-Site Scripting
pythonflaskxssmake_responseweb

What it is

XSS vulnerability in Flask applications where response content is built with make_response and renders untrusted data as HTML without auto-escaping or encoding, allowing attackers to execute scripts in users browsers.

# VULNERABLE: Flask app with make_response XSS
from flask import Flask, request, make_response, session
import json
from datetime import datetime

app = Flask(__name__)
app.secret_key = 'your-secret-key'

@app.route('/dashboard/<user_id>')
def dashboard(user_id):
    """User dashboard with customization"""
    username = request.args.get('name', 'User')
    theme = request.args.get('theme', 'default')
    welcome_msg = request.args.get('msg', '')

    # VULNERABLE: All parameters directly in HTML
    dashboard_html = f"""
    <!DOCTYPE html>
    <html>
    <head>
        <title>Dashboard - {username}</title>
        <style>
            body {{
                background: {theme};
                font-family: Arial, sans-serif;
            }}
        </style>
    </head>
    <body>
        <h1>Welcome {username}!</h1>
        <div id="user-id" data-user="{user_id}">User ID: {user_id}</div>
        {f'<div class="welcome-message">{welcome_msg}</div>' if welcome_msg else ''}
        <div id="content">
            <p>Your personalized dashboard</p>
        </div>
        <script>
            var currentUser = '{username}';
            var userId = '{user_id}';
        </script>
    </body>
    </html>
    """

    response = make_response(dashboard_html)
    response.headers['Content-Type'] = 'text/html'
    return response

@app.route('/preview', methods=['POST'])
def preview_content():
    """Preview user-generated content"""
    title = request.form.get('title', '')
    content = request.form.get('content', '')
    author = request.form.get('author', 'Anonymous')
    tags = request.form.get('tags', '')

    # VULNERABLE: Form data directly in HTML preview
    preview_html = f"""
    <div class="preview-container">
        <article class="content-preview">
            <h1>{title}</h1>
            <div class="meta">
                <span class="author">By: {author}</span>
                <span class="tags">Tags: {tags}</span>
                <span class="date">{datetime.now().strftime('%Y-%m-%d')}</span>
            </div>
            <div class="content">
                {content}
            </div>
        </article>
        <div class="preview-actions">
            <button onclick="publishContent()">Publish</button>
            <button onclick="editContent()">Edit</button>
        </div>
    </div>
    <script>
        function publishContent() {{
            alert('Publishing: {title}');
        }}
    </script>
    """

    response = make_response(preview_html)
    response.headers['Content-Type'] = 'text/html'
    return response

@app.route('/chat/<room_id>')
def chat_room(room_id):
    """Chat room with message display"""
    username = session.get('username', 'Anonymous')
    last_message = request.args.get('last_msg', '')
    room_name = request.args.get('room_name', f'Room {room_id}')

    # VULNERABLE: Session and parameter data in HTML
    chat_html = f"""
    <div class="chat-container">
        <div class="chat-header">
            <h2>{room_name}</h2>
            <span class="user-info">Logged in as: {username}</span>
        </div>
        <div class="chat-messages" id="messages">
            {f'<div class="last-message">Last: {last_message}</div>' if last_message else ''}
        </div>
        <div class="chat-input">
            <input type="text" id="messageInput" placeholder="Type a message...">
            <button onclick="sendMessage()">Send</button>
        </div>
    </div>
    <script>
        var roomId = '{room_id}';
        var currentUser = '{username}';
        var roomName = '{room_name}';

        function sendMessage() {{
            var message = document.getElementById('messageInput').value;
            // Send message logic here
        }}
    </script>
    """

    response = make_response(chat_html)
    response.headers['Content-Type'] = 'text/html'
    return response

@app.route('/error')
def error_page():
    """Custom error page with request details"""
    error_type = request.args.get('type', 'Unknown')
    error_message = request.args.get('msg', 'An error occurred')
    request_url = request.args.get('url', request.url)

    # VULNERABLE: Error details directly in HTML
    error_html = f"""
    <div class="error-page">
        <h1>Error: {error_type}</h1>
        <div class="error-details">
            <p>Message: {error_message}</p>
            <p>Request URL: {request_url}</p>
            <p>Timestamp: {datetime.now()}</p>
        </div>
        <div class="error-actions">
            <a href="/">Go Home</a>
            <button onclick="reportError()">Report Error</button>
        </div>
    </div>
    <script>
        function reportError() {{
            var errorData = {{
                type: '{error_type}',
                message: '{error_message}',
                url: '{request_url}'
            }};
            console.log('Reporting error:', errorData);
        }}
    </script>
    """

    response = make_response(error_html)
    response.headers['Content-Type'] = 'text/html'
    return response

@app.route('/api/format', methods=['POST'])
def format_data():
    """API endpoint that returns formatted HTML"""
    data = request.get_json() or {}
    format_type = data.get('format', 'html')
    content = data.get('content', '')
    title = data.get('title', 'Untitled')

    if format_type == 'html':
        # VULNERABLE: JSON data directly in HTML response
        formatted_html = f"""
        <div class="formatted-content">
            <h2>{title}</h2>
            <div class="content">
                {content}
            </div>
        </div>
        """

        response = make_response(formatted_html)
        response.headers['Content-Type'] = 'text/html'
        return response

    return make_response('Unsupported format', 400)

# Attack examples:
# /dashboard/123?name=<script>alert('XSS')</script>&theme=red;%7D%3C/style%3E%3Cscript%3Ealert('CSS')%3C/script%3E
# POST /preview with content: <img src=x onerror=alert(document.cookie)>
# /chat/1?last_msg=</div><script>steal_session()</script><div>
# /error?type=</h1><script>alert('XSS')</script><h1>&msg=test

if __name__ == '__main__':
    app.run(debug=True)
# SECURE: Flask app with proper escaping and templates
from flask import Flask, request, make_response, session, render_template, jsonify
from markupsafe import escape, Markup
import json
import re
from datetime import datetime

app = Flask(__name__)
app.secret_key = 'your-secret-key'

# Input validation functions
def validate_user_id(user_id):
    """Validate user ID format"""
    return re.match(r'^[0-9]+$', user_id) is not None

def validate_username(username):
    """Validate username format"""
    return re.match(r'^[a-zA-Z0-9_]{3,30}$', username) is not None

def validate_theme(theme):
    """Validate theme against whitelist"""
    allowed_themes = ['default', 'dark', 'light', 'blue', 'green']
    return theme if theme in allowed_themes else 'default'

def sanitize_room_name(room_name):
    """Sanitize room name"""
    # Remove HTML tags and limit length
    clean_name = re.sub(r'<[^>]+>', '', room_name)
    return clean_name[:50] if clean_name else 'Unnamed Room'

@app.route('/dashboard/<user_id>')
def dashboard(user_id):
    """Secure user dashboard"""
    # Validate user ID
    if not validate_user_id(user_id):
        return render_template('error.html',
                             message='Invalid user ID format'), 400

    username = request.args.get('name', 'User')
    theme = validate_theme(request.args.get('theme', 'default'))
    welcome_msg = request.args.get('msg', '')

    # Validate username
    if not validate_username(username):
        username = 'User'

    # Limit message length
    if len(welcome_msg) > 200:
        welcome_msg = welcome_msg[:200]

    # Use template with auto-escaping
    return render_template('dashboard.html',
                         username=username,
                         user_id=user_id,
                         theme=theme,
                         welcome_msg=welcome_msg)

# templates/dashboard.html
"""
<!DOCTYPE html>
<html>
<head>
    <title>Dashboard - {{ username }}</title>
    <link rel="stylesheet" href="/static/css/{{ theme }}.css">
</head>
<body class="theme-{{ theme }}">
    <h1>Welcome {{ username }}!</h1>
    <div id="user-id" data-user="{{ user_id }}">User ID: {{ user_id }}</div>
    {% if welcome_msg %}
        <div class="welcome-message">{{ welcome_msg }}</div>
    {% endif %}
    <div id="content">
        <p>Your personalized dashboard</p>
    </div>
    <script>
        // Safe JavaScript variables using JSON encoding
        var currentUser = {{ username|tojson }};
        var userId = {{ user_id|tojson }};
    </script>
</body>
</html>
"""

@app.route('/preview', methods=['POST'])
def preview_content():
    """Secure content preview"""
    title = request.form.get('title', '').strip()
    content = request.form.get('content', '').strip()
    author = request.form.get('author', 'Anonymous').strip()
    tags = request.form.get('tags', '').strip()

    # Validate input lengths
    if len(title) > 200 or len(content) > 5000 or len(author) > 50 or len(tags) > 200:
        return jsonify({'error': 'Input too long'}), 400

    # For API requests, return JSON
    if request.headers.get('Accept') == 'application/json':
        return jsonify({
            'preview': {
                'title': title,
                'content': content,
                'author': author,
                'tags': tags,
                'date': datetime.now().isoformat()
            }
        })

    # Use template for HTML preview
    return render_template('content_preview.html',
                         title=title,
                         content=content,
                         author=author,
                         tags=tags,
                         date=datetime.now().strftime('%Y-%m-%d'))

# templates/content_preview.html
"""
<div class="preview-container">
    <article class="content-preview">
        <h1>{{ title }}</h1>
        <div class="meta">
            <span class="author">By: {{ author }}</span>
            <span class="tags">Tags: {{ tags }}</span>
            <span class="date">{{ date }}</span>
        </div>
        <div class="content">
            {{ content|nl2br }}
        </div>
    </article>
    <div class="preview-actions">
        <button onclick="publishContent()">Publish</button>
        <button onclick="editContent()">Edit</button>
    </div>
</div>
<script>
    function publishContent() {
        alert('Publishing: ' + {{ title|tojson }});
    }
</script>
"""

@app.route('/chat/<room_id>')
def chat_room(room_id):
    """Secure chat room"""
    # Validate room ID
    if not re.match(r'^[a-zA-Z0-9_-]{1,20}$', room_id):
        return render_template('error.html',
                             message='Invalid room ID'), 400

    username = session.get('username', 'Anonymous')
    last_message = request.args.get('last_msg', '')
    room_name = request.args.get('room_name', f'Room {room_id}')

    # Validate and sanitize inputs
    if not validate_username(username):
        username = 'Anonymous'

    clean_room_name = sanitize_room_name(room_name)

    # Limit last message length
    if len(last_message) > 500:
        last_message = last_message[:500]

    return render_template('chat_room.html',
                         room_id=room_id,
                         room_name=clean_room_name,
                         username=username,
                         last_message=last_message)

# templates/chat_room.html
"""
<div class="chat-container">
    <div class="chat-header">
        <h2>{{ room_name }}</h2>
        <span class="user-info">Logged in as: {{ username }}</span>
    </div>
    <div class="chat-messages" id="messages">
        {% if last_message %}
            <div class="last-message">Last: {{ last_message }}</div>
        {% endif %}
    </div>
    <div class="chat-input">
        <input type="text" id="messageInput" placeholder="Type a message...">
        <button onclick="sendMessage()">Send</button>
    </div>
</div>
<script>
    // Safe JavaScript variables
    var roomId = {{ room_id|tojson }};
    var currentUser = {{ username|tojson }};
    var roomName = {{ room_name|tojson }};

    function sendMessage() {
        var message = document.getElementById('messageInput').value;
        // Send message logic here
    }
</script>
"""

@app.route('/error')
def error_page():
    """Secure error page"""
    error_type = request.args.get('type', 'Unknown')
    error_message = request.args.get('msg', 'An error occurred')

    # Sanitize error inputs
    safe_error_type = re.sub(r'[^a-zA-Z0-9s]', '', error_type)[:50]
    safe_error_message = error_message[:200] if error_message else 'An error occurred'

    return render_template('error.html',
                         error_type=safe_error_type,
                         error_message=safe_error_message,
                         timestamp=datetime.now().strftime('%Y-%m-%d %H:%M:%S'))

# templates/error.html
"""
<div class="error-page">
    <h1>Error: {{ error_type }}</h1>
    <div class="error-details">
        <p>Message: {{ error_message }}</p>
        <p>Timestamp: {{ timestamp }}</p>
    </div>
    <div class="error-actions">
        <a href="/">Go Home</a>
    </div>
</div>
"""

@app.route('/api/format', methods=['POST'])
def format_data():
    """Secure API endpoint for formatted content"""
    data = request.get_json() or {}
    format_type = data.get('format', 'html')
    content = data.get('content', '')
    title = data.get('title', 'Untitled')

    # Validate inputs
    if len(content) > 10000 or len(title) > 200:
        return jsonify({'error': 'Input too long'}), 400

    if format_type == 'html':
        # Use template for safe HTML generation
        html_content = render_template('formatted_content.html',
                                     title=title,
                                     content=content)

        response = make_response(html_content)
        response.headers['Content-Type'] = 'text/html; charset=utf-8'
        return response
    elif format_type == 'json':
        return jsonify({
            'title': title,
            'content': content,
            'formatted_at': datetime.now().isoformat()
        })

    return jsonify({'error': 'Unsupported format'}), 400

# templates/formatted_content.html
"""
<div class="formatted-content">
    <h2>{{ title }}</h2>
    <div class="content">
        {{ content|nl2br }}
    </div>
</div>
"""

# Alternative: Manual escaping when make_response is absolutely necessary
@app.route('/legacy/user/<user_id>')
def legacy_user_page(user_id):
    """Legacy endpoint requiring make_response with manual escaping"""
    if not validate_user_id(user_id):
        return make_response(escape('<p>Invalid user ID</p>'), 400)

    username = request.args.get('name', 'User')

    # Manual escaping
    safe_user_id = escape(user_id)
    safe_username = escape(username)

    html_content = f"""
    <html>
    <head>
        <title>User {safe_user_id}</title>
    </head>
    <body>
        <h1>Welcome {safe_username}!</h1>
        <p>User ID: {safe_user_id}</p>
    </body>
    </html>
    """

    response = make_response(html_content)
    response.headers['Content-Type'] = 'text/html; charset=utf-8'
    return response

if __name__ == '__main__':
    app.run(debug=False)  # Never run with debug=True in production

💡 Why This Fix Works

The vulnerable code directly interpolates user input into HTML content passed to make_response, bypassing Flask auto-escaping and allowing XSS attacks. The fixed version uses render_template with Jinja2 auto-escaping, comprehensive input validation, and when make_response is necessary, manual escaping with markupsafe.escape.

Why it happens

User-provided data from request arguments, forms, or URL parameters is directly embedded in HTML content passed to make_response.

Root causes

User Input in make_response HTML

User-provided data from request arguments, forms, or URL parameters is directly embedded in HTML content passed to make_response.

Preview example – PYTHON
# VULNERABLE: User input in make_response
from flask import Flask, request, make_response

app = Flask(__name__)

@app.route('/user/<username>')
def user_profile(username):
    theme = request.args.get('theme', 'default')

    # Direct user input in HTML response
    html_content = f"""
    <html>
        <head>
            <style>body {{ background: {theme}; }}</style>
        </head>
        <body>
            <h1>Welcome {username}!</h1>
            <p>Your profile page</p>
        </body>
    </html>
    """

    response = make_response(html_content)
    response.headers['Content-Type'] = 'text/html'
    return response

@app.route('/search')
def search():
    query = request.args.get('q', '')

    # Search term directly in response
    result_html = f"<h2>Results for: {query}</h2><p>No results found</p>"

    return make_response(result_html)

Form Data in Custom Responses

POST form data or JSON payloads are directly included in HTML responses created with make_response without sanitization.

Preview example – PYTHON
# VULNERABLE: Form data in make_response
from flask import Flask, request, make_response

@app.route('/contact', methods=['POST'])
def contact_form():
    name = request.form.get('name', '')
    email = request.form.get('email', '')
    message = request.form.get('message', '')

    # Form data directly in HTML
    confirmation_html = f"""
    <div class="confirmation">
        <h2>Thank you, {name}!</h2>
        <p>Email: {email}</p>
        <p>Message: {message}</p>
    </div>
    """

    response = make_response(confirmation_html)
    response.headers['Content-Type'] = 'text/html'
    return response

@app.route('/comment', methods=['POST'])
def add_comment():
    comment = request.json.get('comment', '')
    author = request.json.get('author', '')

    # JSON data directly in response
    comment_html = f"""
    <div class="comment">
        <strong>{author}:</strong> {comment}
    </div>
    """

    return make_response(comment_html)

Fixes

1

Use Flask Templates with Auto-Escaping

Replace make_response with render_template which uses Jinja2 auto-escaping to safely render user content.

View implementation – PYTHON
# SECURE: Use Flask templates with auto-escaping
from flask import Flask, request, render_template

app = Flask(__name__)

@app.route('/user/<username>')
def user_profile(username):
    theme = request.args.get('theme', 'default')

    # Validate theme against whitelist
    allowed_themes = ['default', 'dark', 'light', 'blue']
    safe_theme = theme if theme in allowed_themes else 'default'

    # Use template with auto-escaping
    return render_template('user_profile.html',
                         username=username,
                         theme=safe_theme)

# templates/user_profile.html
"""
<html>
<head>
    <link rel="stylesheet" href="/static/css/{{ theme }}.css">
</head>
<body class="theme-{{ theme }}">
    <!-- Jinja2 auto-escapes username -->
    <h1>Welcome {{ username }}!</h1>
    <p>Your profile page</p>
</body>
</html>
"""

@app.route('/search')
def search():
    query = request.args.get('q', '')

    # Use template for search results
    return render_template('search_results.html', query=query, results=[])
2

Manual Escaping with markupsafe.escape

When make_response is necessary, manually escape all user content using markupsafe.escape before including in HTML.

View implementation – PYTHON
# SECURE: Manual escaping with make_response
from flask import Flask, request, make_response, jsonify
from markupsafe import escape
import re

app = Flask(__name__)

def validate_username(username):
    """Validate username format"""
    return re.match(r'^[a-zA-Z0-9_]{3,30}$', username) is not None

@app.route('/user/<username>')
def user_profile(username):
    # Validate username
    if not validate_username(username):
        return make_response("<p>Invalid username format</p>", 400)

    theme = request.args.get('theme', 'default')

    # Validate theme
    allowed_themes = ['default', 'dark', 'light', 'blue']
    safe_theme = theme if theme in allowed_themes else 'default'

    # Escape user input manually
    safe_username = escape(username)

    html_content = f"""
    <html>
        <head>
            <link rel="stylesheet" href="/static/css/{safe_theme}.css">
        </head>
        <body>
            <h1>Welcome {safe_username}!</h1>
            <p>Your profile page</p>
        </body>
    </html>
    """

    response = make_response(html_content)
    response.headers['Content-Type'] = 'text/html; charset=utf-8'
    return response

@app.route('/contact', methods=['POST'])
def contact_form():
    name = request.form.get('name', '').strip()
    email = request.form.get('email', '').strip()
    message = request.form.get('message', '').strip()

    # Validate inputs
    if not name or len(name) > 50:
        return jsonify({'error': 'Invalid name'}), 400

    if not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', email):
        return jsonify({'error': 'Invalid email'}), 400

    if not message or len(message) > 500:
        return jsonify({'error': 'Invalid message'}), 400

    # Escape all user inputs
    safe_name = escape(name)
    safe_email = escape(email)
    safe_message = escape(message)

    confirmation_html = f"""
    <div class="confirmation">
        <h2>Thank you, {safe_name}!</h2>
        <p>Email: {safe_email}</p>
        <p>Message: {safe_message}</p>
    </div>
    """

    response = make_response(confirmation_html)
    response.headers['Content-Type'] = 'text/html; charset=utf-8'
    return response

Detect This Vulnerability in Your Code

Sourcery automatically identifies cross-site scripting (xss) from unescaped response content via flask make_response and many other security issues in your codebase.