Escape log control characters once, at the sink¶
- Status: accepted
- Date: 2026-10-04
Technical Story: AUTH-9 in security-audit-report-2026-09-29.md
Context and Problem Statement¶
Logged values include client-controlled text, for example a login
identifier.user or device_id. A JSON \n in such a value wrote a second
physical line that looked like a genuine log record, and an ESC or C1 CSI
reached the operator's terminal raw. Log lines are built in several ways:
structured diagnostic_message fields, event names, keys, and plain LOG_*
message strings. Where should control characters be neutralised, and into
what form?
Decision Drivers¶
- No call site may be able to bypass the protection, including future ones.
- A value must be escaped exactly once; double escaping makes logs unreadable.
- Ordinary log text, including UTF-8 and JSON bodies, should read as before.
Considered Options¶
- Escape once in
SingleLog::make_log_line, the one place every console and file record is composed (escape_log_controls). - Escape each structured field value, key and event name in
diagnostic_message. - Additionally escape backslash as
\\, making the escaping reversible.
Decision Outcome¶
Chosen option: escape once in SingleLog::make_log_line, without escaping
backslash. It is the only option that covers every write path, including
LOG_* macros that never build a StructuredLogField, while touching each
byte once.
\n, \r and \t become those escapes, other C0 controls and DEL become
\xHH, UTF-8 encoded C1 controls become \u00HH, and every other byte passes
through. redact_log_message, which runs on the escaped line, treats every
whitespace character as a token boundary so a sensitive last field keeps the
record terminator.
Positive Consequences¶
- One log record is one physical line, whatever any caller passes in.
- Call sites need no change and cannot opt out.
Negative Consequences¶
- A value that literally contains the characters
\nlooks the same in the log as an escaped line feed. The record boundary is still unforgeable; only the rendering of that one value is ambiguous. - Escaping at the sink means helpers such as
diagnostic_messagestill return raw text. Code that sends those strings somewhere other thanSingleLogmust neutralise them itself.
Pros and Cons of the Options¶
Escape in diagnostic_message¶
- Good, because structured output is safe wherever the string is sent.
- Bad, because
LOG_*macros and the module name bypass it. - Bad, because adding the sink-level escape later would escape twice.
Also escape backslash¶
- Good, because the log becomes unambiguous and reversible.
- Bad, because every backslash in existing log text doubles, notably in JSON bodies and diagnostics that quote escaped strings, and it buys no protection against record forging.