Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 13553] ファイルの概要

このコミットは、Go言語のexp/htmlパッケージにおいて、<script>タグ内のコンテンツのエスケープおよび二重エスケープ処理を正確に実装することを目的としています。HTMLの<script>タグ内部のテキストは通常の生テキストとは異なり、特別なパースルールが適用されます。このコミットは、これらの複雑なルールに対応し、特に</script>タグの誤認識や、HTMLコメント(<!---->)によるスクリプト内容の隠蔽といった挙動を適切に処理するようにパーサーを改善しています。これにより、76の追加テストがパスするようになり、パーサーの堅牢性と正確性が向上しました。

コミット

commit dbbfbcc4a18c3303c4e8a55cf652c67702c91aed
Author: Andrew Balholm <andybalholm@gmail.com>
Date:   Wed Aug 1 14:45:35 2012 +1000

    exp/html: implement escaping and double-escaping in scripts
    
    The text inside <script> tags is not ordinary raw text; there are all sorts
    of other complications. This CL implements those complications.
    
    Pass 76 additional tests.
    
    R=nigeltao
    CC=golang-dev
    https://golang.org/cl/6443070

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/dbbfbcc4a18c3303c4e8a55cf652c67702c91aed

元コミット内容

exp/htmlパッケージにおいて、スクリプト内のエスケープと二重エスケープを実装しました。 <script>タグ内のテキストは通常の生テキストではなく、様々な複雑な要素があります。この変更はそれらの複雑な要素を実装します。 76の追加テストがパスするようになりました。

変更の背景

HTMLの<script>タグのコンテンツは、一般的なHTML要素のコンテンツとは異なる特殊なパースルールに従います。通常のHTML要素では、&lt;のようなHTMLエンティティはデコードされ、<のような特殊文字はそのまま扱われます。しかし、<script>タグ内では、コンテンツはJavaScriptコードとして扱われるため、HTMLパーサーは特定のシーケンス(特に</script>)を特別に処理する必要があります。

このコミットが行われた背景には、主に以下の問題がありました。

  1. </script>の誤認識: <script>タグの内部にリテラルな</script>という文字列(例えばJavaScriptの文字列リテラル内や正規表現内)が存在する場合、HTMLパーサーはそれをスクリプトブロックの終了タグと誤認識し、スクリプトのパースを途中で終了させてしまう可能性があります。これは、JavaScriptコードが意図せずHTMLとして解釈され、構文エラーやセキュリティ上の脆弱性(XSSなど)を引き起こす原因となります。
  2. HTMLコメントの特殊な扱い: 過去のブラウザとの互換性のため、<script>タグ内ではHTMLコメントの開始シーケンス<!--と終了シーケンス-->が特殊な意味を持つことがあります。特に<!--が検出されると、パーサーは「スクリプトデータエスケープ状態」のような特殊な状態に入り、その後の</script>の検出を一時的に無視する場合があります。これは、古いブラウザがJavaScriptをサポートしていなかった時代に、スクリプトコードをHTMLコメントで囲んで非表示にする慣習があったためです。この複雑な挙動を正確にエミュレートしないと、既存のウェブコンテンツのパースに問題が生じる可能性があります。
  3. 二重エスケープ状態: HTMLの仕様には「スクリプトデータ二重エスケープ状態 (script data double escape state)」という、さらに複雑な状態が定義されています。これは、<!--の後に特定のシーケンス(例えば別の<script>タグの開始)が続く場合に発生し、パーサーがさらに厳密なルールでコンテンツを処理する状態です。

これらの複雑なルールを正確に実装することで、exp/htmlパーサーはより多くの現実世界のHTMLドキュメントを正しく処理できるようになり、堅牢性と互換性が向上します。このコミットは、これらの特殊なケースを網羅する76の追加テストをパスすることで、その正確性を検証しています。

前提知識の解説

