disarm

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

50 chars
49 chars
admin2026-08-31 12:00:01 INFO login ok user=root

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=root

What 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.

In the file2 entries
admin
2026-08-31 12:00:01 INFO  login ok user=root
On the screen
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.

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")

Related tools