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

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

このコミットは、Go言語の html/template パッケージにおける2つの独立したバグを修正するものです。html/template パッケージは、HTML出力におけるクロスサイトスクリプティング(XSS)攻撃を防ぐために、テンプレートの自動エスケープ機能を提供します。このコミットは、エスケープ処理の正確性と堅牢性を向上させることを目的としています。

コミット

commit 51fba7d8f543e9a3d1c192f9a4e1fa9e29ccc998
Author: Rob Pike <r@golang.org>
Date:   Wed Apr 9 15:57:50 2014 +1000

    html/template: fix two unrelated bugs
    1) The code to catch an exception marked the template as escaped
    when it was not yet, which caused subsequent executions of the
    template to not escape properly.
    2) ensurePipelineContains needs to handled Field as well as
    Identifier nodes.
    
    Fixes #7379.
    
    LGTM=mikesamuel
    R=mikesamuel
    CC=golang-codereviews
    https://golang.org/cl/85240043
---
 src/pkg/html/template/escape.go      | 51 +++++++++++++++++++++++++-----------\n src/pkg/html/template/escape_test.go | 32 ++++++++++++++++++++++\n 2 files changed, 68 insertions(+), 15 deletions(-)\n

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

https://github.com/golang/go/commit/51fba7d8f543e9a3d1c192f9a4e1fa9e29ccc998

元コミット内容

html/template: 2つの無関係なバグを修正

  1. 例外を捕捉するコードが、テンプレートがまだエスケープされていないにもかかわらず、エスケープ済みとしてマークしてしまい、その後のテンプレート実行で適切にエスケープされない原因となっていた。
  2. ensurePipelineContains 関数が Identifier ノードだけでなく Field ノードも処理する必要があった。

Issue #7379 を修正。

変更の背景

このコミットは、Go言語の html/template パッケージにおける2つの異なる、しかし重要なバグを修正するために行われました。html/template パッケージは、Webアプリケーション開発において、ユーザーが提供したデータや外部からのデータをHTMLに安全に組み込むために不可欠な自動エスケープ機能を提供します。これにより、XSS(クロスサイトスクリプティング)などのセキュリティ脆弱性を防ぎます。

1つ目のバグ: テンプレートのエスケープ状態の誤認識 html/template パッケージは、テンプレートが一度エスケープ処理されると、その状態を escaped フラグで記憶します。これにより、同じテンプレートが複数回実行される際に、不必要な再エスケープ処理を避けることができます。しかし、このバグでは、テンプレートのパースやエスケープ処理中にエラー(例外)が発生した場合に、実際にはエスケープが完了していないにもかかわらず、escaped フラグが true に設定されてしまう問題がありました。 この結果、エラー発生後に同じテンプレートが再度実行されると、システムは既にエスケープ済みであると誤解し、必要なエスケープ処理をスキップしてしまいます。これにより、未エスケープのデータがHTML出力に混入し、XSS脆弱性を引き起こす可能性がありました。

2つ目のバグ: パイプライン処理におけるノードタイプの不完全なサポート html/template パッケージでは、テンプレート内でパイプライン(|)を使用して関数やメソッドを呼び出すことができます。例えば、{{.Value | sanitize}} のように使用します。セキュリティ上の理由から、特定のコンテキスト(例:URLやJavaScript)では、特定のサニタイズ関数(エスケープ関数)がパイプラインに自動的に挿入されることがあります。この処理は ensurePipelineContains 関数によって行われます。 このバグは、ensurePipelineContains 関数が、テンプレートの抽象構文木(AST)における IdentifierNode(単純な変数名や関数名を表すノード)のみを考慮し、FieldNode(構造体のフィールドやメソッド呼び出しを表すノード、例:.SomeMethod)を適切に処理していなかったことに起因します。 結果として、FieldNode を含むパイプラインに対しては、必要なサニタイズ関数が挿入されず、セキュリティ上の保護が不完全になる可能性がありました。

これらのバグは、html/template パッケージのセキュリティ保証を損なうものであり、修正が急務でした。

前提知識の解説

Go言語の html/template パッケージ

html/template パッケージは、Go言語でHTML出力を生成するためのテンプレートエンジンです。このパッケージの最大の特徴は、コンテキストアウェアな自動エスケープ機能です。これは、テンプレート内のデータが挿入されるHTMLのコンテキスト(例:HTML要素のテキストコンテンツ、属性値、JavaScriptコード、CSSスタイル、URLなど)を自動的に判断し、そのコンテキストに応じて適切なエスケープ処理を適用することで、XSS攻撃などのWeb脆弱性を防ぐものです。

  • テンプレートのパース: テンプレート文字列を解析し、抽象構文木(AST)を構築します。
  • エスケープ処理: ASTを走査し、各データ挿入ポイントのコンテキストを分析して、必要なエスケープ関数を自動的に挿入します。
  • 実行: テンプレートとデータを結合し、最終的なHTML出力を生成します。

テンプレートエンジンのエスケープ処理の重要性 (XSS対策など)

Webアプリケーションにおいて、ユーザーからの入力やデータベースから取得したデータを直接HTMLに出力することは非常に危険です。悪意のあるユーザーが <script>alert('XSS')</script> のようなスクリプトをデータとして挿入した場合、それがエスケープされずにHTMLに出力されると、他のユーザーのブラウザでそのスクリプトが実行されてしまいます。これがXSS攻撃です。

エスケープ処理は、特殊文字(例:<, >, &, ", ')を、HTMLエンティティ(例:&lt;, &gt;, &amp;, &quot;, &#39;)に変換することで、ブラウザがそれらを単なるテキストとして解釈するようにします。html/template のようなコンテキストアウェアなエスケープは、単に特殊文字をエスケープするだけでなく、JavaScriptコンテキストではJavaScriptのエスケープルールを、URLコンテキストではURLエンコードを適用するなど、より高度な保護を提供します。

Goの text/template および html/template パッケージにおけるAST (抽象構文木)

Goのテンプレートパッケージ(text/templatehtml/template)は、テンプレート文字列を内部的に抽象構文木(AST)として表現します。ASTは、テンプレートの構造をプログラムが理解しやすいツリー構造で表現したものです。

  • parse パッケージ: テンプレート文字列を解析し、ASTを構築する役割を担います。
  • ノード (Node): ASTの各要素は「ノード」と呼ばれます。例えば、テキスト、アクション({{...}})、コマンド、パイプライン、変数などがそれぞれ異なる種類のノードとして表現されます。
  • parse.IdentifierNode: テンプレート内の識別子(変数名、関数名など)を表すノードです。例: {{.Name}}Name
  • parse.FieldNode: 構造体のフィールドアクセスやメソッド呼び出しを表すノードです。例: {{.User.Name}}.User.Name{{.Value | .SomeMethod}}.SomeMethodFieldNode は複数の識別子(例: User, Name)を含むことができます。

panicrecover のGoにおける挙動

Go言語には、例外処理のメカニズムとして panicrecover があります。

  • panic: プログラムの実行を中断し、現在のゴルーチンスタックをアンワインド(巻き戻し)します。これは通常、回復不可能なエラーやプログラミング上のバグを示すために使用されます。
  • recover: defer 関数内で呼び出された場合にのみ機能し、panic からのパニック状態を捕捉し、プログラムの実行を再開させることができます。recovernil 以外の値を返した場合、パニックが捕捉されたことを意味します。

このコミットの1つ目のバグは、panic が発生した際に recover で捕捉する処理のロジックに問題がありました。

技術的詳細

1つ目のバグの修正: escapeTemplates 関数におけるエスケープ状態の誤認識

escapeTemplates 関数は、テンプレートのエスケープ処理を行う主要な関数です。元のコードでは、この関数内で panic が発生した場合に recover を使用してパニックを捕捉し、エラーを返すロジックがありました。

// 修正前のコードの抜粋
func escapeTemplates(tmpl *Template, names ...string) error {
    // ...
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("html/template: %s", r)
        }
    }()
    // ...
    tmpl.escaped = true // ここで設定されていた
    tmpl.Tree = tmpl.text.Tree
    // ...
}

問題は、tmpl.escaped = truetmpl.Tree = tmpl.text.Tree の行が defer 関数が実行される前にあり、かつ for ループの外側にあったことです。 escapeTemplates は複数のテンプレート(names 引数で指定されたもの)を処理する可能性があります。もし最初のテンプレートの処理中に panic が発生した場合、defer 関数が実行されてパニックは捕捉されますが、tmpl.escaped = true は既に実行されてしまっています。しかし、実際にはそのテンプレートのエスケープ処理は完了していません。 さらに、tmpl.Tree = tmpl.text.Tree の行も同様に問題がありました。これは、エスケープ処理が成功した後に、元のテキストテンプレートのASTをエスケープ済みのASTに置き換えるためのものです。エラーが発生した場合、この置き換えも不完全な状態で行われる可能性がありました。