このコミットを理解するためには、HTMLのパース、特に<script>タグの特殊な扱いに関する以下の概念を理解しておく必要があります。

  1. HTMLパーサーのステートマシン: HTMLのパースは、入力ストリームを読み込みながら、現在の状態(ステート)に基づいて次の文字をどのように解釈するかを決定するステートマシンとして機能します。例えば、タグの内部にいる状態、テキストを読み込んでいる状態などがあります。
  2. 生テキスト (Raw Text) とRCDATA:
    • 生テキスト (Raw Text): <style><xmp>などの要素のコンテンツは「生テキスト」として扱われます。これは、内部のコンテンツがHTMLとしてパースされず、リテラルなテキストとして扱われることを意味します。ただし、終了タグ(例: </style>)が検出されると、その要素は終了します。
    • RCDATA (Raw Character Data): <textarea><title>などの要素のコンテンツは「RCDATA」として扱われます。これも生テキストに似ていますが、HTMLエンティティ(例: &lt;)はデコードされます。
    • <script>タグの特殊性: <script>タグのコンテンツは、厳密には生テキストでもRCDATAでもなく、独自の「スクリプトデータ (Script Data)」というパースルールに従います。これは、</script>という文字列が検出されると、そのタグが終了するという点で生テキストに似ていますが、さらに複雑なエスケープルールが適用されます。
  3. </script>の終端: HTMLパーサーは、<script>タグのコンテンツを読み込んでいる最中に、</script>という文字列(大文字・小文字を区別しない)を検出すると、そのスクリプトブロックが終了したと判断します。これは、JavaScriptコード内の文字列リテラルや正規表現にこのシーケンスが含まれている場合に問題を引き起こします。例えば、var s = "</script>";のようなコードは、パーサーによって途中で切断されてしまいます。
  4. HTMLコメントの役割:
    • <!-- (Script Data Escape Start): <script>タグ内で<!--というシーケンスが検出されると、パーサーは「スクリプトデータエスケープ状態」に入ります。この状態では、</script>の検出が一時的に抑制され、代わりに-->がスクリプトデータエスケープ状態の終了を示すものとして扱われます。これは、古いブラウザがJavaScriptをサポートしていなかった時代に、スクリプトコードをHTMLコメントで囲んで非表示にするために使われた慣習の名残です。
    • --> (Script Data Escape End): スクリプトデータエスケープ状態中に-->が検出されると、パーサーは通常のスクリプトデータ状態に戻ります。
  5. 二重エスケープ (Double Escaping): HTMLの仕様には、さらに複雑な「スクリプトデータ二重エスケープ状態 (Script Data Double Escape State)」という概念があります。これは、スクリプトデータエスケープ状態中に、再び<script>のようなタグの開始シーケンスが検出された場合に発生します。この状態では、パーサーはさらに厳密なルールでコンテンツを処理し、</script>の検出を再び抑制したり、特定のシーケンスを特別に扱ったりします。この挙動は、非常に稀なケースですが、HTMLパーサーが完全に仕様に準拠するためには実装が必要です。
  6. トークナイザー (Tokenizer): HTMLパーサーの最初の段階はトークナイザーです。トークナイザーは、入力ストリームを読み込み、HTMLの要素、属性、テキスト、コメントなどの意味のある単位(トークン)に分割します。このコミットの変更は、主にこのトークナイザーのロジック、特に<script>タグのコンテンツを処理する部分に焦点を当てています。

これらの特殊なルールは、ウェブの歴史的な経緯と互換性を維持するために存在しており、現代のブラウザやHTMLパーサーはこれらを正確に実装する必要があります。

技術的詳細

このコミットの技術的詳細の中心は、src/pkg/exp/html/token.goファイルにおけるTokenizer構造体のreadScript関数の追加と、readRawOrRCDATA関数の変更です。これらの変更は、HTML5のスクリプトデータパースルールを厳密に実装しています。

HTML5の仕様では、<script>タグのコンテンツのパースは、以下のような複数の状態を持つステートマシンとして定義されています。

  1. Script Data State (スクリプトデータ状態):
    • デフォルトの状態。文字を読み込み、<!--</、またはEOF(ファイルの終端)を検出すると、対応する次の状態に遷移します。
    • <!--を検出すると、Script Data Escape Start Stateへ。
    • </を検出すると、Script Data End Tag Open Stateへ。
    • それ以外の文字はスクリプトデータとして扱われます。
  2. Script Data End Tag Open State (スクリプトデータ終了タグ開始状態):
    • </を検出した後に遷移。次の文字がASCIIアルファベットの場合、Script Data End Tag Name Stateへ遷移し、終了タグの名前(例: script)を読み込みます。
    • </script>が完全に一致し、その後に空白文字、/、または>が続く場合、スクリプトタグの終了と判断されます。
  3. Script Data Escape Start State (スクリプトデータエスケープ開始状態):
    • <!--を検出した後に遷移。次の文字が-の場合、Script Data Escape Start Dash Stateへ。それ以外はScript Data Stateに戻ります。
  4. Script Data Escape Start Dash State (スクリプトデータエスケープ開始ダッシュ状態):
    • <!--の後に-を検出した後に遷移。次の文字が-の場合、Script Data Escaped Dash Dash Stateへ。それ以外はScript Data Stateに戻ります。
  5. Script Data Escaped State (スクリプトデータエスケープ状態):
    • <!--の後に続くスクリプトデータがこの状態になります。この状態では、</-、またはEOFを検出すると、対応する次の状態に遷移します。
    • </を検出すると、Script Data Escaped Less-than Sign Stateへ。
    • -を検出すると、Script Data Escaped Dash Stateへ。
    • それ以外の文字はエスケープされたスクリプトデータとして扱われます。
  6. Script Data Escaped Dash State (スクリプトデータエスケープダッシュ状態):
    • Script Data Escaped State中に-を検出した後に遷移。次の文字が-の場合、Script Data Escaped Dash Dash Stateへ。それ以外はScript Data Escaped Stateに戻ります。
  7. Script Data Escaped Dash Dash State (スクリプトデータエスケープダッシュダッシュ状態):
    • Script Data Escaped Dash State中に-を検出した後に遷移。この状態は、-->の終了シーケンスを検出するためのものです。
    • >を検出すると、Script Data Stateに戻ります(コメントの終了)。
    • -を検出すると、この状態を維持します。
    • それ以外はScript Data Escaped Stateに戻ります。
  8. Script Data Escaped Less-than Sign State (スクリプトデータエスケープ小なり記号状態):
    • Script Data Escaped State中に<を検出した後に遷移。次の文字が/の場合、Script Data Escaped End Tag Open Stateへ。ASCIIアルファベットの場合、Script Data Double Escape Start Stateへ。それ以外はScript Data Escaped Stateに戻ります。
  9. Script Data Double Escape Start State (スクリプトデータ二重エスケープ開始状態):
    • Script Data Escaped Less-than Sign State中にASCIIアルファベットを検出した後に遷移。この状態では、scriptという文字列(大文字・小文字を区別しない)が続くかどうかをチェックします。
    • scriptが完全に一致し、その後に空白文字、/、または>が続く場合、Script Data Double Escaped Stateへ遷移します。
    • それ以外はScript Data Escaped Stateに戻ります。
  10. Script Data Double Escaped State (スクリプトデータ二重エスケープ状態):
    • 二重エスケープされたスクリプトデータがこの状態になります。この状態では、</-、またはEOFを検出すると、対応する次の状態に遷移します。
    • </を検出すると、Script Data Double Escaped Less-than Sign Stateへ。
    • -を検出すると、Script Data Double Escaped Dash Stateへ。
    • それ以外の文字は二重エスケープされたスクリプトデータとして扱われます。
  11. Script Data Double Escape End State (スクリプトデータ二重エスケープ終了状態):
    • Script Data Double Escaped Less-than Sign State中に/を検出した後に遷移。この状態では、scriptという文字列(大文字・小文字を区別しない)が続くかどうかをチェックします。
    • scriptが完全に一致し、その後に空白文字、/、または>が続く場合、Script Data Escaped Stateへ戻ります。
    • それ以外はScript Data Double Escaped Stateに戻ります。

このコミットでは、readScript関数がこれらの複雑な状態遷移をgoto文とラベルを使って実装しています。各状態は特定のラベルに対応し、文字の読み込みと条件分岐によって次の状態へ遷移します。これにより、HTML5のスクリプトデータパースルールが正確に再現され、</script>の誤認識やHTMLコメントによるエスケープが適切に処理されるようになります。

また、readRawEndTagというヘルパー関数が追加され、</foo>のような終了タグを効率的に読み込み、それが現在のrawTag(例: script)と一致するかどうかを判断するロジックがカプセル化されています。これは、readRawOrRCDATAreadScriptの両方で再利用されます。

テストログファイル(scriptdata01.dat.logtests16.dat.log)の変更は、これらの新しいパースルールが正しく機能することを確認するためのものです。特に、以前はFAILまたはPARSEとされていた多くのテストケースがPASSに変わっており、これはパーサーが以前は誤って処理していた複雑なスクリプトタグのシナリオを正しく扱えるようになったことを示しています。

コアとなるコードの変更箇所

