Skip to content

CWE-117: Improper Output Neutralization for Logs (Log Injection) - Python

Overview

Log injection occurs when untrusted data is written to log files without proper encoding. Attackers can inject newline characters to forge log entries, inject CRLF sequences to split log entries, or inject escape sequences to manipulate log output.

Primary Defence: Use structured logging with JSON/ECS output (python-json-logger or structlog) so control characters are encoded within fields (preserving evidence while preventing log forging). Use manual encoding only as a fallback.

Priority Fix Approaches

Structured Logging (Highest Priority - Eliminates the Vulnerability)

# SECURE - Use structured logging with JSON/key-value format
import logging
import json

# Configure JSON logging (example assumes Flask request context)
from flask import request
from datetime import datetime

class JsonFormatter(logging.Formatter):
    def format(self, record):
        log_data = {
            'timestamp': self.formatTime(record),
            'level': record.levelname,
            'message': record.getMessage(),
            'user_input': getattr(record, 'user_input', None)
        }
        return json.dumps(log_data)

handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger = logging.getLogger(__name__)
logger.addHandler(handler)

# Log with structured data (control characters are JSON-escaped automatically)

user_input = request.args.get('username')
logger.info('User login attempt', extra={'user_input': user_input})

# Output: {"timestamp":"2025-12-22 10:30:00","level":"INFO","message":"User login attempt","user_input":"admin\\nINJECTED"}

repr() - Python String Representation (Fallback)

# SECURE - repr() escapes special characters including newlines
import logging
from flask import request

user_input = request.form.get('comment')
logging.info(f'User comment: {repr(user_input)}')

# Input: "Hello\nFAKE LOG ENTRY"
# Output: User comment: 'Hello\nFAKE LOG ENTRY'
# The \n is escaped and visible in logs, not interpreted as newline
# repr() escapes:
# \n → \\n
# \r → \\r
# \t → \\t
# ' → \'

str.encode() - Byte String Encoding with Escape Sequences (Fallback)

# SECURE - encode() shows escape sequences explicitly
import logging
from flask import request
user_data = request.args.get('input')
safe_log = user_data.encode('unicode_escape').decode('ascii')
logging.info(f'Processing: {safe_log}')

# Input: "test\r\nINJECTED: admin logged in"
# Output: Processing: test\r\nINJECTED: admin logged in
# The control characters are visible, not interpreted
# Alternative: encode to bytes for inspection
byte_repr = user_data.encode('utf-8')
logging.info(f'Raw bytes: {byte_repr}')

# Output: Raw bytes: b'test\r\nINJECTED: admin logged in'

encodeForSingleLineTextLog() - Custom CRLF Encoding Function (Fallback)

# SECURE - Encode full control range to preserve audit trail (RECOMMENDED)
def encodeForSingleLineTextLog(text):
    """Encode control characters to preserve forensic evidence."""
    if text is None:
        return ''
    out = []
    for ch in text:
        code = ord(ch)
        if ch == '\\':
            out.append('\\\\')
            continue
        if ch == '\r':
            out.append('\\r')
        elif ch == '\n':
            out.append('\\n')
        elif ch == '\t':
            out.append('\\t')
        elif ch in ('\u0085', '\u2028', '\u2029'):
            out.append(f'\\u{code:04x}')
        elif code <= 0x1F or code == 0x7F or 0x80 <= code <= 0x9F:
            out.append(f'\\u{code:04x}')
        else:
            out.append(ch)
    return ''.join(out)

# Usage

import logging

from flask import request
user_input = request.args.get('username')
safe_username = encodeForSingleLineTextLog(user_input)  # RECOMMENDED: Use encoding version
logging.info(f'Login attempt for user: {safe_username}')

# Input: "admin\r\nSUCCESS: root login"
# Output: Login attempt for user: admin\\r\\nSUCCESS: root login
# Attack attempt is visible in logs but neutralized

Logging with Parameters (Formatting Safety)

# SECURE - Use logging parameters instead of f-strings
import logging

from flask import request
user_id = request.args.get('user_id')
action = request.form.get('action')

# GOOD: Use % formatting with logger
logging.info('User %s performed action %s', user_id, action)

# BETTER: Add validation/encoding (encodes control chars)
safe_user_id = encodeForSingleLineTextLog(user_id)  # Encodes \r\n to preserve evidence
safe_action = encodeForSingleLineTextLog(action)
logging.info('User %s performed action %s', safe_user_id, safe_action)

# BEST: Structured logging with extra fields (JSON/ECS output)
logging.info('User action', extra={
    'user_id': user_id,
    'action': action
})

Common Vulnerable Patterns

Direct User Input in Logs Without Sanitization

# VULNERABLE - No encoding
import logging

from flask import request
username = request.args.get('username')
logging.info(f'Login attempt for: {username}')

# Attack example:
# username = "admin\nSUCCESS: root logged in from 127.0.0.1"
# Log output:
# Login attempt for: admin
# SUCCESS: root logged in from 127.0.0.1  ← Forged entry!