修正内容: このコミットでは、tmpl.escaped = truetmpl.Tree = tmpl.text.Tree の設定を、for ループ内で各テンプレートが正常にエスケープされた後にのみ行うように変更しました。これにより、個々のテンプレートが完全にエスケープされた場合にのみ、そのテンプレートの escaped フラグが設定され、ASTが更新されるようになります。

// 修正後のコードの抜粋
func escapeTemplates(tmpl *Template, names ...string) error {
    // ...
    e.commit() // エスケープ処理のコミット
    for _, name := range names {
        if t := tmpl.set[name]; t != nil {
            t.escaped = true // 各テンプレートが正常に処理された後に設定
            t.Tree = t.text.Tree
        }
    }
    return nil
}

この変更により、パニックが発生しても、不完全にエスケープされたテンプレートが「エスケープ済み」と誤ってマークされることがなくなりました。

2つ目のバグの修正: ensurePipelineContains 関数における FieldNode のサポート

ensurePipelineContains 関数は、パイプラインに特定のサニタイズ関数(エスケープ関数)が含まれていることを保証するために、パイプラインのコマンドを検査・変更します。元の実装では、パイプライン内のコマンドの引数が *parse.IdentifierNode であることを前提としていました。

// 修正前の ensurePipelineContains の抜粋
// id.Args[0].(*parse.IdentifierNode)).Ident

しかし、テンプレートのパイプラインでは、{{.Value | .SomeMethod}} のようにメソッド呼び出し(FieldNode)も使用されることがあります。この場合、id.Args[0]*parse.FieldNode となり、*parse.IdentifierNode への型アサーションがパニックを引き起こすか、あるいは単に FieldNode が適切に処理されない原因となっていました。

修正内容: この問題を解決するために、新しいヘルパー関数 allIdents が導入されました。

// allIdents returns the names of the identifiers under the Ident field of the node,
// which might be a singleton (Identifier) or a slice (Field).
func allIdents(node parse.Node) []string {
    switch node := node.(type) {
    case *parse.IdentifierNode:
        return []string{node.Ident}
    case *parse.FieldNode:
        return node.Ident // FieldNode.Ident は []string 型
    }
    panic("unidentified node type in allIdents")
    return nil
}

allIdents 関数は、与えられた parse.NodeIdentifierNode であればその識別子を、FieldNode であればそのフィールドに含まれるすべての識別子(FieldNode.Ident[]string 型)を []string として返します。これにより、ensurePipelineContains 関数は、IdentifierNodeFieldNode の両方から識別子を安全に抽出し、パイプラインの検査と変更を行うことができるようになりました。

ensurePipelineContains 関数内のループも、allIdents を使用するように変更され、FieldNode に対応できるようになりました。

// 修正後の ensurePipelineContains の抜粋
for _, idNode := range idents {
    for _, ident := range allIdents(idNode.Args[0]) {
        // ...
    }
}

この変更により、ensurePipelineContains はパイプライン内のすべての識別子(IdentifierNodeFieldNode の両方から抽出されたもの)を正確に検査し、必要なサニタイズ関数が適切に挿入されることが保証されます。

テストケースの追加

src/pkg/html/template/escape_test.goTestPipeToMethodIsEscaped という新しいテストケースが追加されました。このテストは、メソッド呼び出しを含むパイプライン(例: {{0 | .SomeMethod}})が正しくエスケープされることを検証します。特に、パニックが発生した場合でもエスケープ処理が壊れないことを確認するために、panicrecover を使用したテストループが含まれています。

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

src/pkg/html/template/escape.go

--- a/src/pkg/html/template/escape.go
+++ b/src/pkg/html/template/escape.go
@@ -40,10 +40,14 @@ func escapeTemplates(tmpl *Template, names ...string) error {
 			}
 			return err
 		}
-		tmpl.escaped = true
-		tmpl.Tree = tmpl.text.Tree
 	}
 	e.commit()
