Azure Container Apps の URL フィルタリング

# はじめに

Azure Container Apps は、サーバーレスなコンテナ実行環境として多くのユースケースに対応する一方、セキュリティ向上のために HTTP リクエストの URL に対して自動的な正規化・デコード処理を実施しています。

目立たないですが、Docs にも以下のように記載されています。

>アプリが受信する URL が、要求で指定された URL と異なるのはなぜですか?
>Azure Container Apps により URL はデコードされ、URL 混乱攻撃からアプリが保護されます。 http://mysite.com/archive/http%3A%2F%2Fmysite.com%2Farchive%2F123 のようにエンコードされた部分を含む要求 URL は、http://mysite.com/archive/http%3A/mysite.com/archive/123 としてアプリに送信されます。

https://learn.microsoft.com/ja-jp/azure/container-apps/faq#---------url------------url-------------

これにより、アプリケーションが受信する URL が、クライアントから送信されたものと異なる場合があります。

本記事では、この URL フィルタリングの仕組みとその背景、そして開発者が考慮すべきポイントについて説明します。

# URL フィルタリングの仕組み

Azure Container Apps の HTTP イングレスでは、Envoy プロキシがフロントエンドとして機能します。

Envoy は、受信したリクエストの URL を自動的にデコードし、正規化を行います。

例えば、URL 内のエンコードされた特殊文字や余計なパーセントエンコーディングを解除することで、内部的に統一された形式でアプリケーションへ渡されるようになります。

この処理により、URL 混乱攻撃 (URL Confusion Attack) を未然に防ぐ効果が期待されます。つまり、セキュリティを向上させるための自動フィルタリング機能として働いているのです。

## URL混乱攻撃 (URL Confusion Attack) の例

例えば、あるセキュリティポリシーが `/admin`へのアクセスを制限しているとします。

しかし、クライアントが `/%61dmin` や `//admin` 等のエンコードされたURLを送信した場合、プロキシやアプリケーションがこれを正規化して `/admin` として処理するか、またはそのままの状態で扱うかによって、判断が分かれます。

このように、各システムコンポーネントがURLのエンコードや正規化処理に差異があると、攻撃者はそのギャップを利用してアクセス制御を回避することが可能となります。

# サンプルアプリ

簡単なサンプルアプリで動作確認をしてみます。

```shell
$ ls -l
-rw-r--r-- 1 root root 444 Feb 12  2025 Dockerfile
-rw-r--r-- 1 root root 916 Feb 12  2025 request-logger.py
-rw-r--r-- 1 root root   5 Feb 12  2025 requirements.txt
```

```Dockerfile:Dockerfile
FROM python:3.11-slim-bookworm

RUN python3 -m pip install --no-cache-dir --upgrade pip && \
    python3 -m pip install --no-cache-dir \
    Flask

WORKDIR /app

ADD . /app
    
EXPOSE 8080

CMD ["python3", "request-logger.py"]
```

```python:request-logger.py
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
@app.route('/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
def log_request(path):
    # Collect request details
    method = request.method
    url = request.url
    headers = dict(request.headers)
    body = request.get_data(as_text=True)

    # Prepare the response
    response = {
        'method': method,
        'url': url,
        'headers': headers,
        'body': body
    }

    # Send the response back to the client
    return jsonify(response)

if __name__ == '__main__':
    app.run(port=8080, host="0.0.0.0", debug=False)
```

```text:requirements.txt
Flask
```

このサンプルアプリを Container Apps にデプロイし、動作確認をします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248756/aef68066-9cd9-42f3-b34f-6f0bd56187b4.png)

試しに、`https://<Container Apps のイングレス URL>/something/redirect/http%3A%2F%2Fschema.org%2Fresources%2F123` というエンドポイントに対して GET リクエストをブラウザから送ってみます。

すると、以下のレスポンスが返ってきました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248756/49b91d18-3633-4d61-afba-29860ef55ee6.png)


肝となるのは、出力された `url` です。受信したリクエストの URL が自動的にデコードされ、正規化されていることがわかります。
- リクエストした URL: `https://<Container Apps のイングレス URL>/something/redirect/http%3A%2F%2Fschema.org%2Fresources%2F123`
- 出力された URL: `https://<Container Apps のイングレス URL>/something/redirect/http:/schema.org/resources/123`

# 開発者が留意すべき点

この自動デコード処理は、セキュリティ向上には寄与しますが、一方で以下の３点に注意する必要があると考えます。

- ログや解析時の相違

クライアントから送信された URL と、アプリケーションが実際に受け取る URL に相違が生じるため、ログ出力やデバッグ時に混乱する可能性があります。

例えば、Node.js (Express) では以下のように実装することで、クライアントから送信された URL と、アプリケーションが実際に受け取る URL を同一にすることができます。

```javascript:app.js
const express = require('express');
const app = express();

app.use((req, res, next) => {
  // req.url は Envoy によりデコード・正規化された値が入る場合がある
  console.log('Normalized URL:', req.url);

  // もし必要なら、再エンコードして比較用に利用する
  const reEncodedUrl = encodeURI(req.url);
  console.log('Re-encoded URL:', reEncodedUrl);

  next();
});

app.get('/api/secret', (req, res) => {
  res.send('Secret data');
});

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});
```

- アプリケーションのパスマッチング

URL パスをキーとして動作するルーティングやフィルタリング処理を実装している場合、エンコード前後の違いに注意し、必要に応じて追加の処理（再エンコードなど）を検討します。

Container Apps では、リクエストヘッダの `Referer` から元のリクエスト URL を取得できます。

```javascript:app.js
app.use((req, res, next) => {
  const originalUrl = req.header('Referer');
  if (originalUrl) {
    console.log(`Original URL from header: ${originalUrl}`);
  } else {
    console.warn('Referer header is missing.');
  }
  next();
});
```

上記のサンプルアプリでも、リクエストヘッダ `Referer` に元のリクエスト URL が入っていることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248756/c8c68ca4-a534-4d5f-9e72-6b7bbaf908de.png)


- セキュリティポリシーとの整合性

自動正規化により、意図しないパスへのアクセスがブロックされる場合もあるため、セキュリティポリシーやアクセス制御ルールとの整合性を確認することが重要です。

以下の例では、`/admin` へのアクセス時に `Authorization` ヘッダーの値が特定のトークン（例：`Bearer expected-token`）であるかを確認し、条件を満たさない場合は `403 Forbidden` を返します。

```javascript
app.use((req, res, next) => {
  // Envoy により正規化された URL で /admin へのアクセスをチェック
  if (req.url === '/admin') {
    const authHeader = req.header('Authorization');
    if (!authHeader || authHeader !== 'Bearer expected-token') {
      console.warn('Access denied to /admin due to missing or invalid Authorization header.');
      return res.status(403).send('Forbidden');
    }
  }
  next();
});

app.get('/admin', (req, res) => {
  res.send('Welcome to the admin area');
});
```

# おわりに

本記事では、Azure Container Apps に搭載されている URL フィルタリングの仕組みとその背景、そして開発者が考慮すべきポイントについて説明しました。

URL 混乱攻撃に対する対策を念頭に入れ、システム全体のセキュリティを向上させつつ、開発者として Container Apps を利用する際にお役立ちできれば幸いです。

※なお、本記事の実装はあくまでサンプルです。実開発においては、要件に応じて最適な設計・実装を行っていただきますようお願いいたします。