ケーススタディで学ぶ、AWSにおけるIP制限: CloudFront、ALB、アプリケーションレイヤにおける実装と考慮点の整理
この記事は Eureka Advent Calendar 2023 の19日目の記事です。
はじめに
エウレカ Site & Data Reliability Engineer の @MoneyForest です。
この1年は主にSREとして経験を積み、アプリケーション開発やデータ基盤の運用改善など色々なことをしましたが、なぜかアドカレでは去年に引き続き今年もWAFについて書いています。
年末になると不思議とWAFに思いを馳せているのはなんだかロマンティックかもしれませんね。
去年の記事 → ケーススタディで学ぶ、AWS WAFの導入における考慮点
今回は、AWSにおけるIP制限の設定について、ケーススタディ形式で実装と考慮点を解説します。
ケース設定
要件
今回のケーススタディでは以下のようなアクセス制限の要件を仮定します。
- 特定のパス (
/admin
) は特定のIP(222.111.22.11)からのアクセスのみ許可する。 - その他のパスは全てのIPからの接続を許可する。
同一アプリケーション上に管理者用、一般ユーザー用それぞれの機能があるイメージで、基本的には公開されているものの、特定のエンドポイントではIP制限をかけたいというイメージです。
システム構成
今回のケーススタディでは以下のようなシステム構成を仮定します。
禁止マークがついている箇所が今回IP制限を考える箇所になります。
※ X-Forwarded-ForヘッダをALBやCloudFrontがどう取り扱うかは、ALBやCloudFrontの設定で変更することができますが、そこには触れず、送信元IPを末尾に追加し、フォワードするという前提とします。
攻撃
今回のケーススタディでは以下のような攻撃リクエストを仮定します。
# 許可IP以外の環境
$ curl ipecho.net/plain; echo
111.70.11.100
# ヘッダに許可IPを設定した、悪意のあるリクエスト
$ curl -H "X-Forwarded-For: 222.111.22.11" https://moneyforest.link/admin
許可IP以外の環境(111.70.11.100)から、許可IPをX-Forwarded-Forヘッダ(222.111.22.11)に設定し、制限エンドポイントに悪意を持ってリクエストしてくるイメージです。
1. CloudFront レイヤでのIP制限
実装(Terraform)
AWS WAFで今回のアクセス制限の要件を満たす設定をTerraformで実装すると以下のような形になります。
resource "aws_wafv2_web_acl" "cloudfront" {
provider = aws.us-east-1
scope = "CLOUDFRONT"
... 略 ...
rule {
name = "AllowSpecificIPForAdmin"
priority = 1
action {
block {}
}
statement {
and_statement {
statement {
byte_match_statement {
field_to_match {
uri_path {}
}
positional_constraint = "EXACTLY"
search_string = "/admin"
text_transformation {
priority = 0
type = "NONE"
}
}
}
statement {
not_statement {
statement {
ip_set_reference_statement {
arn = aws_wafv2_ip_set.cloudfront_allowed_ips.arn
}
}
}
}
}
}
... 略 ...
}
resource "aws_wafv2_ip_set" "cloudfront_allowed_ips" {
provider = aws.us-east-1
name = "${local.name}-cloudfront-allowed-ips"
scope = "CLOUDFRONT"
ip_address_version = "IPV4"
addresses = [
"222.111.22.11/32",
]
}
考慮点
仮定の攻撃リクエストをしたとき、WAFログのhttprequestフィールドには以下のような情報が記録されています。(必要箇所を抜粋)
{clientip=111.70.11.100, headers=[{name=x-forwarded-for, value=222.111.22.11}]
着目する点は以下です。
- clientipにはクライアントの本来のIP(111.70.11.100)が入っている
- X-Forwarded-Forヘッダにはクライアントが悪意を持って設定したIP(222.111.22.11)が入っている
ip_set_reference_statementの設定ではヘッダで検証することも可能ですが、デフォルトではclientipでの検証となるため、以下の記述だけで今回は適切に防御することができます。(X-Forwarded-Forヘッダの内容は判定に影響しない)
ip_set_reference_statement {
arn = aws_wafv2_ip_set.cloudfront_allowed_ips.arn
}
2. ALB レイヤでのIP制限
AWS(CloudFront)以外のCDNを活用していたり、CloudFrontのオリジンが多岐に渡るなどで、AWS WAFの設定が困難なケースもあるかと思います。
その場合、次のレイヤであるALBでのIP制限を考えることになります。ALBはCloudFrontからフォワードされるため、WAFでのclientipの判定がCloudFrontのIPとなってしまい、先程と同様のルールは適用できません。
そこで本来のクライアントのIPを参照するために、X-Forwarded-Forヘッダ(デファクトスタンダードとなっている、プロキシを経由してクライアントのIPを伝播させるためのヘッダ)を活用することになります。
実装(Terraform)
AWS WAFで今回のアクセス制限の要件を満たす設定をTerraformで実装すると以下のような形になります。
resource "aws_wafv2_web_acl" "alb" {
scope = "REGIONAL"
... 略 ...
rule {
name = "AllowSpecificIPForAdmin"
priority = 1
action {
block {}
}
statement {
and_statement {
statement {
byte_match_statement {
field_to_match {
uri_path {}
}
positional_constraint = "EXACTLY"
search_string = "/admin"
text_transformation {
priority = 0
type = "NONE"
}
}
}
statement {
not_statement {
statement {
ip_set_reference_statement {
arn = aws_wafv2_ip_set.alb_allowed_ips.arn
ip_set_forwarded_ip_config {
header_name = "X-Forwarded-For"
fallback_behavior = "NO_MATCH"
position = "LAST"
}
}
}
}
}
}
}
... 略 ...
}
}
resource "aws_wafv2_ip_set" "alb_allowed_ips" {
name = "${local.name}-alb-allowed-ips"
scope = "REGIONAL"
ip_address_version = "IPV4"
addresses = [
"222.111.22.11/32",
]
}
考慮点
仮定の攻撃リクエストをしたとき、WAFログのhttprequestフィールドには以下のような情報が記録されています。(必要箇所を抜粋)
{clientip=130.176.135.142, headers=[{name=X-Forwarded-For, value=222.111.22.11, 111.70.11.100}]
ここでは以下の点に着目します。
- clientip(130.176.135.142)が CloudFrontのIP である
- X-Forwarded-Forヘッダに クライアントが悪意を持って設定したIP(222.111.22.11)と、クライアントの本来のIP(111.70.11.100)が設定されている
- X-Forwarded-Forヘッダの末尾にクライアントの本来のIP(111.70.11.100)が設定されている
上記を踏まえ、ip_set_reference_statementの設定でX-Forwarded-Forヘッダを検証するようにします。
X-Forwarded-Forヘッダの末尾のIPが本来のクライアントのIPであるため、position = “LAST” オプションを指定することで、今回は適切に防御することができます。
ip_set_reference_statement {
arn = aws_wafv2_ip_set.alb_allowed_ips.arn
ip_set_forwarded_ip_config {
header_name = "X-Forwarded-For"
fallback_behavior = "NO_MATCH"
position = "LAST"
}
}
positionには”FIRST”と”ANY”オプションもありますが、今回のケースだと“LAST”以外を指定してしまうと攻撃リクエストをバイパスしてしまい、不適切な防御になってしまうので注意が必要です。
3. アプリケーションレイヤでのIP制限
IP制限に複雑な要件があり、WAFでは表現しきれない場合、アプリケーションでのIP制限を行うこともあるかもしれません。
引き続き本来のクライアントのIPを参照するためにX-Forwarded-Forヘッダを取り扱うことになりますが、アプリケーションでX-Forwarded-Forヘッダを適切に取り扱う実装は難しいです。
実装(Go)
アプリケーションで今回のアクセス制限の要件を満たす設定をGoで実装すると以下のような形になります。(注: 後述しますが、このコードは要件は満たすものの不十分なコードです)
func main() {
e := echo.New()
e.GET("/admin", func(c echo.Context) error {
xForwardedFor := c.Request().Header.Get("X-Forwarded-For")
ipList := strings.Split(xForwardedFor, ",")
var clientIP string
// IPアドレスのリストから最後から2番目のIPを取得(最後のIPはCloudFrontのIPのため)
if len(ipList) >= 2 {
clientIP = strings.TrimSpace(ipList[len(ipList)-2]) // 最後から2番目のIP
} else {
// X-Forwarded-For ヘッダのIPが不足している場合は不正なリクエストとして処理
return c.String(http.StatusForbidden, "Access Denied")
}
// アクセス制限の判定
if !isAllowedIP(clientIP) {
return c.String(http.StatusForbidden, "403 Forbidden (App)")
}
return c.String(http.StatusOK, "hi admin")
})
e.Logger.Fatal(e.Start(":80"))
}
// clientIPが許可IPアドレスのリストに含まれるか判定する
func isAllowedIP(ip string) bool {
allowedIPs := getEnvAsSlice("ALLOWED_IPS", []string{""}, ",")
for _, allowedIP := range allowedIPs {
if ip == allowedIP {
return true
}
}
return false
}
func getEnvAsSlice(name string, defaultValue []string, separator string) []string {
value, exists := os.LookupEnv(name)
if !exists {
return defaultValue
}
return strings.Split(value, separator)
}
考慮点
仮定の攻撃リクエストをしたとき、GoアプリケーションのログをWAFログの形式に合わせると以下のようになるでしょう。
{clientip=10.0.0.XXX, headers=[{name=X-Forwarded-For, value=222.111.22.11, 111.70.11.100, 130.176.135.142}]
ここでは以下の点に着目します。
- clientipが ALBのPrivate IPである
- X-Forwarded-Forに クライアントが悪意を持って設定したIPと、クライアントの本来のIP、CloudFrontのIPが設定されている
- X-Forwarded-Forの末尾からCloudFrontのIPを除き、末尾のIPがクライアントの本来のIPアドレスである
アプリケーションの実装における難しさ
今回実装したGoのコードは、アクセス制限の要件を満たしますが、実は実装で不十分な点が存在します。
- clientipが ALBのPrivate IP、つまり信頼された通信経路からのリクエストであることを検証していない(
net
パッケージなどを利用して送信元IPアドレスの検証が必要) - X-Forwarded-Forの末尾IPがCloudFrontのIPアドレスであることを検証していない(AWSが提供しているマスターデータから、X-Forwarded-Forの末尾IPがCloudFrontのIPアドレスの範囲内かどうかの検証が必要)
もちろんこれらのを実装することは可能ですが、以下の問題について考える必要があります。
- 実装およびレビューにはX-Forwarded-Forヘッダの仕様理解と、プロキシのX-Forwarded-Forヘッダの取り扱いについて正しい理解が必要
- CloudFrontのIPアドレス範囲のマスターデータの取り扱いをどうするか決める必要がある(アプリケーション起動時にキャッシュ、定期的にリクエストして更新など)
- ALBのPrivate IPであることを検証するために、VPCのCIDRというインフラの関心事がアプリケーションに持ち込まれてしまう
- CloudFront -> ALB -> アプリケーションの通信経路に新しいプロキシが挟まったら壊れる可能性がある
- 実装が肥大化しアプリケーションの保守性が低下する
まとめ
AWSにおけるIP制限について整理しました。
IP制限といっても、エッジ以外ではリクエストヘッダを参照する必要があり、その場合にリクエストヘッダの仕様や通信経路を理解していないと適切な防御ができず、実は難しいことがわかりました。
今回紹介した他にも、Lambda@Edgeでの制限、ALBリスナールールでの制限、Nginx(OpenResty)をアプリケーションのサイドカーとして立て、Luaスクリプトでの制限、などなど更に色々なレイヤでの防御が考えられるのですが、内容が肥大化してしまうし、言いたいことがわからなくなってしまうので避けました。
(一部はプライベートリポジトリでPoCしてコードまで書いてウキウキしてたのですが書ききれず大変残念です・・・)
おわりに
クラウドの活用に伴い、アプリケーションにリクエストが到達するまでに様々なコンポーネントを経由することが一般的になっています。
CDNやロードバランサーなど、システムやユーザーに恩恵がある反面、IP制限など、実装の上で取り扱いが難しくなるものもあります。
今回のケーススタディでは各レイヤで同じ内容のIP制限の実現について考えてみましたが、多層防御の観点でそういった防御をすることもあるでしょうし、なにか制約があってそのレイヤで防御しなければならない場合もあると思います。
その際にこのブログがどなたかの一助となれば幸いです。
明日は Jon Mulligan さんの 「React Without the State Machine」です!