もうlintに怒られない！Goでより安全にzipを扱うために（Potential DoS vulnerability via decompression bombエラーへの対処法）

## はじめに

[フューチャー Advent Calendar 2020](https://qiita.com/advent-calendar/2020/future)の5日目の記事です。昨日は[AWSアソシエイト試験](https://qiita.com/advent-calendar/2020/future)についての刺激的な記事でした！
バッチ処理などを実装していると、定期的に連携されるファイルを取り込む場面が出てきます。5日目の本記事では、そのうち、zip形式のファイル取り込みをGoで実装する際のTipsを書きます。今回はzipの実装を例に説明しますが、gzipファイル取り込みの際も同じ手法が使えます。

## 環境・使用するライブラリ情報

()は実装時のバージョンです。

* Mac OS (Mojave 10.14)
* go (1.14.1 darwin/amd64)
* golangci-lint (1.31.0)

## 事象

zipファイル取り込みを実装してみると、lintで下記のような不穏なメッセージが。。

```go:before.go
import (
	"archive/zip"
	"io"
	"os"
	"path/filepath"
	"golang.org/x/xerrors"
)

// src：対象zipファイルが格納されているパス
// dest：対象zipファイルの格納先
func Decompress(src, dest string) error {
	r, err := zip.OpenReader(src)
	for _, f := range r.File {
		// zipファイルを開く
		rc, err := f.Open()
		// zipファイル格納先を定義する
		path := filepath.Join(dest, f.Name)
		// 新規ファイルを作成する
		f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
		// 作成したファイルにzipファイルをコピーする
		if _, err := io.Copy(f, rc); err != nil {
			return xerrors.Errorf("failed to io copy: %w", err)
		}
	}
	return nil
}
```

```bash:出力されるlintエラー
before.go:21: G110: Potential DoS vulnerability via decompression bomb (gosec)
```

取り込むファイルのファイルサイズ上限を設定していないことにより、DoS攻撃、この場合高圧縮ファイル爆弾[^1]などの悪意ある攻撃が制御できないことの警告のようです。

## 対応策

外部システムからファイル連携される以上、どこかで怪しいファイルを検知しておかないと、いざという時にシステムがやられていまい大変なことになりますね。
それでは、上記の脅威とlintエラーを撲滅していきましょう。必要なことは3つです。

1. 取り込むファイルサイズの上限値を設定する
2. `io.CopyN()`を使う
3. ファイルサイズ上限値抵触とそれ以外のエラーをそれぞれハンドリングする

以下、それぞれ解説します。

### 1. 取り込むファイルサイズの上限値を設定する

まず、取り込みを許容するファイルサイズの上限値を決めます。取り込もうとしているファイルが異常なものかどうかを判断する基準になります。
システムの要件にもよりますが、ここでは仮に1GBと置きます。

```go
const MaxFileSize = 1073741824 //1GB
```

### 2. io.CopyN()を使う

`io.Copy()`は対象ファイルとコピーするファイルの2値を引数として動作しますが、これでは関数実行時にファイルが大きすぎることに気付くことができず、結局システムがクラッシュするまでコピーが実行されてしまいます。
そこで、`io.CopyN()`を使用します。[^2]これにより、ファイルサイズ上限値を3つ目の引数として渡すことができます。
関数の中では、コピーしたファイルのバイト数が上限値に達するか、エラーが発生するまでコピーが実行されます。
before.go の実装を置き換えるとこのようになりますね。

```go
if _, err := io.CopyN(f, rc, MaxFileSize); err != nil {
	return xerrors.Errorf("failed to io copy: %w", err)
}
```

良さそうに見えますが、このままだと下記2点の課題が残ります。

* 上限値に満たないサイズのファイルが連携された時、EOFエラーを起こしてしまう

3つ目の引数で渡されたサイズ分コピーを行おうとするので、コピー元がそのファイルサイズより小さい場合、さらにアクセスしようとしてエラーとなってしまいます。

* 本当に悪意のあるファイルが来た時に警告することができない

仮に何十GBのファイルが来た場合、1GBでコピー処理は切り上げられるためシステムのクラッシュは避けることができますが、エラーが起こっているわけではないため異常なファイルが連携されたことを検知することはできないままです。

### 3. ファイルサイズ上限値抵触とそれ以外のエラーをそれぞれハンドリングする

そこで、以下のようにハンドリングするようにします。

> 上限値に満たないサイズのファイルが連携された時、EOFエラーを起こしてしまう

EOFエラーの制御を除外します。
エラーハンドリング時に、この関数がEOFエラーを返却する時は、すなわちコピー元のファイル分のコピーが完了していることとみなし、E0Fエラーでないことを条件に加えます。
※`err != nil && err.Error() != io.EOF.Error()`　というふうにエラーメッセージで内容を判断していましたが、↓のように比較するよりスマートな方法をご提案いただきました！ @spiegel-im-spiegel さんありがとうございます。（2021/06/16 追記）　

```go
if !xerrors.Is(err, io.EOF) {
    return fmt.Errorf("failed to io copy: %w", err)
}
```

> 本当に悪意のあるファイルが来た時に警告することができない

`io.CopyN()`の1つ目の返り値である、コピー済みのファイルサイズ（byte単位）を利用します。
この値が、上限値を上回っているかどうか確認することで、異常な容量のファイルが連携された場合検知することができます！

```go
writeCount, err := io.CopyN(f, rc, MaxFileSize)
// 先にEOF以外のエラーをハンドリングする
if !xerrors.Is(err, io.EOF) {
    return fmt.Errorf("failed to io copy: %w", err)
}
// 正常にコピーできている状態かつ、コピーしたファイルサイズが上限値に抵触していないことを確認する
if writeCount >= MaxFileSize {
	return xerrors.Errorf("this file might be a zip bomb!: %s", src)
}
```

### まとめのコード

ここまでの処理をコードに表すとこのようになります。
これでlintからも警告が出力されなくなりました！

```go:after.go
import (
	"archive/zip"
	"io"
	"os"
	"path/filepath"
	"golang.org/x/xerrors"
)

// src：対象zipファイルが格納されているパス
// dest：対象zipファイルの格納先
func Decompress(src, dest string) error {
	// 1. 取り込むファイルサイズの上限値を設定する
	const MaxFileSize = 1073741824 // 1GB
	r, err := zip.OpenReader(src)
	for _, f := range r.File {
		// zipファイルを開く
		rc, err := f.Open()
		// zipファイル格納先を定義する
		path := filepath.Join(dest, f.Name)
		// 新規ファイルを作成する
		f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
		// 作成したファイルにzipファイルをコピーする
		// 2. io.CopyN()を使う
		writeCount, err := io.CopyN(f, rc, MaxFileSize)
		// 3. ファイルサイズ上限値抵触とそれ以外のエラーをそれぞれハンドリングする
		// 先にEOF以外のエラーをハンドリングする
		if !xerrors.Is(err, io.EOF) {
		    return xerrors.Errorf("failed to io copy: %w", err)
		}
		// 正常にコピーできている状態かつ、コピーしたファイルサイズが上限値に抵触していないことを確認する
		if writeCount >= MaxFileSize {
			return xerrors.Errorf("this file might be a zip bomb!: %s", src)
		}
	}
	return nil
}
```

```bash:出力されるlintエラー

（出力なし）
```


## 終わりに

よくぶち当たりそうな課題だと感じましたが、どんぴしゃな記事がなくハマったので言語化にトライしました。
これでもうクラッシュもlintの警告も怖くありません！安心してzipを取り込みつつ、素敵なクリスマスをお過ごしください。


[^1]: 展開すると物凄い大きさのファイルサイズになり、読み込んだシステムの負荷を高めてクラッシュさせてしまうような、悪意のある圧縮ファイルのことです。英語ではZip Bombとも呼ばれます。参考：[高圧縮ファイル爆弾（Wikipedia）](https://ja.wikipedia.org/wiki/%E9%AB%98%E5%9C%A7%E7%B8%AE%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E7%88%86%E5%BC%BE)
[^2]: 参考：[io.CopyN 公式ドキュメント](https://golang.org/pkg/io/#CopyN)