0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

`String.prototype.replace` に外部文字列を渡すと、`$1` が勝手に展開されることがある

0
Posted at

結論

JavaScript / TypeScript の String.prototype.replace で、第 2 引数に「外部由来の文字列」をそのまま渡すのは避けたほうが安全です。$1 $& $` $' などが特殊文字として展開されるため、想定外の出力になることがあります。

つまり、replace の第 2 引数に渡す文字列は、完全なリテラル文字列ではありません。$ から始まる一部の文字列は、replacement pattern として解釈されます。

なお、これは String.prototype.replaceAll でも同じです。第 2 引数に文字列を渡す場合は、$&$1 などが replacement pattern として解釈されます。

// 危険なコード
const userInput = '<span>price $1 USD</span>';
const result = html.replace(/(<img[^>]*>)/g, userInput);
// → $1 はキャプチャグループ参照として展開される

何が起きるか

replace の第 2 引数が文字列の場合、以下のシーケンスが「リテラル文字」ではなく「特殊文字」として解釈されます。

記号 意味
$$ リテラルの $
$& マッチ全体
$` マッチ前の文字列
$' マッチ後の文字列
$n(n は数字) n 番目のキャプチャグループ

これは MDN の String.prototype.replace ドキュメント で明記されている仕様です。

再現例

const html = '<img src="x.png" alt="">';
const replacement = 'price $1';

// パターン 1: キャプチャあり
console.log(html.replace(/(<img[^>]*>)/, replacement));
// → 'price <img src="x.png" alt="">'
// $1 がキャプチャ全体に置き換わってしまう

// パターン 2: キャプチャなし
console.log(html.replace(/<img[^>]*>/, replacement));
// → 'price $1'
// 第 1 引数にキャプチャがない場合、$1 はそのまま残る
// ただし、挙動を誤解しやすく混乱の元になる

// パターン 3: $&
console.log(html.replace(/<img[^>]*>/, 'BEFORE $& AFTER'));
// → 'BEFORE <img src="x.png" alt=""> AFTER'
// $& はマッチ全体に展開される

どんな時に困るか

「外部から入ってきた文字列を、別の文字列に埋め込む」場面では注意が必要です。

  • ユーザー入力をテンプレートに埋め込む
  • DB / KV から取得した値を HTML / コードに挿入する
  • 翻訳リソースに含まれる金額表示($1,000 など)

特に問題が起きやすいのは「$1 が出現する自然言語の文字列」を扱うときです。価格表示、株式記号($AAPL)、変数参照のサンプルコード、正規表現の解説記事などが該当します。

// ありがちなバグ
const product = { description: 'Save $1 on every purchase!' };
const html = template.replace(/({{description}})/g, product.description);
// → $1 が '{{description}}' に展開されてしまう

解決策 1: 関数版を使う

第 2 引数に関数を渡すと特殊展開は起きません。関数の戻り値がそのままリテラル文字列として挿入されます。

const result = html.replace(/<img[^>]*>/g, () => userInput);

これが最もシンプルで安全です。実装の意図が「ユーザー入力をそのまま挿入する」なら、関数版を選ぶのが自然です。

ただし注意点として、関数の引数経由で matchcapture groups を取りたい場合は、それらを使った文字列構築を関数内で書く必要があります。

const result = html.replace(/<img\s+src="([^"]+)"/g, (match, src) => {
  // src を使って何か作る
  return `<img src="${escapeHtml(src)}" loading="lazy"`;
});

解決策 2: $ を escape する

事前に文字列内の $$$ に置換しておく方法もあります。

const escaped = userInput.replace(/\$/g, '$$$$');
const result = html.replace(/<img[^>]*>/g, escaped);

ここで $$$$ と書くのは、最初の userInput.replace() の replacement 文字列として $$ を出力したいからです。replace の replacement 文字列では $$ がリテラルの $ になるため、$$$$ と書くと結果として $$ が生成されます。

その後、生成された $$ を本来の html.replace() の replacement として渡すと、そこで $$ がリテラルの $ として扱われます。少し分かりにくいため、通常は関数版のほうが扱いやすいです。

関数版が一律で安全か

関数版でも「関数の戻り値内で他の特殊文字に化けるリスク」はありません。replace の関数引数は MDN 仕様で「戻り値はリテラル文字列として扱う」と明記されています。

'abc'.replace(/b/, () => '$&');
// → 'a$&c'  ($& は展開されない)

したがって「外部入力を replacement にする」場面では、関数版を標準にしておくと事故が減ります。

問題が起きやすい場面

実コードで遭遇しやすいのは、「ある程度信頼している取得元から受け取った HTML 断片を、別の HTML の開始タグ部分へ差し込む」ような処理です。

なお、HTML に外部入力を挿入する場合は、この記事で扱う $ 展開の問題とは別に、HTML エスケープやサニタイズも必要です。ここでは replace の replacement 文字列としての挙動に話を絞ります。

function replaceElementStartTag(
  html: string,
  tagName: string,
  replacementHtml: string,
) {
  if (!/^[a-z][a-z0-9-]*$/i.test(tagName)) {
    throw new Error('invalid tag name');
  }

  const pattern = new RegExp(`<${tagName}[^>]*>`, 'gi');
  return html.replace(pattern, () => replacementHtml);
}

replacementHtml<span>price $1 USD</span> のような何気ない文字列だと、出力が <span>price <img ...> USD</span> のように予想外に変形することがあります。エンドユーザー向けページの場合、「描画は壊れていないが内容が変」という状態になりやすいため、テストでも気付きにくいです。

早期検出するためのテスト

it('replacement に $ を含む文字列があっても特殊展開されない', () => {
  const html = '<img src="x">';
  const result = replaceElementStartTag(html, 'img', '<span>price $1 USD</span>');
  expect(result).toContain('$1');  // 文字どおり残ること
});

このテストを追加するだけで、「うっかり文字列版に戻す」リグレッションを防ぎやすくなります。

まとめ

  • String.prototype.replace / replaceAll の replacement に文字列を渡すと、$1$& などが特殊展開されます
  • replacement 文字列は完全なリテラルではなく、$ から始まる一部の記法が replacement pattern として解釈されます
  • 外部由来の文字列をそのまま挿入したい場合は、文字列版ではなく関数版 () => replacement を使うのが安全です
  • escape 方式 (replace(/\$/g, '$$$$')) もありますが、可読性が悪いです
  • $ を含む replacement のテスト」を 1 件入れておくとリグレッション防止になります
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?