このコミットにおける主要なコード変更は、src/pkg/exp/html/token.goファイルに集中しています。

  1. readRawOrRCDATA関数の変更:
    • この関数は、元々<script><textarea>のような生テキスト/RCDATA要素のコンテンツを読み込むための汎用的な関数でした。
    • 変更後、z.rawTag == "script"の場合に、新しく追加されたz.readScript()関数を呼び出すように分岐が追加されました。これにより、<script>タグのコンテンツは専用の、より複雑なパースロジックで処理されるようになります。
    • 元の終了タグ検出ロジック(</foo>の検出)は、新しく追加されたz.readRawEndTag()関数に置き換えられ、コードの重複が排除されました。
  2. readRawEndTag関数の追加:
    • この新しいヘルパー関数は、</foo>のような終了タグを読み込み、それが現在のz.rawTagと一致するかどうかを判断します。
    • 一致した場合、入力位置を巻き戻し(z.raw.endを調整)、trueを返します。これにより、呼び出し元は終了タグが検出されたことを知り、適切な処理を行うことができます。
    • この関数は、readRawOrRCDATAreadScriptの両方から呼び出され、終了タグ検出の共通ロジックを提供します。
  3. readScript関数の追加:
    • この関数は、<script>タグのコンテンツをパースするための、このコミットの最も重要な追加です。
    • HTML5のスクリプトデータパースルールに従い、</script>タグの検出、HTMLコメント(<!---->)によるエスケープ、および二重エスケープのシナリオを処理します。
    • 内部では、goto文と複数のラベル(scriptData, scriptDataLessThanSign, scriptDataEndTagOpen, scriptDataEscapeStart, scriptDataEscapeStartDash, scriptDataEscaped, scriptDataEscapedDash, scriptDataEscapedDashDash, scriptDataEscapedLessThanSign, scriptDataEscapedEndTagOpen, scriptDataDoubleEscapeStart, scriptDataDoubleEscaped, scriptDataDoubleEscapedDash, scriptDataDoubleEscapedDashDash, scriptDataDoubleEscapedLessThanSign, scriptDataDoubleEscapeEnd)を使用して、HTML5仕様で定義されている複雑なステートマシンを実装しています。
    • z.readByte()を繰り返し呼び出して文字を読み込み、現在の状態と読み込んだ文字に基づいて次の状態に遷移します。
    • z.raw.endを適切に調整することで、トークンの開始と終了位置を正確に管理します。

これらの変更により、exp/htmlパッケージは、<script>タグのコンテンツをHTML5の仕様に厳密に従ってパースできるようになり、より堅牢で正確なHTMLパーサーとしての機能が強化されました。

コアとなるコードの解説

src/pkg/exp/html/token.goにおけるreadScript関数は、HTML5のスクリプトデータパースアルゴリズムをGo言語で実装したものです。この関数は、<script>タグの開始が検出された後に呼び出され、対応する</script>タグが検出されるまで、スクリプトコンテンツを文字ごとに読み込み、その間に発生する可能性のある特殊なシーケンス(<!----></script>自体)を適切に処理します。

