One line in, two lines out
Is this safe to write to a log?
A newline in a username ends the log entry and starts another, so the field
carries a whole forged record after it — correct format, plausible time,
never happened. An escape sequence does the opposite: it leaves every byte in the
file and changes what tail shows you. Paste a value that reaches a
log line and see which of the two it does.
The tool
This value writes 2 log entries.
One field, 2 records. 1 of them was written by whoever supplied this value, in your format, at a plausible time. Nothing about the file looks wrong afterwards. disarm takes out 1 character.
Where they are
Your value with every character disarm neutralizes shown in the position it occupies. Hover one for its name and what it does. One class: line break.
adminLF2026-08-31 12:00:01 INFO login ok user=rootWhat the file holds, and what you would see
The left is the bytes, with line breaks taken at face value. The right is what a terminal draws from them — simulated for the handful of sequences these attacks use (carriage return, backspace, erase-in-line, cursor-column), not a terminal emulator. Where the two disagree, the right is what a person reading the log actually reads.
admin 2026-08-31 12:00:01 INFO login ok user=root
admin 2026-08-31 12:00:01 INFO login ok user=root
Running disarm 0.14.1, compiled to WebAssembly. Your text is never uploaded — the engine is loaded into this page and runs on your machine.
Two attacks, one function
Forging. A log line is a record separated from the next one by a newline. Put a newline in a value and the value stops being a field and starts being a record boundary. Everything after it is a new entry, written by whoever supplied the value, in your format. Nothing about the file looks wrong afterwards, which is the difficulty: there is no corruption to notice, only an event that did not happen.
Rewriting. The second attack never touches the file's meaning.
ESC [ 2 K erases the line a terminal is drawing and
ESC [ 1 G puts the cursor back at column one, so a value carrying
both is displayed as only what follows them. Every byte is still in the file. The
program you are reading it with is the thing being attacked, and grep will
disagree with your eyes.
Both are the same class of mistake: a value crossing into a context whose structure it can affect. The fix is the same too, and it belongs where the value enters the line rather than where somebody later reads it — by then the file is already wrong.
What gets neutralized
strip_log_injection replaces each of these with a string you choose,
or drops them when you pass an empty one. Tabs are the only conditional member:
they split a field in a tab-delimited log, and are kept when you say so.
- Line breaks — CR, LF, NEL (
U+0085), and the line and paragraph separators (U+2028,U+2029). These are the forging attack. NEL and the separators matter because a reader that splits on Unicode line boundaries treats them as breaks even though most code that looks for\ndoes not. - ESC — introduces every terminal control sequence there is. On its own it is one character; what follows it is ordinary text that a terminal reads as a command.
- NUL — truncates the line in anything that reads C strings, which is more of the logging pipeline than people expect.
- C0 and C1 controls, and DEL — backspace rewrites, bell interrupts, and the rest render as nothing while remaining in the file.
- Tab, unless
keep_tab— a field separator in a tab-delimited log, and ordinary whitespace everywhere else.
The call refuses a replacement string containing anything it would itself neutralize. That is what makes the guarantee hold: whatever you configure, the output has no raw CR or LF in it. Try setting the replacement to a newline in the tool above and it will say so rather than quietly producing a line with a break in it.
What this is not. Not an HTML or SQL sanitizer, and not a defense against a logging framework interpolating its own format string. Encode at the viewer's sink for those. This makes a value safe to write as a line.
The same thing in your own code
Each code block has been compiled and verified in CI. Provided under the MIT license to illustrate disarm. disarm on GitHub →
Two languages rather than the usual seven, because
strip_log_injection is exposed by the Rust and Python APIs and not by
the Node, Java or Kotlin bindings. Checked against the published packages rather
than assumed.
# Make a value safe to write as a log line.
# pip install disarm
from disarm import strip_log_injection
# A newline stops being a field and starts being a record boundary: one username
# gives you two log entries, the second written by whoever supplied the value.
FORGED = "admin\n2026-08-31 12:00:01 INFO login ok user=root"
assert FORGED.count("\n") == 1
assert "\n" not in strip_log_injection(FORGED, replacement="")
# The other attack leaves the file alone and rewrites the display. ESC[2K erases
# the line a terminal is drawing and ESC[1G puts the cursor back at column one,
# so `tail` shows only what follows while every byte is still on disk.
REWRITE = "attacker\x1b[2K\x1b[1Guser=admin"
assert strip_log_injection(REWRITE, replacement="") == "attacker[2K[1Guser=admin"
# NEL is a line break to a Unicode-aware reader and invisible to a search for
# "\n", which is why the class matters more than the character.
assert strip_log_injection("admin\x85forged", replacement="") == "adminforged"
# Tabs are the one conditional member: a field separator in a delimited log, and
# ordinary whitespace everywhere else.
assert strip_log_injection("user\tadmin", replacement="") == "useradmin"
assert strip_log_injection("user\tadmin", replacement="", keep_tab=True) == "user\tadmin"
# A replacement containing something the call itself neutralizes is refused.
# That is the guarantee: whatever you configure, the output has no raw break.
try:
strip_log_injection("a\nb", replacement="\n")
raise AssertionError("should have been refused")
except ValueError:
pass
print("one field, two entries; the escape sequence survives as text")
// Make a value safe to write as a log line.
// cargo add disarm
use disarm::api::strip_log_injection;
fn main() {
// A newline stops being a field and starts being a record boundary: one
// username gives you two log entries, the second written by whoever
// supplied the value.
let forged = "admin\n2026-08-31 12:00:01 INFO login ok user=root";
assert_eq!(forged.matches('\n').count(), 1);
let safe = strip_log_injection(forged, "", false).unwrap();
assert!(!safe.contains('\n'));
// The other attack leaves the file alone and rewrites the display. ESC[2K
// erases the line a terminal is drawing and ESC[1G puts the cursor back at
// column one, so `tail` shows only what follows while every byte is still
// on disk.
let rewrite = "attacker\u{1b}[2K\u{1b}[1Guser=admin";
assert_eq!(
strip_log_injection(rewrite, "", false).unwrap(),
"attacker[2K[1Guser=admin"
);
// NEL is a line break to a Unicode-aware reader and invisible to a search
// for "\n", which is why the class matters more than the character.
assert_eq!(
strip_log_injection("admin\u{85}forged", "", false).unwrap(),
"adminforged"
);
// Tabs are the one conditional member: a field separator in a delimited
// log, and ordinary whitespace everywhere else.
assert_eq!(strip_log_injection("user\tadmin", "", false).unwrap(), "useradmin");
assert_eq!(strip_log_injection("user\tadmin", "", true).unwrap(), "user\tadmin");
// A replacement containing something the call itself neutralizes is
// refused. That is the guarantee: whatever you configure, the output has no
// raw break in it.
assert!(strip_log_injection("a\nb", "\n", false).is_err());
println!("one field, two entries; the escape sequence survives as text");
}
Related tools
- Remove invisible characters — the same idea for text that is displayed rather than logged.
- Detect Trojan Source — control characters that change what a reader sees, in source code.
- Normalize Unicode whitespace — when the character is a blank rather than a control.
- Which cleanup do I need? — where this sits among the rest.