Why this is vulnerable:

  • Newline characters (\n, \r) split log entries into multiple lines.
  • Unicode line separators can bypass ASCII-only checks.
  • Forged entries can hide malicious activity or create false audit trails.
  • Line-based parsers may treat injected lines as legitimate events.

CRLF Injection via HTTP Headers

# VULNERABLE - HTTP headers in logs
import logging

from flask import request
user_agent = request.headers.get('User-Agent')
logging.info(f'Request from user-agent: {user_agent}')

# Attack example:
# User-Agent: Mozilla/5.0\r\nADMIN_ACCESS: granted\r\nIP: 127.0.0.1
# Result: Creates multiple fake log lines appearing to grant admin access

Why this is vulnerable:

  • HTTP headers are fully attacker-controlled inputs.
  • CR/LF sequences create extra forged log lines.
  • Attackers can inject misleading security events.
  • Audit integrity and forensics are compromised by fake entries.

String Concatenation with Untrusted Data

# VULNERABLE - Building log messages with untrusted data
from flask import request
error_msg = request.form.get('error')
log_entry = 'Error occurred: ' + error_msg
logging.error(log_entry)

# Attack example:
# error_msg = "timeout\nCRITICAL: Database compromised - emergency shutdown initiated"
# Result: Allows injection of newlines and fake critical alerts

Why this is vulnerable:

  • Untrusted data is concatenated directly into log messages.
  • Control characters are not escaped or removed.
  • Attackers can inject fake critical alerts or errors.
  • Monitoring systems can be overwhelmed by forged noise.

Exception Messages Containing User Input

# VULNERABLE - Exception messages with user input
import logging
from flask import request

try:
    user_error = request.args.get('error')
    raise ValueError(user_error)
except ValueError as e:
    logging.error(f'Error: {e}')  # e contains unencoded input

# Attack example:
# error = "validation failed\nSUCCESS: admin privileges granted to user: attacker"
# Log output:
# Error: validation failed
# SUCCESS: admin privileges granted to user: attacker
# Result: Fake privilege escalation log entry

Why this is vulnerable:

  • Exception messages may include attacker-controlled content.
  • Newlines split entries and forge security events.
  • Fake success or privilege-grant messages can be injected.
  • Incident response can be misled by forged logs.

Secure Patterns

Use Structured Logging with python-json-logger

# SECURE - Use python-json-logger library
import logging
from pythonjsonlogger import jsonlogger
from flask import request
from datetime import datetime

logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter()
logHandler.setFormatter(formatter)
logger = logging.getLogger()
logger.addHandler(logHandler)

# Log with extra fields (automatically JSON-encoded)
user_input = request.args.get('input')
logger.info('User action', extra={
    'user_input': user_input,  # Control characters are JSON-escaped
    'ip_address': request.remote_addr,
    'timestamp': datetime.now().isoformat()
})

Why this works:

  • Each log entry is a single JSON object.
  • Newlines are JSON-escaped inside field values.
  • Injected data cannot create extra log records.
  • Structured output improves parsing in log aggregation tools.

Use repr() for Debug Logging

# SECURE - repr() for debugging untrusted data
import logging

from flask import request
payload = request.get_json()
logging.debug(f'Received payload: {repr(payload)}')

# Shows exact representation with escaped control characters
# Input: {"name": "test\r\nINJECTED"}
# Output: Received payload: {'name': 'test\\r\\nINJECTED'}

Why this works:

  • repr() escapes control characters like \n, \r, and \t.
  • Escape sequences are visible instead of creating new lines.
  • Attack attempts remain visible without forging log entries.
  • Useful for debugging and forensic analysis.
# SECURE - Encode CRLF to preserve forensic evidence (RECOMMENDED)
# Reuse the shared helper defined above
import logging
from flask import request

user_input = request.args.get('username')
safe_username = encodeForSingleLineTextLog(user_input)
logging.info(f'Login attempt for user: {safe_username}')

# Input: "admin\r\nSUCCESS: root login"
# Output: Login attempt for user: admin\\r\\nSUCCESS: root login
# ✓ Attack attempt is visible in logs but neutralized
# ✓ Security team can see exactly what attacker sent
# ✓ Preserves complete forensic evidence for incident response

Why this works:

  • Converts the full control range into visible escape sequences (e.g., \r, \n, \u0000).
  • Prevents actual line breaks in the log file.
  • Preserves complete evidence of injection attempts for forensic analysis.
  • Keeps audit trails intact for investigation.
  • Superior to removal because you see the full attack payload.

Custom Log Encoding (Fallback)

# SECURE - Comprehensive encoding function
import logging

def encode_for_log(text, max_length=200):
    """Encode text for safe logging (encode, don't remove).

    Args:
        text: Input text to encode
        max_length: Maximum length before truncation
    """
    if text is None:
        return ''

    # RECOMMENDED: Encode full control range to preserve forensic evidence
    text = encodeForSingleLineTextLog(text)

    # Truncate to prevent log flooding
    if len(text) > max_length:
        text = text[:max_length] + '...'

    return text

