NuxtのフォームでHTMLタグのエスケープってどうやるの…?

シェアする:

この記事をおすすめしたい人

  • Nuxtでフォームを作ろうとしている人
  • NuxtのHTMLタグのエスケープを考えている人
  • つまりオレ

Nuxt.jsでフォーム入力を受け付ける場合のエスケープ問題について考えました。

Nuxt3のテストをちょこちょこ始めているんですが、今回はNuxt2での挙動になります。 基本的な考え方はたぶんNuxt3でも同じなんじゃないかと思います。

やりたいこととエスケープ問題

HTMLでフォームから入力を受け付ける時に入力内容にscriptタグがあったりすると、入力内容を出力する際に意図しないスクリプトが実行される問題があります。

入力側が自分のためにスクリプトを入れる場合だけではなく、悪意を持って入れてくる場合があるので、セキュリティ上の問題があるわけです。 クロスサイトスクリプティング(XSS)と呼ぶそうです。

PHPだとhtmlspecialchars関数がありましたが、Nuxt.jsではどのように対応すればいいのかというのが今回のお話。

結論、Nuxtでは対策が必要ない

なんかどうもNuxt側がデフォで対応してくれてるみたいで必要ないっぽい。

例えばこんな↓感じでv-modelでinputに表示するパターンと、


<input type="text" v-model="value">

それをプレビューで出力するこんな↓パターンがあります。


<span>{{value}}</span>

どちらの場合でもvalueにHTMLタグが入ってようがなかろうが、特に何も意識しなくていいっぽい。

逆にエスケープしてしまう方が意図しない挙動になり、例えば「ここで改行<br>」というデータの場合、エスケープするとこんな↓風になります。


ここで改行&lt;br&gt;

この説明をHTML上でやるとわけわからんことになってますが、エスケープされたタグがさらにエスケープされてしまっています。

エスケープしてない場合は一度だけエスケープされてこの↓ように。


ここで改行<br>

出力時にタグが無効化されてただの文字として出力されてます。

ちなみに変数内(上の例だとvalue)ではエスケープされていない状態で格納されています。 なのでフォームのデータを最終的にどのように使うかによっては、エスケープが必要になる場合があります。

あくまでNuxt内で上記方法で出力するケースにおいてはエスケープが不要(Nuxtがやってくれてる)というだけです。

v-htmlの場合は注意が必要

変数内のタグをそのまま出力するv-htmlを使う場合は当然ですが注意が必要です。

こういう↓場合です。


<span v-html="value"></span>

v-htmlでは上で確認したNuxt側のタグエスケープが起こらないので自前エスケープが必要。 コンポーネント出力のためにv-runtime-templateを使っている場合もたぶん同様。

Nuxtでエスケープ関数の実装

v-htmlを使う場合や最終の処理のためにはエスケープ関数が必要になるケースがあるのでとりあえず実装してみます。

Hey! ChatGPT!!

いろいろ注文つけて出て来たコードはこれ↓。


computed: {
  escapeRules() {
    return [
      { char: '&', escaped: '&amp;' },
      { char: '<', escaped: '&lt;' },
      { char: '>', escaped: '&gt;' },
      { char: '"', escaped: '&quot;' },
      { char: "'", escaped: '&#039;' },
    ]
  },
},
methods: {
  escapeHTML(value) {
    this.escapeRules.forEach(rule => {
      const regExp = new RegExp(rule.char, 'g');
      value = value.replace(regExp, rule.escaped);
    });
    
    return value;
  },
},

場所は呼び出しが楽なよう、プラグインの中にVue.mixinで定義しています。 これだと全てのコンポーネント内で「this.escapeHTML」で呼び出せます。

escapeRulesをcomputedとして別に定義しているのはこの後の処理のためです。 今から考えるとdataで良かったのか。 Vue.mixinでdataも使えるのかしら。 というか外に出さない値なんだからローカルの変数でも良かったのか。

処理内容は、escapeRulesで定義した5文字のどれかにマッチした場合、そのescapedの文字に置換するという処理になっています。

これが正しいのかPHPのhtmlspecialcharsの処理も見てみましたが、同じ5文字をエスケープしていたので合ってそう。 今のところ実際の動作でも意図したように動いています。

再実行した場合の対処は?

一度escapeHTMLした値を格納し、再度フォームに出力して、もう一度escapeHTMLした場合の対策を考えます。

どういうことかというと、何度もescapeHTMLを実行するとエスケープ後の文字にエスケープ対象の文字を含んでいるため、こんな↓感じでどんどんおかしなことになっていきます。

  • <br>
  • &lt;br&gt;
  • &amp;lt;br&amp;gt;
  • &amp;amp;lt;br&amp;amp;gt;

これの対策として「エスケープ済みの文字列なら再エスケープしない」という条件分岐を入れます。

Hey! ChatGPT!!


computed: {
  escapeRules() {
    return [
      { char: '&', escaped: '&amp;' },
      { char: '<', escaped: '&lt;' },
      { char: '>', escaped: '&gt;' },
      { char: '"', escaped: '&quot;' },
      { char: "'", escaped: '&#039;' },
    ]
  },
},
methods: {
  escapeHTML(value) {
    if ( this.isEscaped(value) ) {
      return value;
    }
    
    this.escapeRules.forEach(rule => {
      const regExp = new RegExp(rule.char, 'g');
      value = value.replace(regExp, rule.escaped);
    });
    
    return value;
  },
  isEscaped(value) {
    return this.escapeRules.some(rule => new RegExp(rule.escaped, 'g').test(value));
  },
},

さっき定義したescapeRulesのescapedの文字を含んでいればエスケープ済みとして判定して、再エスケープを行わないようになりました。

これでだいたいの場合では安全に再エスケープを防げるわけですが、このロジックで動いていることを攻撃側が知っていれば「&amp;」とセットで攻撃的なスクリプトを入れてやればいいわけです。 これについは詳しく見てないですが次にちょこっと触れます。

一部のタグだけ許可したい場合は?

例えばbrタグだけ許可したい場合とかあると思います。

その場合は自前で実装してもいいですがdompurifyというライブラリがあるようです。

コイツを使えば許可タグを設定できるだけじゃなく、上記の攻撃の対策してくれるとChatGPTくんは言ってました。 すいません、今回はコイツのテストまでできてないです。

セキュリティ対策っていたちごっこだろうから、自分で管理じゃなくてライブラリに依存してしまう方がよさそうなので、その点でもメリットあるなーと思いました。

そもそもv-htmlで出力する必要ある?

せっかくNuxt側が通常の使い方ならエスケープしてくれるところに

わざわざv-htmlで出力する必要あるの?

いや、たぶんある場合もあるんだとは思いますが、僕が今回作ったフォームではユーザ入力でタグを再現したくなることって改行=brしかなかったんですよね。 まぁbrだけだろうが出力にv-htmlを使う以上は上の対策を考えないといけないんですけども。 実装コストと考えて、プレビューでbrタグを諦めるというのもありなんじゃないかなーと。

出力にv-htmlを使わないならこの辺の問題を全て無視してしまえるので、そういうアプローチもありだなーと思いました。

シェアする: