~/blog

A $ in your replacement string silently rewrites the result

published

#javascript#strings#security

TL;DR

The second argument of String.prototype.replace() is not a literal string — it is a template language. $&, $`, $', $1 and $<name> are substitution commands. If that argument ever holds data you did not write yourself — user input, a price, a regex-built path — the output changes and nothing throws. Pass a function instead: str.replace(pattern, () => value) treats the value as literal text.

The problem

This looks like it inserts a price:

const price = "$100";
"Total: PLACEHOLDER".replace(/(Total): PLACEHOLDER/, price);
// → "Total00"

$1 was consumed as “insert capture group 1” ("Total"), and only 00 survived from the string you passed. No exception, no warning — just a wrong number in front of a user.

The same string behaves differently depending on the pattern, which is what makes this hard to spot in review:

const price = "$100";

"Total: PLACEHOLDER".replace(/(Total): PLACEHOLDER/, price);  // → "Total00"   ← has a group
"Total: PLACEHOLDER".replace(/Total: PLACEHOLDER/, price);    // → "$100"      ← no group
"Total: PLACEHOLDER".replace("PLACEHOLDER", price);           // → "Total: $100"

Three call sites, one replacement value, three results. The bug only appears once somebody adds a capture group to the regex — often in an unrelated refactor.

Why it happens

The spec defines a set of $ escapes for the replacement string, and the runtime expands them before inserting. Verified on Node v26.2.0:

In the replacementExpands toExample ("Hola NAME!", pattern NAME)
$&the matched substring"Hola NAME!" — the placeholder comes back
$`everything before the match"Hola Hola !"
$'everything after the match"Hola !!"
$ncapture group n, if the pattern has onegroup text
$nliteral $n, if the pattern has no such group"$1"
$<name>named groupgroup text
$<name>empty string if no such name exists""
$$a literal $"$"

Two of these deserve special attention.

$` and $' leak surrounding context into the output. If you are building an HTML string and an attacker controls the replacement value, $' copies the rest of the document into the injection point — the classic way a “safe” escaping pass gets defeated, because the escaped characters are re-introduced from a part of the string that was never escaped.

$<nope> on a pattern that has named groups but not that name expands to nothing. Your value disappears entirely and the result is an empty string. That one has no error path at all.

replaceAll() uses exactly the same replacement grammar, so a loop does not save you — it just corrupts every occurrence instead of the first.

What to do

Use a function replacement. The return value of a replacer function is inserted verbatim; $ has no meaning inside it.

const price = "$100";
"Total: PLACEHOLDER".replace(/(Total): PLACEHOLDER/, () => price);
// → "$100"

This is the fix for essentially every case where the replacement is a value rather than a template you wrote. It costs nothing and it cannot be broken by someone adding a capture group later.

If you must pass a string, escape it. Double every $:

const escapeReplacement = (s) => s.replaceAll("$", "$$$$");

"Hola NAME".replace("NAME", escapeReplacement("$&"));  // → "Hola $&"

The "$$$$" is not a typo: the replacement argument of that inner replaceAll is itself parsed for $ escapes, so four dollar signs produce two, which the outer call then collapses back to one. If that made you read it twice, that is the argument for the function form.

Where to look in an existing codebase. Grep for .replace( and .replaceAll( whose second argument is a variable rather than a string literal:

rg '\.replaceAll?\([^)]*,\s*[a-zA-Z_$][\w.$]*\s*\)' --type js --type ts

Template-building code, i18n interpolation, slug and path rewriting, and “redact the secret” helpers are where this lands most often — all four routinely put untrusted or externally-sourced text in the replacement slot.

Caveats

References