以下に、主要な部分とその役割を解説します。

  1. readScript()関数のエントリポイントと終了処理:

    func (z *Tokenizer) readScript() {
        defer func() {
            z.data.end = z.raw.end
        }()
        var c byte
        // ... 状態遷移ロジック ...
    }
    
    • defer文は、関数が終了する際にz.data.end = z.raw.endを実行することを保証します。これは、スクリプトコンテンツの実際の終了位置をz.data.endに設定するためのものです。z.raw.endは、トークナイザーが現在読み込んでいる位置を示します。
  2. 主要な状態遷移: readScript関数は、goto文とラベルを多用して、HTML5仕様のスクリプトデータパースアルゴリズムの各状態を表現しています。

    • scriptData (スクリプトデータ状態):

      scriptData:
          c = z.readByte()
          if z.err != nil { return }
          if c == '<' {
              goto scriptDataLessThanSign
          }
          goto scriptData
      
      • これはスクリプトコンテンツを読み込むデフォルトの状態です。
      • <を検出すると、scriptDataLessThanSign状態に遷移し、終了タグやコメントの開始シーケンスの可能性をチェックします。
      • それ以外の文字は単に読み飛ばされ、この状態を維持します。
    • scriptDataLessThanSign (スクリプトデータ小なり記号状態):

      scriptDataLessThanSign:
          c = z.readByte()
          if z.err != nil { return }
          switch c {
          case '/':
              goto scriptDataEndTagOpen // </script> の可能性
          case '!':
              goto scriptDataEscapeStart // <!-- の可能性
          }
          z.raw.end-- // < の後に特殊な文字が続かない場合、< を再消費
          goto scriptData
      
      • <の後に続く文字をチェックします。
      • /であれば、</script>の開始の可能性があるのでscriptDataEndTagOpenへ。
      • !であれば、<!--の開始の可能性があるのでscriptDataEscapeStartへ。
      • どちらでもなければ、<は通常の文字として扱われるべきなので、z.raw.end--で1バイト戻し、scriptData状態に戻ります。
    • scriptDataEndTagOpen (スクリプトデータ終了タグ開始状態):

      scriptDataEndTagOpen:
          if z.readRawEndTag() || z.err != nil {
              return // </script> が検出されたか、エラーが発生
          }
          goto scriptData // 検出されなければ、通常のスクリプトデータに戻る
      
      • z.readRawEndTag()を呼び出して、</script>が完全に一致するかどうかをチェックします。
      • 一致した場合、readRawEndTagtrueを返し、readScript関数は終了します(スクリプトタグの終了)。
      • 一致しない場合、通常のscriptData状態に戻ります。
    • scriptDataEscapeStart (スクリプトデータエスケープ開始状態):

      scriptDataEscapeStart:
          c = z.readByte()
          if z.err != nil { return }
          if c == '-' {
              goto scriptDataEscapeStartDash // <!-- の可能性
          }
          z.raw.end--
          goto scriptData
      
      • <!の後に続く文字をチェックします。-であればscriptDataEscapeStartDashへ。
    • scriptDataEscaped (スクリプトデータエスケープ状態):

      scriptDataEscaped:
          c = z.readByte()
          if z.err != nil { return }
          switch c {
          case '-':
              goto scriptDataEscapedDash
          case '<':
              goto scriptDataEscapedLessThanSign
          }
          goto scriptDataEscaped
      
      • <!--の後に続くコンテンツを処理する状態です。
      • -<を検出すると、それぞれscriptDataEscapedDashscriptDataEscapedLessThanSignに遷移し、--></script>の可能性をチェックします。
    • scriptDataDoubleEscaped (スクリプトデータ二重エスケープ状態):

      scriptDataDoubleEscaped:
          c = z.readByte()
          if z.err != nil { return }
          switch c {
          case '-':
              goto scriptDataDoubleEscapedDash
          case '<':
              goto scriptDataDoubleEscapedLessThanSign
          }
          goto scriptDataDoubleEscaped
      
      • 二重エスケープされたコンテンツを処理する状態です。ここでも-<を特別に扱います。
    • scriptDataDoubleEscapeEnd (スクリプトデータ二重エスケープ終了状態):

      scriptDataDoubleEscapeEnd:
          if z.readRawEndTag() { // </script> が検出された場合
              z.raw.end += len("</script>") // </script> を再消費して、エスケープ状態に戻る
              goto scriptDataEscaped
          }
          if z.err != nil { return }
          goto scriptDataDoubleEscaped
      
      • 二重エスケープ状態中に</を検出した後に、scriptという文字列が続いた場合に遷移します。
      • z.readRawEndTag()</script>が検出された場合、z.raw.endを調整して</script>を再消費し、scriptDataEscaped状態に戻ります。これは、二重エスケープが終了し、一段階エスケープされた状態に戻ることを意味します。
  3. readRawEndTag()ヘルパー関数:

    func (z *Tokenizer) readRawEndTag() bool {
        // ... ロジック ...
    }
    
    • この関数は、</の後に続くタグ名がz.rawTag(この場合はscript)と一致するかどうかを効率的にチェックします。
    • 大文字・小文字を区別せずに比較し、一致した場合、その後に空白文字、/、または>が続くかをチェックして、有効な終了タグであるかを判断します。
    • 有効な終了タグであればtrueを返し、z.raw.endを調整して、呼び出し元が終了タグを再消費できるようにします。

これらの状態と遷移の組み合わせにより、readScript関数はHTML5の複雑なスクリプトデータパースルールを正確に模倣し、様々なエッジケース(例えば、スクリプト内のコメント、部分的なタグ、ネストされたタグのような文字列)を適切に処理できるようになっています。

関連リンク

参考にした情報源リンク