JavaScript Tips | 文字列操作:文字列を安全にHTMLエスケープする

JavaScript JavaScript
スポンサーリンク
スポンサーリンク
  1. 業務・実務で使える JavaScript ユーティリティ:文字列を安全にHTMLエスケープする方法
  2. HTMLエスケープとは何か
    1. HTMLとして解釈される文字を、表示用の表現へ変える
    2. タグ除去やサニタイズとは目的が異なる
  3. ステップ1:出力先を確認する
    1. DOMへ文字を表示するだけならtextContentを使う
    2. HTML文字列を作る場合は、挿入する位置に注意する
  4. ステップ2:変換する文字を決める
  5. ステップ3:基本的な置換処理を書く
    1. 順番に置換する場合はアンパサンドを最初にする
    2. 置換後の結果を保存する
  6. ステップ4:対応表を使って1回の置換処理にまとめる
  7. ステップ5:入力値の扱いを決める
    1. 文字列以外を自動変換するか、例外にするか
    2. 空文字は正常な入力として扱う
  8. 業務で使える完成版テンプレート
  9. 例題:HTML文字列の本文へ埋め込む
    1. 構造は固定し、可変の文字列だけをエスケープする
  10. 重要:二重エスケープを防ぐ
    1. HTMLエスケープは繰り返しても同じ結果になる処理ではない
    2. 文字参照らしい並びを勝手に信用しない
  11. セキュリティ上の注意:すべての場所で使えるわけではない
    1. 属性値は引用符と属性の種類まで確認する
    2. URLは許可する形式を別に検証する
    3. JavaScriptやCSSへ流用しない
    4. 独自関数で「安全なHTML」と宣言しない
  12. 改行を残して表示したい場合
  13. テストで正常系と境界条件を確認する
    1. 5文字・空文字・既存の文字参照を確認する
    2. 文字列以外では例外になることを確認する
  14. まとめ

業務・実務で使える JavaScript ユーティリティ:文字列を安全にHTMLエスケープする方法

JavaScriptで文字列をHTMLエスケープするには、HTMLで特別な意味を持つ文字を、< や & などの文字参照へ変換します。これにより、文字列をHTMLの構文としてではなく、文字として表示できるようにします。

ただし、最初に押さえたいのは、画面に文字列を表示するだけなら、HTMLエスケープした文字列を作るより、textContent を使うほうが適切な場合が多いということです。HTMLエスケープは、HTML文字列を組み立てる必要がある場面で利用します。

この記事では、HTMLエスケープの意味から始め、実装手順、再利用できるテンプレート、テスト、セキュリティ上の注意点まで段階的に説明します。「安全」という言葉が指す範囲も明確にしながら、業務で扱いやすい関数を作ります。

HTMLエスケープとは何か

HTMLとして解釈される文字を、表示用の表現へ変える

HTMLでは、< や > はタグの記述に使われます。たとえば、HTMLとして渡された <strong>重要</strong> は、タグの文字列ではなく、強調された「重要」として表示されます。

一方、画面に <strong>重要</strong> という記法そのものを表示したい場合は、山括弧などを文字参照へ変換します。この変換がHTMLエスケープです。

// エスケープ前の文字列
const original = "<strong>重要</strong>";

// HTMLエスケープした文字列の例
const escaped = "<strong>重要</strong>";

// escaped の文字列には、文字参照が含まれている
console.log(escaped);
// "<strong>重要</strong>"
JavaScript

エスケープ後の文字列をHTMLの通常の本文部分として解析すると、文字参照が表示用の文字へ戻り、タグの記法そのものが表示されます。タグとして実行・描画させたいのではなく、文字として見せたい場面で利用する仕組みです。

タグ除去やサニタイズとは目的が異なる

