iOS/Androidアプリで使うAPIをgoaで作っていて、Payloadの中身をログに出したくない、という案件が出てきたので色々調べた。
参考になった情報
-
続・GoでパスワードなどをPrintfで出力させたくない - kawaken's blog
stringのエイリアスを定義した上で、fmt.Stringer
,fmt.GoStringer
インターフェースを実装することでフォーマットされないようにする。
基本的にはこれで良い。
ただ、goaが自動で出力するログは、一度JSONをパースして構造体に値を入れた後にjson.Marshal
で文字列にしている模様。
https://github.com/goadesign/goa/blob/90bd33edff1a4f17fab06d1f9e14e659075d1328/middleware/log_request.go#L92 -
Keep passwords and secrets out of your logs with Go
タイトルはやりたいことそのまま。
対象の構造体でjson.Marshaler
を実装し、json.Marshal
の結果を置き換える、というもの。
Payload用の構造体はgoagenで自動生成されるので、そのまま使うことはできないが、↑と組み合わせれば良い感じになりそう。
作ったやつ
やったこと
参考URLの2つを組み合わせた。
-
string
のエイリアスSecretString
を定義する -
fmt.Stringer
,fmt.GoStringer
インターフェースを実装する -
json.Marshaler
インターフェースを実装する -
string
に戻すための関数を定義する
※最後のやつは必須ではない。単にstring(foo)
で戻せるが、「生の文字列を使っているのがどこか?」を簡単に把握できるようにするためには必要かなと。
最小限のコードは以下の通り。
type SecretString string
func (ss SecretString) String() string {
return "[FILTERED]"
}
func (ss SecretString) GoString() string {
return ss.String()
}
func (ss SecretString) MarshalJSON() ([]byte, error) {
return json.Marshal(ss.String())
}
func (ss SecretString) RawString() string {
return string(ss)
}
GitHubに置いたコードは、もう少し色気を出して色々書いている。
secret_string.go
使い方
基本的には、string
で定義するところをsecretstr.SecretString
に置き換えるだけで使える。READMEのサンプルコードを参照。
これにより、
fmt.Println(パスワードをメンバに含む構造体)
のようなデバッグコードが仮に残っていたとしても、ログに生のパスワードが出力されることはなくなる。
ただ、goagenで自動生成されるコードについては一工夫必要。
goagenで自動生成されるPayloadでの使い方
例えば以下のようにリクエストを投げた場合。
curl -X POST http://localhost:8080/auth/login -d '{"id":"raw_id","password":"raw_password"}' -H "Content-Type: application/json"
Before
通常の定義だと、型はただのstring
なので、当然ログにID/PWが出る。
Payload(func() {
Param("id", String, "Login ID")
Param("password", String, "Login password")
})
2019/05/05 21:30:13 [INFO] started req_id=tBkbeBBZ60-1 POST=/auth/login from=::1 ctrl=LoginController action=login
2019/05/05 21:30:13 [INFO] headers req_id=tBkbeBBZ60-1 Accept=*/* Content-Length=41 Content-Type=application/json User-Agent=curl/7.54.0
2019/05/05 21:30:13 [INFO] payload req_id=tBkbeBBZ60-1 raw={"id":"raw_id","password":"raw_password"}
2019/05/05 21:30:13 [INFO] completed req_id=tBkbeBBZ60-1 status=0 bytes=0 time=143.578µs ctrl=LoginController action=login
After
ドキュメントを眺めてみると、Metadata
を定義すれば型を変更できるとのこと。
https://godoc.org/github.com/goadesign/goa/design/apidsl#Metadata
Payload(func() {
Param("id", func() {
Description("Login ID")
Metadata("struct:field:type", "secretstr.SecretString", "github.com/75py/secretstr")
})
Param("password", func() {
Description("Login password")
Metadata("struct:field:type", "secretstr.SecretString", "github.com/75py/secretstr")
})
})
// LoginLoginPayload is the login login action payload.
type LoginLoginPayload struct {
// Login ID
ID *secretstr.SecretString `form:"id,omitempty" json:"id,omitempty" yaml:"id,omitempty" xml:"id,omitempty"`
// Login password
Password *secretstr.SecretString `form:"password,omitempty" json:"password,omitempty" yaml:"password,omitempty" xml:"password,omitempty"`
}
ログは以下のようになる。
2019/05/05 21:31:15 [INFO] started req_id=tBkbeBBZ60-2 POST=/auth/login/secretstr from=::1 ctrl=LoginController action=login_secretstr
2019/05/05 21:31:15 [INFO] headers req_id=tBkbeBBZ60-2 Accept=*/* Content-Length=41 Content-Type=application/json User-Agent=curl/7.54.0
2019/05/05 21:31:15 [INFO] payload req_id=tBkbeBBZ60-2 raw={"id":"[FILTERED]","password":"[FILTERED]"}
2019/05/05 21:31:15 [INFO] completed req_id=tBkbeBBZ60-2 status=0 bytes=0 time=80.848µs ctrl=LoginController action=login_secretstr
少し困っているところ
この方法だと、MaxLengthなどのバリデーションが使えなくなる。
if utf8.RuneCountInString(payload.ID) > 5 { // cannot use *payload.ID (type secretstr.SecretString) as type string in argument to utf8.RuneCountInString
err = goa.MergeErrors(err, goa.InvalidLengthError(`raw.id`, payload.ID, utf8.RuneCountInString(payload.ID), 5, false))
}
このくらいなら自分で実装すれば良いので大した手間ではないけど、もっと良い方法があればぜひ教えてください。