Shogo's Blog

Apr 13, 2016 - 4 minute read - go golang

net/httpで安全に静的ファイルを返す

net/httpで静的ファイルを返すで、 http.ServeFileを使っていてアレ?と思ったのでちょっと詳しく調べてみました。 (http.FileServerを使うものだと思ってたため)

結論だけ先に書いておくと

  • やはり、特に理由がなければhttp.FileServerを使ったほうが良さそう
  • どうしてもhttp.ServeFileを使う場合は定数でパス指定をする
  • 「自作パスルータを使っている」かつ「Go 1.6.1 未満を使っている」場合はとくに要注意

ディレクトリトラバーサル脆弱性

紹介されているのは以下のコードです。

http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
  http.ServeFile(w, r, r.URL.Path[1:])
})

しかし、参照先の「Go Golang to serve a specific html file」には Actually, do not do that. (やっちゃいけない)とコメントされています。 ディレクトリトラバーサルにより 脆弱性の原因となってしまう可能性があるためです。

脆弱性再現のために、以下の様なコードを書いてGo1.5でコンパイルして実行してみました。

package main

import (
	"net/http"
	"strings"
)

func main() {
	http.ListenAndServe(":3000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if strings.HasPrefix(r.URL.Path, "/static/") {
			http.ServeFile(w, r, r.URL.Path[1:])
		} else {
			http.NotFound(w, r)
		}
	}))
}

..を含んだパスをリクエストしてみます。(実行した場所によって..の数は変わるので適宜調整してみてください)

$ curl -v http://localhost:3000/static/../../../.ssh/id_rsa
* About to connect() to localhost port 3000 (#0)
*   Trying ::1... connected
* Connected to localhost (::1) port 3000 (#0)
> GET /static/../../../.ssh/id_rsa HTTP/1.1
> User-Agent: curl/7.19.7 (x86_64-redhat-linux-gnu) libcurl/7.19.7 NSS/3.19.1 Basic ECC zlib/1.2.3 libidn/1.18 libssh2/1.4.2
> Host: localhost:3000
> Accept: */*
>
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Content-Length: 1679
< Content-Type: text/plain; charset=utf-8
< Last-Modified: Fri, 13 Jun 2014 04:57:05 GMT
< Date: Tue, 12 Apr 2016 17:53:19 GMT
<
-----BEGIN RSA PRIVATE KEY-----
(中略)
-----END RSA PRIVATE KEY-----
* Connection #0 to host localhost left intact
* Closing connection #0

macのcurlで試したらクライアント側で相対パスを解決した状態でリクエストが飛んでしまって上手く行きませんでした。 オプションで外す方法がよくわかなかったので、telnetで叩いてみた例も載せておきます。

$ telnet localhost 3000
Trying ::1...
Connected to localhost.
Escape character is '^]'.
GET /static/../../../.ssh/id_rsa HTTP/1.0

HTTP/1.0 200 OK
Accept-Ranges: bytes
Content-Length: 1679
Content-Type: text/plain; charset=utf-8
Last-Modified: Fri, 13 Jun 2014 04:57:05 GMT
Date: Tue, 12 Apr 2016 18:02:56 GMT

-----BEGIN RSA PRIVATE KEY-----
(中略)
-----END RSA PRIVATE KEY-----
Connection closed by foreign host.

ああ、僕の秘密鍵が・・・。

脆弱性を回避する

Go1.6以降を使う

Go1.6以降では修正されており、 同じコードをGo1.6でコンパイルしてcurlで叩くと400が帰ってきます。

$ curl -v http://localhost:3000/static/../../../.ssh/id_rsa
* About to connect() to localhost port 3000 (#0)
*   Trying ::1... connected
* Connected to localhost (::1) port 3000 (#0)
> GET /static/../../../.ssh/id_rsa HTTP/1.1
> User-Agent: curl/7.19.7 (x86_64-redhat-linux-gnu) libcurl/7.19.7 NSS/3.19.1 Basic ECC zlib/1.2.3 libidn/1.18 libssh2/1.4.2
> Host: localhost:3000
> Accept: */*
> 
< HTTP/1.1 400 Bad Request
< Content-Type: text/plain; charset=utf-8
< X-Content-Type-Options: nosniff
< Date: Tue, 12 Apr 2016 18:12:46 GMT
< Content-Length: 17
< 
invalid URL path
* Connection #0 to host localhost left intact
* Closing connection #0

http.ServeMuxを使う

http.ServeMuxにはパスの正規化機能が組み込まれており、 正規化されていないURLにアクセスが来た場合は自動的リダイレクトしてくれるようです。 HTTPハンドラに渡ってくるときには、必ず相対パスが含まれていない状態になっています。 (これに最初は気が付かず、脆弱性が再現しないので困ってた。)

package main

import "net/http"

func main() {
  // 内部でhttp.ServeMuxを使ってくれる
  http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
    // r.URLには相対パスが含まれない形で渡ってくる
    http.ServeFile(w, r, r.URL.Path[1:])
  })
  http.ListenAndServe(":3000", nil)
}

