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

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

このコミットは、Go言語の実験的なHTMLパーサーパッケージ exp/html における重要な改善を導入しています。具体的には、外部コンテンツ(Foreign Content)内のCDATAセクションのパース処理を修正し、コメント内のNULLバイト(\x00)をUnicodeの置換文字(U+FFFD)に変換するようになりました。これにより、HTML5のパース仕様への準拠が向上し、23の追加テストがパスするようになっています。

コミット

commit a1f340fa1a26fd29f1369cbc91755e7519813dd0
Author: Andrew Balholm <andybalholm@gmail.com>
Date:   Fri Jul 27 16:05:25 2012 +1000

    exp/html: parse CDATA sections in foreign content
    
    Also convert NUL to U+FFFD in comments.
    
    Pass 23 additional tests.
    
    R=nigeltao
    CC=golang-dev
    https://golang.org/cl/6446055

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

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

元コミット内容

exp/html: parse CDATA sections in foreign content

Also convert NUL to U+FFFD in comments.

Pass 23 additional tests.

R=nigeltao
CC=golang-dev
https://golang.org/cl/6446055

変更の背景

HTML5のパース仕様は非常に複雑であり、特にXML由来の要素(SVGやMathMLなど)がHTMLドキュメント内に埋め込まれる「外部コンテンツ(Foreign Content)」の扱いには、HTMLとは異なるルールが適用されます。XMLではCDATAセクションがテキストデータをマークアップとして解釈させずに含めるために使用されますが、HTMLの文脈では通常はサポートされません。しかし、外部コンテンツ内ではCDATAセクションが有効であるため、HTMLパーサーはこれを正しく処理する必要があります。

このコミット以前のexp/htmlパッケージは、外部コンテンツ内のCDATAセクションを適切にパースできていませんでした。これにより、SVGやMathMLなどのXMLベースのコンテンツがHTMLドキュメントに埋め込まれた際に、その内部のテキストが正しく解釈されない、あるいはパースエラーを引き起こす可能性がありました。

また、HTML5の仕様では、コメント内に含まれるNULLバイト(U+0000)は、Unicodeの置換文字(U+FFFD)に変換されるべきだと定められています。これは、NULLバイトが文字列の終端を示すために使われることがあり、セキュリティ上の問題やパースの不整合を引き起こす可能性があるためです。このコミットは、これらの仕様不適合を解消し、パーサーの堅牢性と標準への準拠を向上させることを目的としています。

前提知識の解説

HTML5パース仕様

HTML5のパース仕様は、WHATWGによって詳細に定義されており、ブラウザがHTMLドキュメントをどのように解析し、DOMツリーを構築するかを厳密に規定しています。この仕様は、XMLとは異なり、非常に寛容なエラー処理ルールを持つことが特徴です。

外部コンテンツ (Foreign Content)

HTML5において「外部コンテンツ(Foreign Content)」とは、HTML名前空間以外の名前空間に属する要素を指します。最も一般的な例は、SVG (Scalable Vector Graphics) や MathML (Mathematical Markup Language) です。これらのコンテンツはXMLの構文規則に従うため、HTMLの通常のパースルールとは異なる特別な処理が必要になります。

CDATAセクション

CDATA (Character Data) セクションはXMLの構文の一部であり、<& のような特殊文字を含むテキストブロックを、マークアップとしてではなく純粋な文字データとして扱いたい場合に使用されます。構文は <![CDATA[...]]> です。XMLパーサーは、このセクション内の内容をそのまま文字データとして読み込み、内部のマークアップを無視します。HTMLでは通常CDATAセクションはサポートされませんが、外部コンテンツ(SVGやMathML)内では有効です。

NULLバイト (U+0000) と Unicode置換文字 (U+FFFD)

  • NULLバイト (U+0000): ASCIIコードで0x00に相当する文字で、C言語などでは文字列の終端を示すために使われることがあります。HTMLやXMLの文脈では、NULLバイトは通常許可されず、パースエラーやセキュリティ脆弱性の原因となる可能性があります。
  • Unicode置換文字 (U+FFFD): Unicodeの「Replacement Character」で、不正な文字エンコーディングや、文字セットで表現できない文字を検出した際に、その文字の代わりに表示される特殊な記号です()。HTML5の仕様では、パース中に不正なバイトシーケンスやNULLバイトが検出された場合、U+FFFDに変換することが求められています。

技術的詳細