前提を整理すると、それぞれの処理には次の違いがあります。

  • HTMLエスケープは、タグの記法も含めて文字として表示するための変換です。
  • HTMLタグ除去は、タグを取り除き、本文のテキストを抽出する処理です。
  • HTMLサニタイズは、書式付きHTMLを残すために、危険な要素や属性などをルールに従って除去・制限する処理です。

たとえば、入力された <strong>重要</strong> をそのまま文字として見せたいならエスケープ、本文の「重要」だけが必要ならテキスト抽出、強調表示を安全に残したいならサニタイズを検討します。

ステップ1:出力先を確認する

DOMへ文字を表示するだけならtextContentを使う

通常の要素へ利用者の入力を表示するだけなら、まず textContent を選びます。代入した文字列をHTMLとして解析しないため、タグのような文字列もそのまま表示できます。

// 表示したい入力値
const userInput = "<strong>重要</strong>";

// 通常の表示用要素を作る
const output = document.createElement("p");

// HTMLとして解析させず、文字として設定する
output.textContent = userInput;

// 画面に追加する
document.body.append(output);
JavaScript

この例では、画面にタグの記法を含む文字列が表示されます。事前にHTMLエスケープする必要はありません。

textContent に渡す前にHTMLエスケープすると、文字参照の表記まで画面に見えてしまうことがあります。HTML文字列へ挿入する処理と、テキストとしてDOMへ設定する処理を混同しないことが重要です。

HTML文字列を作る場合は、挿入する位置に注意する

HTML文字列を返す既存の表示関数や、HTML形式のレポートを組み立てる処理では、エスケープ済み文字列が必要になる場合があります。

ただし、同じHTML内でも、通常の本文、属性値、URL、JavaScript、CSSでは解釈のルールが異なります。今回の関数は、主に通常のHTML要素の本文へ、文字列を埋め込む用途を想定します。

ステップ2:変換する文字を決める

実務向けの基本的な関数では、次の5文字を変換対象にします。山括弧やアンパサンドに加え、引用符もまとめて扱うことで、変換ルールを明確にできます。

  • & を &amp; に変換します。
  • < を &lt; に変換します。
  • > を &gt; に変換します。
  • " を &quot; に変換します。
  • ' を &#39; に変換します。

この5文字を変換することは、あらゆる出力先で安全になることを意味しません。たとえば、URLの許可判定やJavaScript文字列の生成には、それぞれ別のルールが必要です。

ステップ3:基本的な置換処理を書く

順番に置換する場合はアンパサンドを最初にする

仕組みを理解するため、まずは replaceAll() を使って順番に変換してみます。重要なのは、アンパサンドの変換を最初に行うことです。

/**
 * 基本的なHTMLエスケープを行います。
 * この例では、入力が文字列であることを前提にしています。
 */
