Skip to content

CWE-117: Improper Output Neutralization for Logs - Python

Overview

Log injection occurs when untrusted data is written to log files without encoding. Newline or CRLF sequences in that data split one record into several and forge entries, and escape sequences manipulate the 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
# The encodeForSingleLineTextLog() encoder from Priority Fix Approaches above, as its own module
from log_encoding import encodeForSingleLineTextLog
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 datetime import datetime

from flask import request
# python-json-logger 3.1+ lives here. The old
# "from pythonjsonlogger import jsonlogger" path still works but raises a
# DeprecationWarning on current releases.
from pythonjsonlogger.json import JsonFormatter

logHandler = logging.StreamHandler()
logHandler.setFormatter(JsonFormatter('%(asctime)s %(levelname)s %(message)s'))
logger = logging.getLogger(__name__)
logger.addHandler(logHandler)
logger.setLevel(logging.INFO)

# 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,
    'event_time': datetime.now().isoformat()
})

# {"asctime": "...", "levelname": "INFO", "message": "User action",
#  "user_input": "admin\nNL\u2028LS", "ip_address": "10.0.0.1", ...}

Why this works:

  • Each log entry is a single JSON object, so the record boundary is the object rather than a line break an attacker can supply.
  • Values go in named fields via extra=, and the formatter serializes them with json.dumps. A newline becomes the two characters \n inside the field.
  • Python is the ecosystem where this covers the Unicode separators too. json.dumps defaults to ensure_ascii=True, so U+0085, U+2028 and U+2029 come out as \u0085, \u2028 and \u2029. Verified on python-json-logger 4.2.0. That is not true of the equivalent Java, Go or Node encoders, so guidance copied from those ecosystems will understate what you get here - and setting json_ensure_ascii=False to make logs prettier gives the gap back.
  • Use field names that are not already LogRecord attributes: message, asctime, name, msg, args, levelname, module and the rest of the record's own fields all raise KeyError: "Attempt to overwrite 'x' in LogRecord" at the call site. timestamp is not reserved, which is why the original of this example got away with it - but event_time says the same thing without depending on that.
  • 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.
  • The escape sequences appear in the log instead of creating new lines, so an attack attempt stays visible without forging an entry.
  • Useful for debugging and forensic analysis.
# SECURE - Encode CRLF to preserve forensic evidence (RECOMMENDED)
import logging
from flask import request
# The encodeForSingleLineTextLog() encoder from Priority Fix Approaches above, as its own module
from log_encoding import encodeForSingleLineTextLog

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, so the audit trail stays intact for investigation.
  • Superior to removal because you see the full attack payload.

Custom Log Encoding (Fallback)