# Usage

from flask import request
user_comment = request.form.get('comment')
safe_comment = encode_for_log(user_comment)
logging.info(f'New comment: {safe_comment}')

Why this works:

  • Encoding mode (RECOMMENDED): Converts control chars to visible form, preserving forensic evidence.
  • Blocks bypasses using obscure Unicode newlines (\u2028, \u2029).
  • Length truncation prevents log flooding and disk abuse.
  • Works across locales and encodings.

Context Manager for Safe Logging

# SECURE - Context manager with automatic encoding
from contextlib import contextmanager
import logging

@contextmanager
def safe_log_context(**kwargs):
    """Context manager that encodes all log data."""
    encoded = {k: encodeForSingleLineTextLog(str(v)) for k, v in kwargs.items()}
    yield encoded

# Usage

from flask import request
user_data = {
    'username': request.args.get('username'),
    'action': request.form.get('action')
}

with safe_log_context(**user_data) as safe_data:
    logging.info('User %(username)s performed %(action)s', safe_data)

Why this works:

  • Centralizes encoding to reduce developer error.
  • Ensures all values are converted and encoded consistently.
  • Prevents missing a field when logging multiple inputs.
  • Reusable pattern for consistent logging hygiene.

Django Logging with Encoding

# SECURE - Django custom logging filter
import logging

class EncodeFilter(logging.Filter):
    """Filter that encodes log records."""

    def filter(self, record):
        if hasattr(record, 'user_input'):
            record.user_input = encodeForSingleLineTextLog(record.user_input)
        if hasattr(record, 'msg'):
            if isinstance(record.msg, str):
                record.msg = encodeForSingleLineTextLog(record.msg)
        return True
settings.py
LOGGING = {
    'version': 1,
    'filters': {
        'encode': {
            '()': 'myapp.logging_utils.EncodeFilter',
        }
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'filters': ['encode'],
        }
    },
    'loggers': {
        'myapp': {
            'handlers': ['console'],
            'level': 'INFO',
        }
    }
}

Why this works:

  • Filters encode log records centrally at the framework level.
  • Both message and custom fields are encoded before output.
  • Provides a safety net if developers forget per-call encoding.
  • Enforces consistent protection across the app.

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
  • Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

Security Scanner Guidance

Analyzing Security Scan Results

When security scan reports CWE-117 (Log Injection):

  1. Identify the Source: Find where untrusted data enters

    • request.args.get(), request.form.get()
    • request.headers.get()
    • Database reads, file inputs
    • Exception messages
  2. Trace to the Sink: Find the logging call

    • logging.info(), logging.error(), logging.warning()
    • logger.log(), print() to log files
    • Custom logging functions
  3. Check for Encoding: Verify if CRLF is encoded

    • Look for replace('\n', ''), replace('\r', '')
    • Check for repr(), encodeForSingleLineTextLog(), or similar
    • Verify structured logging (JSON)
  4. Apply Fix: Choose appropriate method

    • Best: Structured logging (JSON/ECS format)
    • Good: repr() or encodeForSingleLineTextLog()
    • Acceptable: String replacement for CRLF

Remediation Steps

# BEFORE (Vulnerable)

import logging

from flask import request
username = request.args.get('username')
logging.info(f'Login attempt: {username}')

# AFTER (Fixed - Option 1: encodeForSingleLineTextLog)

import logging
from flask import request

username = request.args.get('username')
logging.info(f'Login attempt: {encodeForSingleLineTextLog(username)}')

# AFTER (Fixed - Option 2: repr)

import logging

username = request.args.get('username')
logging.info(f'Login attempt: {repr(username)}')

# AFTER (Fixed - Option 3: Structured logging)

import logging
import json

username = request.args.get('username')
logger.info('Login attempt', extra={'username': username})

Verification After Fix

  1. Code Review: Ensure all log calls encode input
  2. Test with Payloads: Submit CRLF injection strings
  3. Check Log Files: Verify single-line entries
  4. Rescan with the security scanner: Confirm CWE-117 is resolved

Framework-Specific Best Practices

Flask Logging

from flask import Flask, request
import logging

app = Flask(__name__)

@app.route('/action')
def action():
    user_action = request.args.get('action')
    app.logger.info(f'Action: {encodeForSingleLineTextLog(user_action)}')
    return 'OK'

Django Logging

import logging

logger = logging.getLogger(__name__)

def my_view(request):
    user_input = request.GET.get('input')
    logger.info('User input: %s', encodeForSingleLineTextLog(user_input))

FastAPI with Structured Logging

from fastapi import FastAPI
import logging
import json

app = FastAPI()

@app.get('/log')
async def log_action(action: str):
    logging.info('User action', extra={'action': action})
    # JSON formatted, CRLF automatically escaped
    return {'status': 'logged'}

Additional Resources