+	for _, name := range names {
+		if t := tmpl.set[name]; t != nil {
+			t.escaped = true
+			t.Tree = t.text.Tree
+		}
+	}
 	return nil
 }
 
@@ -207,6 +211,19 @@ func (e *escaper) escapeAction(c context, n *parse.ActionNode) context {
 	return c
 }
 
+// allIdents returns the names of the identifiers under the Ident field of the node,
+// which might be a singleton (Identifier) or a slice (Field).
+func allIdents(node parse.Node) []string {
+	switch node := node.(type) {
+	case *parse.IdentifierNode:
+		return []string{node.Ident}
+	case *parse.FieldNode:
+		return node.Ident
+	}
+	panic("unidentified node type in allIdents")
+	return nil
+}
+
 // ensurePipelineContains ensures that the pipeline has commands with
 // the identifiers in s in order.
 // If the pipeline already has some of the sanitizers, do not interfere.
@@ -229,27 +246,31 @@ func ensurePipelineContains(p *parse.PipeNode, s []string) {
 		idents = p.Cmds[i+1:]
 	}
 	dups := 0
-	for _, id := range idents {
-		if escFnsEq(s[dups], (id.Args[0].(*parse.IdentifierNode)).Ident) {
-			dups++
-			if dups == len(s) {
-				return
+	for _, idNode := range idents {
+		for _, ident := range allIdents(idNode.Args[0]) {
+			if escFnsEq(s[dups], ident) {
+				dups++
+				if dups == len(s) {
+					return
+				}
 			}
 		}
 	}
 	newCmds := make([]*parse.CommandNode, n-len(idents), n+len(s)-dups)
 	copy(newCmds, p.Cmds)
 	// Merge existing identifier commands with the sanitizers needed.
-	for _, id := range idents {
-		pos := id.Args[0].Position()
-		i := indexOfStr((id.Args[0].(*parse.IdentifierNode)).Ident, s, escFnsEq)
-		if i != -1 {
-			for _, name := range s[:i] {
-				newCmds = appendCmd(newCmds, newIdentCmd(name, pos))
+	for _, idNode := range idents {
+		pos := idNode.Args[0].Position()
+		for _, ident := range allIdents(idNode.Args[0]) {
+			i := indexOfStr(ident, s, escFnsEq)
+			if i != -1 {
+				for _, name := range s[:i] {
+					newCmds = appendCmd(newCmds, newIdentCmd(name, pos))
+				}
+				s = s[i+1:]
 			}
-			s = s[i+1:]
 		}
-		newCmds = appendCmd(newCmds, id)
+		newCmds = appendCmd(newCmds, idNode)
 	}
 	// Create any remaining sanitizers.
 	for _, name := range s

src/pkg/html/template/escape_test.go

--- a/src/pkg/html/template/escape_test.go
+++ b/src/pkg/html/template/escape_test.go
@@ -1649,6 +1649,38 @@ func TestEmptyTemplate(t *testing.T) {
 	}\n}\n \n+type Issue7379 int\n+\n+func (Issue7379) SomeMethod(x int) string {\n+\treturn fmt.Sprintf(\"<%d>\", x)\n+}\n+\n+// This is a test for issue 7379: type assertion error caused panic, and then\n+// the code to handle the panic breaks escaping. It\'s hard to see the second\n+// problem once the first is fixed, but its fix is trivial so we let that go. See\n+// the discussion for issue 7379.\n+func TestPipeToMethodIsEscaped(t *testing.T) {\n+\ttmpl := Must(New(\"x\").Parse(\"<html>{{0 | .SomeMethod}}</html>\\n\"))\n+\ttryExec := func() string {\n+\t\tdefer func() {\n+\t\t\tpanicValue := recover()\n+\t\t\tif panicValue != nil {\n+\t\t\t\tt.Errorf(\"panicked: %v\\n\", panicValue)\n+\t\t\t}\n+\t\t}()\n+\t\tvar b bytes.Buffer\n+\t\ttmpl.Execute(&b, Issue7379(0))\n+\t\treturn b.String()\n+\t}\n+\tfor i := 0; i < 3; i++ {\n+\t\tstr := tryExec()\n+\t\tconst expect = \"<html>&lt;0&gt;</html>\\n\"\n+\t\tif str != expect {\n+\t\t\tt.Errorf(\"expected %q got %q\", expect, str)\n+\t\t}\n+\t}\n+}\n+\n func BenchmarkEscapedExecute(b *testing.B) {\n \ttmpl := Must(New(\"t\").Parse(`<a onclick=\"alert(\'{{.}}\')\">{{.}}</a>`))\n \tvar buf bytes.Buffer\n```

## コアとなるコードの解説

### `src/pkg/html/template/escape.go` の変更点

1.  **`escapeTemplates` 関数の修正**:
    *   変更前は、`tmpl.escaped = true` と `tmpl.Tree = tmpl.text.Tree` が `for` ループの外側、かつ `defer` の `recover` 処理よりも前にありました。これにより、エスケープ処理中にパニックが発生した場合でも、テンプレートが不完全にエスケープされた状態で「エスケープ済み」とマークされてしまう問題がありました。
    *   変更後は、これらの行が `for` ループ内に移動され、各テンプレートが正常にエスケープ処理を完了した後にのみ、そのテンプレートの `escaped` フラグが `true` に設定され、ASTが更新されるようになりました。これにより、エラー発生時のエスケープ状態の誤認識が解消されました。

2.  **`allIdents` ヘルパー関数の追加**:
    *   この新しい関数は、`parse.Node` を引数に取り、それが `*parse.IdentifierNode` であればその識別子を、`*parse.FieldNode` であればそのフィールドに含まれるすべての識別子(`FieldNode.Ident` は `[]string` 型)を `[]string` として返します。
    *   これにより、`ensurePipelineContains` 関数が、パイプライン内のコマンド引数として `IdentifierNode` と `FieldNode` の両方を統一的に処理できるようになりました。

3.  **`ensurePipelineContains` 関数の修正**:
    *   変更前は、パイプライン内のコマンド引数が `*parse.IdentifierNode` であることを前提として型アサーションを行っていました。
    *   変更後は、新しく追加された `allIdents` 関数を使用して、コマンド引数から識別子を抽出するように修正されました。これにより、`FieldNode` が含まれるパイプラインでも、必要なサニタイズ関数が正しく検査・挿入されるようになり、2つ目のバグが修正されました。

### `src/pkg/html/template/escape_test.go` の変更点

1.  **`TestPipeToMethodIsEscaped` テストケースの追加**:
    *   このテストは、`html/template` パッケージがメソッド呼び出しを含むパイプライン(例: `{{0 | .SomeMethod}}`)を正しくエスケープできることを検証します。
    *   特に、`tryExec` 関数内で `panic` と `recover` を使用して、エスケープ処理中にエラーが発生した場合でも、テンプレートが「エスケープ済み」と誤ってマークされず、その後の実行で正しくエスケープされることを確認しています。これは、1つ目のバグの修正が正しく機能していることを保証するためのものです。

これらの変更により、`html/template` パッケージのセキュリティと堅牢性が向上し、より安全なWebアプリケーション開発に貢献します。

## 関連リンク

*   Go Gerrit Change-ID: [https://golang.org/cl/85240043](https://golang.org/cl/85240043)
*   Go Issue #7379: コミットメッセージに記載されていますが、公式のGoリポジトリで直接この番号のIssueを見つけることはできませんでした。これは、Issueが非常に古いか、移動されたか、あるいは別のトラッカーに存在した可能性が考えられます。しかし、コミットメッセージ自体がバグの内容を明確に説明しています。

## 参考にした情報源リンク

*   Go言語公式ドキュメント: `html/template` パッケージ
    *   [https://pkg.go.dev/html/template](https://pkg.go.dev/html/template)
*   Go言語公式ドキュメント: `text/template` パッケージ (ASTの理解に役立つ)
    *   [https://pkg.go.dev/text/template](https://pkg.go.dev/text/template)
*   Go言語公式ドキュメント: `panic` と `recover`
    *   [https://go.dev/blog/defer-panic-and-recover](https://go.dev/blog/defer-panic-and-recover)
*   クロスサイトスクリプティング (XSS) に関する一般的な情報
    *   OWASP Cross-Site Scripting (XSS): [https://owasp.org/www-community/attacks/xss/](https://owasp.org/www-community/attacks/xss/) (一般的な情報源として)
*   Go言語のASTと`parse`パッケージに関する情報 (より深い理解のため)
    *   Goのソースコード内の`go/ast`や`go/parser`パッケージのドキュメントも参考になります。