このコミットは、exp/htmlパッケージのトークナイザー(token.go)とパーサー(parse.go)に修正を加えています。

  1. CDATAセクションのパース:

    • parse.goread()メソッド内で、現在の要素が外部コンテンツ(n.Namespace != "")である場合にのみ、トークナイザーのcdataOKフラグをtrueに設定するように変更されました。これにより、CDATAセクションのパースが適切なコンテキストでのみ有効になります。
    • token.goreadCDATA()という新しいメソッドが追加されました。このメソッドは、<![CDATA[ シーケンスを検出し、その後の ]]> までをCDATAセクションの内容として読み込みます。
    • readMarkupDeclaration()メソッドが修正され、<! の後に [CDATA[ が続く場合にreadCDATA()を呼び出すようになりました。CDATAセクションが検出された場合、convertNULフラグがtrueに設定され、TextTokenとして返されます。
  2. コメント内のNULLバイト変換:

    • token.goTokenizer構造体にcdataOKという新しいフィールドが追加されました。
    • Text()メソッド内のNULLバイト変換ロジックが変更されました。以前はz.convertNULtrueの場合にのみNULLバイトを置換していましたが、今回の変更でCommentTokenの場合もNULLバイトを置換するようになりました。これにより、コメント内のNULLバイトもU+FFFDに変換されるようになります。

これらの変更により、パーサーはHTML5の仕様に厳密に準拠し、外部コンテンツ内のCDATAセクションを正しく処理し、コメント内のNULLバイトを適切に置換できるようになりました。

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

src/pkg/exp/html/parse.go

--- a/src/pkg/exp/html/parse.go
+++ b/src/pkg/exp/html/parse.go
@@ -390,6 +390,10 @@ func (p *parser) reconstructActiveFormattingElements() {
 
 // read reads the next token from the tokenizer.
 func (p *parser) read() error {
+	// CDATA sections are allowed only in foreign content.
+	n := p.oe.top()
+	p.tokenizer.cdataOK = n != nil && n.Namespace != ""
+
 	p.tokenizer.Next()
 	p.tok = p.tokenizer.Token()
 	if p.tok.Type == ErrorToken {

src/pkg/exp/html/token.go

--- a/src/pkg/exp/html/token.go
+++ b/src/pkg/exp/html/token.go
@@ -155,6 +155,8 @@ type Tokenizer struct {
 	// convertNUL is whether NUL bytes in the current token's data should
 	// be converted into \ufffd replacement characters.
 	convertNUL bool
+	// cdataOK is whether CDATA sections are allowed in the current context.
+	cdataOK bool
 }
 
 // Err returns the error associated with the most recent ErrorToken token.
@@ -347,8 +349,8 @@ func (z *Tokenizer) readUntilCloseAngle() {
 }
 
 // readMarkupDeclaration reads the next token starting with "!". It might be
-// a "<!--comment-->", a "<!DOCTYPE foo>", or "<!a bogus comment". The opening
-// "<!" has already been consumed.
+// a "<!--comment-->", a "<!DOCTYPE foo>", a "<![CDATA[section]]>" or
+// "<!a bogus comment". The opening "<!" has already been consumed.
 func (z *Tokenizer) readMarkupDeclaration() TokenType {
 	z.data.start = z.raw.end
 	var c [2]byte
@@ -364,27 +366,81 @@ func (z *Tokenizer) readMarkupDeclaration() TokenType {
 		return CommentToken
 	}
 	z.raw.end -= 2
+	if z.readDoctype() {
+		return DoctypeToken
+	}
+	if z.cdataOK && z.readCDATA() {
+		z.convertNUL = true
+		return TextToken
+	}
+	// It's a bogus comment.
+	z.readUntilCloseAngle()
+	return CommentToken
+}
+
+// readDoctype attempts to read a doctype declaration and returns true if
+// successful. The opening "<!" has already been consumed.
+func (z *Tokenizer) readDoctype() bool {
 	const s = "DOCTYPE"
 	for i := 0; i < len(s); i++ {
 		c := z.readByte()
 		if z.err != nil {
 			z.data.end = z.raw.end
-			return CommentToken
+			return false
 		}
 		if c != s[i] && c != s[i]+('a'-'A') {
 			// Back up to read the fragment of "DOCTYPE" again.
 			z.raw.end = z.data.start
-			z.readUntilCloseAngle()
-			return CommentToken
+			return false
 		}
 	}
 	if z.skipWhiteSpace(); z.err != nil {
 		z.data.start = z.raw.end
 		z.data.end = z.raw.end
-		return DoctypeToken
+		return true
 	}
 	z.readUntilCloseAngle()
-	return DoctypeToken
+	return true
+}
+
+// readCDATA attempts to read a CDATA section and returns true if
+// successful. The opening "<!" has already been consumed.
+func (z *Tokenizer) readCDATA() bool {
+	const s = "[CDATA["
+	for i := 0; i < len(s); i++ {
+		c := z.readByte()
+		if z.err != nil {
+			z.data.end = z.raw.end
+			return false
+		}
+		if c != s[i] {
+			// Back up to read the fragment of "[CDATA[" again.
+			z.raw.end = z.data.start
+			return false
+		}
+	}
+	z.data.start = z.raw.end
+	brackets := 0
+	for {
+		c := z.readByte()
+		if z.err != nil {
+			z.data.end = z.raw.end
+			return true
+		}
+		switch c {
+		case ']':
+			brackets++
+		case '>':
+			if brackets >= 2 {
+				z.data.end = z.raw.end - len("]]>")
+				return true
+			}
+			brackets = 0
+		default:
+			brackets = 0
+		}
+	}
+	panic("unreachable")
 }
 
 // startTagIn returns whether the start tag in z.buf[z.data.start:z.data.end]
@@ -751,7 +807,7 @@ func (z *Tokenizer) Text() []byte {
 		z.data.start = z.raw.end
 		z.data.end = z.raw.end
 		s = convertNewlines(s)
-		if z.convertNUL && bytes.Contains(s, nul) {
+		if (z.convertNUL || z.tt == CommentToken) && bytes.Contains(s, nul) {
 			s = bytes.Replace(s, nul, replacement, -1)
 		}
 		if !z.textIsRaw {

テストログの変更

src/pkg/exp/html/testlogs/plain-text-unsafe.dat.logsrc/pkg/exp/html/testlogs/tests21.dat.log の両方で、以前はFAILとなっていたCDATAセクション関連のテストケースがPASSに変わっています。これは、変更が正しく機能し、パーサーがこれらのケースを適切に処理できるようになったことを示しています。特にtests21.dat.logでは、SVG内のCDATAセクションに関する多数のテストがパスに変わっています。

コアとなるコードの解説

parse.go の変更点

parse.goread()関数は、トークナイザーから次のトークンを読み込む前に、現在のパースコンテキストに基づいてtokenizer.cdataOKフラグを設定します。 n := p.oe.top() は、現在開いている要素スタックの最上位要素を取得します。 p.tokenizer.cdataOK = n != nil && n.Namespace != "" の行は、現在の要素が存在し、かつその要素がHTML名前空間以外の名前空間(つまり外部コンテンツ)に属している場合にのみ、CDATAセクションのパースを許可するようにcdataOKフラグをtrueに設定します。これにより、HTMLの通常のコンテキストでCDATAセクションが誤ってパースされるのを防ぎ、HTML5の仕様に準拠します。

token.go の変更点

  1. Tokenizer構造体へのcdataOKフィールド追加: cdataOK bool は、現在のパースコンテキストでCDATAセクションが許可されているかどうかを示すフラグです。これはparse.goから設定されます。

  2. readMarkupDeclaration()関数の修正: この関数は、<! で始まるマークアップ宣言を読み込む際に呼び出されます。 新しいロジックでは、まずreadDoctype()を試み、次にz.cdataOKtrueであり、かつreadCDATA()が成功した場合にTextTokenを返します。z.convertNUL = trueが設定されるのは、CDATAセクション内のNULLバイトも置換対象とするためです。 これにより、<! の後に DOCTYPE[CDATA[ が続くパターンを適切に識別し、それ以外のパターンは「不正なコメント(bogus comment)」として処理されます。

  3. readCDATA()関数の追加: この新しい関数は、<! の後に [CDATA[ が続くシーケンスを読み込み、CDATAセクションの開始を検出します。 その後、]]> シーケンスが見つかるまで文字を読み込み、その間の内容をCDATAセクションのデータとして扱います。 brackets変数は、] 文字の連続をカウントし、]]> の終端シーケンスを検出するために使用されます。

  4. Text()関数のNULLバイト変換ロジックの修正: if (z.convertNUL || z.tt == CommentToken) && bytes.Contains(s, nul) の行が変更されました。 以前はz.convertNULtrueの場合にのみNULLバイトを置換していましたが、z.tt == CommentTokenという条件が追加されました。これは、トークンのタイプがコメントである場合にも、NULLバイトをU+FFFDに変換することを意味します。これにより、HTML5のコメント内のNULLバイト処理に関する仕様に準拠します。

これらの変更は、HTML5のパース仕様、特に外部コンテンツとNULLバイトの処理に関する厳密な要件を満たすために不可欠です。

関連リンク

参考にした情報源リンク