# SECURE - Comprehensive encoding function
import logging
# The encodeForSingleLineTextLog() encoder from Priority Fix Approaches above, as its own module
from log_encoding import encodeForSingleLineTextLog

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 of the input before truncation
    """
    if text is None:
        return ''

    # Truncate FIRST. Cutting encoded text can split an escape sequence and
    # leave '...xxx\\u2' or a dangling backslash in the log.
    truncated = text[:max_length]
    suffix = "..." if len(text) > max_length else ""

    # RECOMMENDED: Encode full control range to preserve forensic evidence
    return encodeForSingleLineTextLog(truncated) + suffix

# 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, which encoding alone does nothing about.
  • Truncate before encoding, not after. Encoding expands one character into up to six, so a cut applied to the encoded string can land inside a sequence: truncating encodeForSingleLineTextLog(...) at 200 characters produces a tail of \\u2 on a U+2028 payload, or a bare trailing \\ on a newline one. Both are unreadable, and the dangling backslash changes how the next character is read. Slicing the input first makes the boundary fall on a real character.
  • Works across locales and encodings.

Context Manager for Safe Logging

# SECURE - Context manager with automatic encoding
from contextlib import contextmanager
import logging
# The encodeForSingleLineTextLog() encoder from Priority Fix Approaches above, as its own module
from log_encoding import encodeForSingleLineTextLog

@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.
  • Converts and encodes every value passed in, so no field is missed when several inputs go into one entry.
  • Reusable pattern for consistent logging hygiene.

Django Logging with Encoding

# SECURE - Django custom logging filter
import logging
# The encodeForSingleLineTextLog() encoder from Priority Fix Approaches above, as its own module
from log_encoding import encodeForSingleLineTextLog

class EncodeFilter(logging.Filter):
    """Encode the values a record carries, not just its template."""

    def filter(self, record):
        # record.args holds the %-style arguments, and that is where user
        # input actually lives: logger.info("User: %s", username) leaves
        # record.msg as the constant "User: %s". Encoding only msg would
        # touch nothing an attacker controls.
        if isinstance(record.args, dict):
            record.args = {
                k: encodeForSingleLineTextLog(v) if isinstance(v, str) else v
                for k, v in record.args.items()
            }
        elif record.args:
            record.args = tuple(
                encodeForSingleLineTextLog(a) if isinstance(a, str) else a
                for a in record.args
            )

        # An f-string call site has already built the whole message, so the
        # payload is in msg with nothing to separate it from the template.
        if isinstance(record.msg, str):
            record.msg = encodeForSingleLineTextLog(record.msg)

        # Custom fields passed via extra=
        if hasattr(record, 'user_input') and isinstance(record.user_input, str):
            record.user_input = encodeForSingleLineTextLog(record.user_input)
        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 run centrally at the framework level, so this is the safety net for call sites that forgot to encode.
  • It has to encode record.args, and that is the part that is easy to get wrong. Every parameterized call on this page - logger.info('User input: %s', user_input) - leaves record.msg as the constant template and puts the user value in record.args. A filter that encodes only record.msg therefore passes the payload straight through: running logger.info('User input: %s', 'admin\nFAKE') through such a filter still emits a real newline and forges the entry. Both branches are needed because f-string call sites put the payload in msg instead.
  • Attach the filter to the handler, as in the settings below. A filter on a logger does not run for records that propagate up from child loggers, so the coverage would be narrower than it looks.
  • The filter mutates the record in place. If two handlers share it, the second sees already-encoded text and encodes the backslashes again, so attach it to one handler or make the encoder idempotent.
  • This is a backstop, not the primary fix. A JSON formatter at the handler does the same job without a custom class - reach for this when you are stuck with text output.

Security Scanner Guidance

Analyzing Security Scan Results

When a 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()
    • Last resort: stripping CR/LF with str.replace(). It silences the scanner and discards the evidence, and on its own it misses \u0085, \u2028, \u2029 and the ANSI escape range. If you record this as the fix, record what it does not cover.

Remediation Steps

# The encodeForSingleLineTextLog() encoder from Priority Fix Approaches above, as its own module
from log_encoding import encodeForSingleLineTextLog

# 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

from pythonjsonlogger.json import JsonFormatter

# The formatter is the fix. Without it, extra= is discarded and the value
# you meant to log never appears in the output at all.
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter('%(asctime)s %(levelname)s %(message)s'))
logger = logging.getLogger(__name__)
logger.addHandler(handler)

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
# The encodeForSingleLineTextLog() encoder from Priority Fix Approaches above, as its own module
from log_encoding import encodeForSingleLineTextLog

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
# The encodeForSingleLineTextLog() encoder from Priority Fix Approaches above, as its own module
from log_encoding import encodeForSingleLineTextLog

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

import logging

from fastapi import FastAPI
from pythonjsonlogger.json import JsonFormatter

app = FastAPI()

# Without a JSON formatter configured, extra= is silently discarded: the
# default handler renders only the message, so the value you meant to log
# never reaches the output at all. The fields have to be named in the
# format string for the formatter to emit them.
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter('%(asctime)s %(levelname)s %(message)s'))
logger = logging.getLogger(__name__)
logger.addHandler(handler)
logger.setLevel(logging.INFO)

@app.get('/log')
async def log_action(action: str):
    logger.info('User action', extra={'action': action})
    # {"asctime": "...", "levelname": "INFO", "message": "User action",
    #  "action": "view\\nFAKE"}  <- control chars escaped inside the field
    return {'status': 'logged'}

Pick field names that are not already LogRecord attributes. extra={'message': ...} and extra={'name': ...} both raise KeyError: "Attempt to overwrite 'message' in LogRecord" at the call site rather than being ignored quietly.

Common Pitfalls

  • Assuming %-style parameterization alone stops line splitting: logging.info('User %s performed %s', user_id, action) keeps the template and arguments separate at the call site, but the standard Formatter still substitutes the values and writes the combined text as one string. Without a JSON formatter or explicit encoding, a raw newline in user_id still splits the output line - parameterization here avoids eager string building, it doesn't by itself encode control characters.
  • Encoding new call sites but missing exception handlers: logging.error(f'Error: {e}') logs the exception's string representation directly. If the exception was constructed from user input (raise ValueError(user_input)), str(e) carries that input unencoded into the log, even in a codebase where every other call site uses encodeForSingleLineTextLog() or repr().
  • Logging repr() of a container instead of its values: repr(request.args) shows the ImmutableMultiDict's own representation, not necessarily an encoded form of every string it holds - some object __repr__ implementations interpolate contained values without escaping them, so a control character inside a form field can still reach the log depending on the container type, not just the outer repr() call.

Additional Resources