A $ in your replacement string silently rewrites the result
published
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 replacement | Expands to | Example ("Hola NAME!", pattern NAME) |
|---|---|---|
$& | the matched substring | "Hola NAME!" — the placeholder comes back |
$` | everything before the match | "Hola Hola !" |
$' | everything after the match | "Hola !!" |
$n | capture group n, if the pattern has one | group text |
$n | literal $n, if the pattern has no such group | "$1" |
$<name> | named group | group 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
- This is not a JavaScript quirk in the sense of a bug: it is specified behaviour and has been since ES3. The trap is the API shape, not the engine.
- The function form has one real cost: you lose the ability to reference capture groups
concisely. Inside a replacer function they arrive as arguments
(
(match, g1, g2, offset, string)), which is more verbose but explicit. - Other languages have the same shape with different sigils — Python’s
re.subuses\1and raiseserroron a bad group reference, and Java’sMatcher.replaceAllthrowsIllegalArgumentException. JavaScript is unusual in failing silently. String.rawdoes not help here. It controls how the literal is parsed at compile time; the$expansion happens later, insidereplace().