function escapeHtmlBasic(text) {
  return text
    // 後の処理が生成する文字参照まで変換しないよう、最初に行う
    .replaceAll("&", "&")
    .replaceAll("<", "<")
    .replaceAll(">", ">")
    .replaceAll('"', """)
    .replaceAll("'", "'");
}

// HTMLの記法を、文字として表示できる表現へ変換する
console.log(escapeHtmlBasic("<b>A & B</b>"));
// "<b>A & B</b>"
JavaScript

先に < を &lt; へ変換し、その後でアンパサンドを置換すると、生成した文字参照の先頭まで変換されてしまいます。その結果、&amp;lt; のような二重の表現になります。

置換後の結果を保存する

文字列の置換は、元の文字列を直接書き換えません。戻り値として新しい文字列を受け取る必要があります。

// 元の値と変換後の値を分けて保存する
const original = "<p>お知らせ</p>";
const escaped = escapeHtmlBasic(original);

console.log(original); // "<p>お知らせ</p>"
console.log(escaped);  // "<p>お知らせ</p>"
JavaScript

変換前と変換後を別々に保持すると、保存用データと表示用データを分けやすくなります。業務アプリケーションでは、この分離が二重エスケープの防止にも役立ちます。

ステップ4:対応表を使って1回の置換処理にまとめる

変換の順序に依存しにくい書き方として、正規表現で対象文字をまとめて探し、対応表から置換後の文字列を返す方法があります。

// 変換対象の文字と、変換後の表現を対応付ける
const entities = {
  "&": "&",
  "<": "<",
  ">": ">",
  '"': """,
  "'": "'"
};

// 正規表現で対象文字を探し、対応する表現へ置き換える
const result = "<b>A & B</b>".replace(
  /[&<>"']/g,
  character => entities[character]
);

console.log(result);
// "<b>A & B</b>"
JavaScript

正規表現の角括弧は、「中にある文字のいずれか」を表します。末尾の g は、最初の1件だけでなく、すべての一致箇所を対象にする指定です。

character => entities[character] は、一致した1文字に対応する置換結果を返す関数です。たとえば一致文字が < なら、対応表から &lt; を取得します。

この処理では、置換によって挿入した文字列を、同じ呼び出しの中で再び検索して置換することはありません。そのため、生成した文字参照のアンパサンドまで変換してしまう問題を避けられます。

ステップ5:入力値の扱いを決める

文字列以外を自動変換するか、例外にするか

実務では、入力が必ず文字列とは限りません。APIの値が欠落したり、数値やオブジェクトを誤って渡したりする場合があります。

すべてを String() で変換する方法もありますが、欠損値やオブジェクトが意図しない文字列になることがあります。

// 自動変換した結果が、表示したい値とは限らない
console.log(String(null));      // "null"
console.log(String(undefined)); // "undefined"
console.log(String({}));        // "[object Object]"
JavaScript

今回は、文字列以外を受け取ったら TypeError を投げる方針にします。欠損値を空文字へ変えたい場合や、数値を表示したい場合は、呼び出し側で明示的に変換します。

空文字は正常な入力として扱う

表示する内容が空の場合は珍しくありません。空文字 "" は正常な入力として、そのまま空文字を返します。未設定を意味する null や undefined とは分けて考えます。

業務で使える完成版テンプレート

ここまでの方針をまとめた関数が、次の escapeHtml() です。DOM APIに依存しないため、ブラウザー側でもサーバー側でも、同じ変換処理を利用できます。

/**
 * 通常のHTML本文へ埋め込む文字列をエスケープします。
 *
 * 注意:
 * - HTMLサニタイザーではありません。
 * - JavaScript、CSS、URLなどの安全性は保証しません。
 * - エスケープ済み文字列を再入力しないでください。
 *
 * @param {string} text エスケープする元の文字列
 * @returns {string} HTMLエスケープ後の文字列
 * @throws {TypeError} 入力が文字列ではない場合
 */
function escapeHtml(text) {
  // 不正な型を、意図せず文字列化しない
  if (typeof text !== "string") {
    throw new TypeError("text は文字列で指定してください");
  }

  // 変換する5文字の対応表
  const entities = {
    "&": "&",
    "<": "<",
    ">": ">",
    '"': """,
    "'": "'"
  };

  // 一致した文字に対応する文字参照を返す
  return text.replace(/[&<>"']/g, character => entities[character]);
}

// タグの記法を文字として扱う
console.log(escapeHtml("<strong>重要</strong>"));
// "<strong>重要</strong>"

// アンパサンドを変換する
console.log(escapeHtml("A & B"));
// "A & B"

// 引用符も変換する
console.log(escapeHtml('"商品名" と \'分類\''));
// ""商品名" と '分類'"

// 日本語や絵文字はそのまま残る
console.log(escapeHtml("お知らせ 📌"));
// "お知らせ 📌"

// 空文字も正常に扱う
console.log(escapeHtml(""));
// ""
JavaScript

この関数では、前後の空白除去、改行の削除、全角・半角の統一などは行いません。エスケープは、元の内容を表示用の表現へ変換する処理だからです。データの整形が必要な場合は、別の処理として設計します。

例題:HTML文字列の本文へ埋め込む

構造は固定し、可変の文字列だけをエスケープする

HTML文字列を返す必要がある場合は、タグなどの固定部分と、外部から受け取る文字列を分けます。可変部分だけをエスケープし、通常の本文位置へ埋め込みます。

/**
 * 商品名を表示するHTML文字列を作ります。
 * 商品名は、通常の要素の本文へ挿入します。
 */
function createProductHtml(productName) {
  // 外部から受け取る部分だけをエスケープする
  const escapedName = escapeHtml(productName);

  // タグの構造は、コード側で固定する
  return `<p class="product-name">${escapedName}</p>`;
}

const html = createProductHtml("A & B <限定版>");

console.log(html);
// '<p class="product-name">A & B <限定版></p>'
JavaScript

この例で組み立てたHTMLを適切なHTML描画処理へ渡すと、商品名は A & B <限定版> という文字として表示されます。商品名がHTMLの構造を変更しないよう、挿入位置を通常の本文に限定しています。

ただし、DOMを直接操作できるなら、同じ表示は次のように textContent で作るほうが単純です。

// DOMを直接作れる場合の推奨例
const productName = "A & B <限定版>";

const element = document.createElement("p");
element.className = "product-name";

// 生の文字列を、そのままテキストとして設定する
element.textContent = productName;

document.body.append(element);
JavaScript

重要:二重エスケープを防ぐ

HTMLエスケープは繰り返しても同じ結果になる処理ではない

エスケープ済みの文字列をもう一度処理すると、文字参照のアンパサンドも変換されます。その結果、本来表示したかった文字ではなく、文字参照そのものが見える場合があります。

// 元の値
const original = "<b>";

// 1回目の変換
const once = escapeHtml(original);

// 2回目の変換
const twice = escapeHtml(once);

console.log(once);  // "<b>"
console.log(twice); // "&lt;b&gt;"
JavaScript

これを防ぐ基本方針は、元データを保持し、出力先が決まった時点で、その出力先に必要な変換を1回だけ行うことです。HTML用、JSON用、CSV用などの表現を、元データと混同しないようにします。

文字参照らしい並びを勝手に信用しない

二重エスケープを避けるために、&lt; などを見つけたら変換を省く実装は、入力の意味を曖昧にします。利用者が、文字参照の記法そのものを入力している可能性もあるからです。

// 利用者が文字参照の記法そのものを入力した場合
const input = "<";

// アンパサンドを変換するのが、この関数の正しい動作
console.log(escapeHtml(input));
// "&lt;"
JavaScript

この結果を通常のHTML本文として表示すると、入力したとおりの &lt; が見えます。既存の文字参照を特別扱いするのではなく、入力が生データなのか出力用データなのかを管理することが大切です。

セキュリティ上の注意:すべての場所で使えるわけではない

属性値は引用符と属性の種類まで確認する

固定された通常の文字列属性へ値を入れる場合でも、属性値を必ず引用符で囲む必要があります。今回の関数はスペースや等号などを変換しないため、引用符なしの属性へ値を埋め込む用途には使えません。

また、引用符をエスケープしても、イベントハンドラー属性、URL属性、スタイル属性などが安全になるわけではありません。属性が何を意味するかを含めて検証します。

URLは許可する形式を別に検証する

HTMLエスケープは、URLの接続先やスキームが適切かを判断しません。リンク先などを扱う場合は、URLとして解析し、許可するスキームや必要に応じた接続先の制限を設けます。

encodeURIComponent() も別の処理です。これはURLの構成要素をエンコードするための関数であり、HTMLエスケープの代替でも、URLの安全性を判定する関数でもありません。

JavaScriptやCSSへ流用しない

script 要素内、イベントハンドラー、CSSなどへ文字列を埋め込む場合は、解釈のルールが異なります。HTMLエスケープ関数を流用してはいけません。

可能であれば、文字列をプログラムのソースへ埋め込む設計を避け、DOMのプロパティやイベント登録APIなどを使います。テンプレートエンジンやフレームワークを利用する場合も、自動エスケープの範囲と例外を確認します。

独自関数で「安全なHTML」と宣言しない

今回の関数が返すのは、通常のHTML本文へ埋め込むための文字列です。どの文脈でも安全なHTMLを返すわけではありません。型チェックや対応表の実装だけで、HTML全体の安全性を保証したと考えないことが重要です。

改行を残して表示したい場合

HTMLエスケープをしても、HTMLの通常の表示では、文字列内の改行がそのまま画面上の改行になるとは限りません。だからといって、安易に改行を <br> へ変換し、HTMLとして挿入する必要はありません。

プレーンテキストとして表示するなら、textContent とCSSの white-space を組み合わせる方法があります。

// 改行を含む文字列
const message = "1行目です。\n2行目です。";

// 表示用の通常要素を作る
const output = document.createElement("div");

// 改行や連続する空白を表示へ反映する
output.style.whiteSpace = "pre-wrap";

// エスケープせず、生の文字列をテキストとして設定する
output.textContent = message;

document.body.append(output);
JavaScript

このように、文字の安全な設定と見た目の調整を分けると、HTML文字列の組み立てを減らせます。

テストで正常系と境界条件を確認する

5文字・空文字・既存の文字参照を確認する

エスケープ関数は短いコードですが、変換漏れがないことを確認する必要があります。正常な文字列に加え、複数の対象文字、空文字、すでに文字参照のように見える入力もテストします。

// [入力値, 期待する結果]
const testCases = [
  ["plain text", "plain text"],
  ["", ""],
  ["&", "&"],
  ["<", "<"],
  [">", ">"],
  ['"', """],
  ["'", "'"],
  ["A & B", "A & B"],
  ["<b>重要</b>", "<b>重要</b>"],
  ["<", "&lt;"],
  ["日本語と絵文字 📌", "日本語と絵文字 📌"],
  ["1行目\n2行目", "1行目\n2行目"],
  ["<<&&", "<<&&"]
];

// 実際の結果と期待値を比較する
for (const [input, expected] of testCases) {
  const actual = escapeHtml(input);

  console.log({
    input,
    expected,
    actual,
    pass: actual === expected
  });
}
JavaScript

文字列以外では例外になることを確認する

// この関数では受け付けない入力
const invalidValues = [null, undefined, 123, true, {}, []];

for (const value of invalidValues) {
  try {
    // 型が違うため、TypeErrorを期待する
    escapeHtml(value);

    console.error("失敗:期待した例外が発生しませんでした");
  } catch (error) {
    // 例外の種類を確認する
    console.log({
      value,
      pass: error instanceof TypeError
    });
  }
}
JavaScript

このテストは変換関数の仕様を確認するものです。実際の安全性を評価するには、呼び出し側が正しい出力位置で利用しているか、自動エスケープとの重複がないかも確認します。

まとめ

文字列のHTMLエスケープは、HTMLの構文に使われる文字を、表示用の文字参照へ変換する処理です。ただし、最初に判断するべきなのは、そもそもHTML文字列を作る必要があるかどうかです。

  • 通常の要素へ文字を表示するだけなら、まず textContent を使います。
  • HTML文字列の本文へ埋め込む場合は、可変部分をエスケープします。
  • 基本の5文字を、対応表と置換関数で変換できます。
  • 元データを保持し、出力時に必要な変換を1回だけ行います。
  • HTMLエスケープを、URL検証、JavaScript、CSS、サニタイズへ流用しません。
  • 関数単体のテストに加え、実際の出力位置と表示方法も確認します。

業務で重要なのは、単に記号を置き換えることではなく、「どこへ出力するための変換なのか」を明確にすることです。用途を限定した小さな関数と、安全な表示方法を組み合わせることで、読みやすく保守しやすい実装になります。

タイトルとURLをコピーしました