相対パスを含んだリクエストを投げてもアクセスはできません。

$ curl -v http://localhost:3000/static/../../../.ssh/id_rsa
* About to connect() to localhost port 3000 (#0)
*   Trying ::1... connected
* Connected to localhost (::1) port 3000 (#0)
> GET /static/../../../.ssh/id_rsa HTTP/1.1
> User-Agent: curl/7.19.7 (x86_64-redhat-linux-gnu) libcurl/7.19.7 NSS/3.19.1 Basic ECC zlib/1.2.3 libidn/1.18 libssh2/1.4.2
> Host: localhost:3000
> Accept: */*
> 
< HTTP/1.1 301 Moved Permanently
< Location: /.ssh/id_rsa
< Date: Tue, 12 Apr 2016 18:14:49 GMT
< Content-Length: 47
< Content-Type: text/html; charset=utf-8
< 
<a href="/.ssh/id_rsa">Moved Permanently</a>.

* Connection #0 to host localhost left intact
* Closing connection #0

http.FileServerを使う

http.Dirhttp.FileServerを使うとルートディレクトリを指定でき、 その外へはアクセスできなくなるので想定外のファイルが見えてしまうことはありません。

package main

import (
  "net/http"
  "strings"
)

func main() {
  fileServer := http.StripPrefix("/static/", http.FileServer(http.Dir("static")))
  http.ListenAndServe(":3000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    if strings.HasPrefix(r.URL.Path, "/static/") {
      fileServer.ServeHTTP(w, r)
    } else {
      http.NotFound(w, r)
	}
  }))
}

相対パスを含んだURLにアクセスしても404になって見れません。

$ curl -v http://localhost:3000/static/../../../.ssh/id_rsa
* About to connect() to localhost port 3000 (#0)
*   Trying ::1... connected
* Connected to localhost (::1) port 3000 (#0)
> GET /static/../../../.ssh/id_rsa HTTP/1.1
> User-Agent: curl/7.19.7 (x86_64-redhat-linux-gnu) libcurl/7.19.7 NSS/3.19.1 Basic ECC zlib/1.2.3 libidn/1.18 libssh2/1.4.2
> Host: localhost:3000
> Accept: */*
> 
< HTTP/1.1 404 Not Found
< Content-Type: text/plain; charset=utf-8
< X-Content-Type-Options: nosniff
< Date: Tue, 12 Apr 2016 18:39:34 GMT
< Content-Length: 19
< 
404 page not found
* Connection #0 to host localhost left intact
* Closing connection #0

http.ServeFileに定数を渡す

どうしても特定のファイルを指定したい場合は、http.ServeFileに渡すファイルパスを定数で指定するべきです。 例えば、「Go Golang to serve a specific html file」の質問者が上げている例を 正しく書きなおすと以下のようになると思います。

http.Handle("/", http.FileServer(http.Dir("static"))) Serves the html file in static directory.

Is there any way in Go that we can specify the html file to serve?

Something like render_template in Flask

I want to do something like:

http.Handle("/hello", http.FileServer(http.Dir("static/hello.html")))

package main

import "net/http"

func main() {
  http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "static/hello.html")
  })
  http.ListenAndServe(":3000", nil)
}

回答者がActually, do not do thatとコメントしているのはhttp.ServeFiler.URL.Path[1:]を渡すことで、 http.ServeFile自体が悪いわけではありません。 正しく安全に使いましょう。

まとめ

まとめ再掲。

  • やはり、特に理由がなければhttp.FileServerを使ったほうが良さそう
  • どうしてもhttp.ServeFileを使う場合は定数でパス指定をする
  • 「自作パスルータを使っている」かつ「Go 1.6.1 未満を使っている」場合はとくに要注意

まとめのまとめ

godocのexampleどおりにやるのが一番。

package main

import "net/http"

func main() {
  http.HandleFunc("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
  http.ListenAndServe(":3000", nil)
}