Log Injection Defenses: Sanitize User Fields in Structured JSON Loggers

Log Injection Defenses: Sanitize User Fields in Structured JSON Loggers

Classic log injection smuggles newlines into plaintext logs to forge entries. Structured JSON reduces that risk—but only if you actually serialize objects and scrub control characters. User agents, search queries, and ticket titles still break SIEM parsers or plant fake password=... lines when teams string-concatenate.

⚡ TL;DR: Always log via JSON serializers (pino/winston/structlog); never interpolate user strings into format templates as raw lines. Strip/escape \n, \r, \u0000, and ANSI. Cap field lengths. Pair with Secret-Aware Context Filters, CloudWatch Logs Anomaly Detection, and Lambda Log Buffering.

Attack shape

User sets display_name = "alice\nERROR auth ok user=admin"
Plaintext logger:
  INFO user=alice
  ERROR auth ok user=admin   ← forged
JSON logger with raw concat can still break if you print strings by hand

Node: pino with redaction and scrubbing

import pino from "pino";

function scrub(value: unknown): unknown {
  if (typeof value !== "string") return value;
  return value
    .replace(/[\u0000-\u001f\u007f]/g, " ")
    .slice(0, 512);
}

const log = pino({
  level: "info",
  redact: {
    paths: ["req.headers.authorization", "password", "token", "*.secret"],
    censor: "[REDACTED]",
  },
  serializers: {
    user(u) {
      return { id: u.id, name: scrub(u.name) };
    },
  },
});

// GOOD
log.info({ user: { id, name: req.body.name } }, "profile_updated");

// BAD
console.log(`profile_updated name=${req.body.name}`);

Python: structlog / stdlib JSON

import json
import logging
import re

_CTRL = re.compile(r"[\x00-\x1f\x7f]")

def scrub(s: str, limit: int = 512) -> str:
    return _CTRL.sub(" ", s)[:limit]

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "level": record.levelname,
            "msg": scrub(str(record.getMessage())),
            "correlation_id": scrub(getattr(record, "correlation_id", "")),
        }
        if hasattr(record, "user_name"):
            payload["user_name"] = scrub(str(record.user_name))
        return json.dumps(payload, ensure_ascii=False)

# GOOD: extra fields as structured keys
logger.info("profile_updated", extra={"user_name": scrub(name)})

# BAD
logger.info("profile_updated name=%s" % name)

SIEM and CloudWatch considerations

Even with JSON, unbounded user fields inflate ingestion cost and confuse anomaly detectors—cap lengths and drop high-cardinality free text from indices when possible (Lambda Log Buffering, CloudWatch Logs Anomaly Detection). Never log secrets; treat tokens like Secret-Aware Context Filters.

Closing checklist

  • [ ] All services use structured JSON loggers (no raw console concat)
  • [ ] User-controlled strings scrubbed of control chars and length-capped
  • [ ] Redaction paths cover auth headers, passwords, tokens
  • [ ] CI lint bans console.log(\…${user}) patterns where feasible
  • [ ] SIEM parsers tested against adversarial newline payloads
  • [ ] Correlation IDs remain trusted server-side values

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply