diff --git a/docs/adr/0006-rate-limiting.md b/docs/adr/0006-rate-limiting.md index a6048f2..dcde396 100644 --- a/docs/adr/0006-rate-limiting.md +++ b/docs/adr/0006-rate-limiting.md @@ -20,3 +20,9 @@ We need to protect endpoints from abuse and runaway clients. PHP NENE2's `Thrott - Suitable for single-process deployments (uvicorn workers share no state between processes) - For multi-process / multi-node deployments, replace the in-memory store with Redis (implement `ThrottleStoreInterface` — future work) - Fixed-window is vulnerable to burst at window boundary; sliding-log or token-bucket can be added later without changing the interface + +## Known Limitations + +**`X-Forwarded-For` spoofing**: The client key is derived from the first entry in the `X-Forwarded-For` header, which can be set to an arbitrary value by the client. A malicious actor can send different forged IPs on every request to bypass the rate limit. + +**Mitigation**: In production, place the application behind a trusted reverse proxy (nginx, Caddy, AWS ALB, etc.) that strips and rewrites `X-Forwarded-For` before the request reaches the application. Do not expose the app directly to the internet without a reverse proxy when rate limiting is required. diff --git a/docs/ja/reference/framework-modules.md b/docs/ja/reference/framework-modules.md index 737ce47..8038c98 100644 --- a/docs/ja/reference/framework-modules.md +++ b/docs/ja/reference/framework-modules.md @@ -139,6 +139,8 @@ cfg_test = AppSettings(throttle_enabled=False) # テスト用オーバー `ThrottleMiddleware` には `enabled` フラグがありません。`if settings.throttle_enabled:` でラップして制御します。 +> **注意 — `X-Forwarded-For` 偽装**: レートリミットのキーは `X-Forwarded-For` ヘッダーの最初のエントリから生成されますが、クライアントがこのヘッダーを偽装することで制限を回避できます。本番環境では、信頼できるリバースプロキシ(nginx、Caddy、AWS ALB 等)の背後にアプリを配置し、リバースプロキシが `X-Forwarded-For` を上書きするよう設定してください。詳細は [ADR-0006](../../adr/0006-rate-limiting.md) を参照してください。 + #### 完全な登録順(任意ミドルウェア含む) ```python @@ -349,18 +351,24 @@ from nene2.mcp import HttpxMcpClient client = HttpxMcpClient("bearer-token") response = client.get("http://localhost:8080", "/notes") response.is_successful() # True -response.body # dict | list — パース済み JSON +response.body # str — 生のレスポンステキスト response.status_code # int +response.request_id() # str | None — X-Request-ID ヘッダーの値 ``` ### `McpHttpResponse` -`HttpxMcpClient` メソッドの戻り値型。フィールド: `status_code: int`、`body: dict | list`。 -メソッド: `is_successful() -> bool`(`200 ≤ status_code < 300` のとき `True`)。 +`HttpxMcpClient` メソッドの戻り値型。 + +フィールド: `status_code: int`、`headers: dict[str, str]`、`body: str`(生のレスポンステキスト)。 + +メソッド: +- `is_successful() -> bool`(`200 ≤ status_code < 300` のとき `True`) +- `request_id() -> str | None` — `X-Request-ID` レスポンスヘッダーの値を返す(なければ `None`) ### `McpHttpClientProtocol` -カスタム MCP HTTP クライアントの構造的契約。`get()`・`post()`・`put()`・`delete()` を実装して `McpHttpResponse` を返します。 +カスタム MCP HTTP クライアントの構造的契約。`get()`・`post()`・`put()`・`delete()` で `McpHttpResponse` を返し、`has_authentication() -> bool` を実装します。 --- diff --git a/docs/reference/framework-modules.md b/docs/reference/framework-modules.md index 7423ad9..56777f7 100644 --- a/docs/reference/framework-modules.md +++ b/docs/reference/framework-modules.md @@ -170,6 +170,8 @@ Starlette applies middleware in **reverse registration order** — the last regi `ThrottleMiddleware` has no `enabled` flag — wrap with `if settings.throttle_enabled:` to disable it. +> **Note — `X-Forwarded-For` spoofing**: The rate limit key is derived from the first entry of the `X-Forwarded-For` header, which clients can forge. In production, always place the application behind a trusted reverse proxy (nginx, Caddy, AWS ALB, etc.) that rewrites `X-Forwarded-For` before the request reaches the app. See [ADR-0006](../../adr/0006-rate-limiting.md) for details. + #### Full registration order with optional middleware ```python @@ -394,17 +396,24 @@ from nene2.mcp import HttpxMcpClient client = HttpxMcpClient("bearer-token") response = client.get("http://localhost:8080", "/notes") response.is_successful() # True -response.body # dict | list — parsed JSON +response.body # str — raw response text response.status_code # int +response.request_id() # str | None — value of X-Request-ID header ``` ### `McpHttpResponse` -Return type of `HttpxMcpClient` methods. Fields: `status_code: int`, `body: dict | list`. Method: `is_successful() -> bool` (`True` when `200 ≤ status_code < 300`). +Return type of `HttpxMcpClient` methods. + +Fields: `status_code: int`, `headers: dict[str, str]`, `body: str` (raw response text). + +Methods: +- `is_successful() -> bool` — `True` when `200 ≤ status_code < 300` +- `request_id() -> str | None` — returns the `X-Request-ID` response header value, or `None` ### `McpHttpClientProtocol` -Structural contract for custom MCP HTTP clients. Implement `get()`, `post()`, `put()`, `delete()` returning `McpHttpResponse`. +Structural contract for custom MCP HTTP clients. Implement `get()`, `post()`, `put()`, `delete()` returning `McpHttpResponse`, and `has_authentication() -> bool`. --- diff --git a/tests/example/conftest.py b/tests/example/conftest.py new file mode 100644 index 0000000..111f4c4 --- /dev/null +++ b/tests/example/conftest.py @@ -0,0 +1,13 @@ +"""Shared fixtures for example HTTP integration tests.""" + +import pytest +from fastapi.testclient import TestClient + +from example.app import create_app +from nene2.config import AppSettings + + +@pytest.fixture +def client() -> TestClient: + """Fresh TestClient with in-memory DB and throttle disabled (per-test isolation).""" + return TestClient(create_app(AppSettings(throttle_enabled=False))) diff --git a/tests/example/note/test_list_notes.py b/tests/example/note/test_list_notes.py index 5e78436..67b17fe 100644 --- a/tests/example/note/test_list_notes.py +++ b/tests/example/note/test_list_notes.py @@ -1,17 +1,11 @@ """HTTP-level tests for the Note endpoints.""" +import pytest from fastapi.testclient import TestClient -from nene2.config import AppSettings -from src.example.app import create_app - -def _client() -> TestClient: - return TestClient(create_app(AppSettings())) - - -def test_list_notes_empty() -> None: - r = _client().get("/notes") +def test_list_notes_empty(client: TestClient) -> None: + r = client.get("/notes") assert r.status_code == 200 body = r.json() assert body["items"] == [] @@ -20,8 +14,7 @@ def test_list_notes_empty() -> None: assert body["total"] == 0 -def test_create_and_get_note() -> None: - client = _client() +def test_create_and_get_note(client: TestClient) -> None: r = client.post("/notes", json={"title": "Hello", "body": "World"}) assert r.status_code == 201 note_id = r.json()["id"] @@ -31,19 +24,18 @@ def test_create_and_get_note() -> None: assert r2.json()["title"] == "Hello" -def test_create_note_empty_title_returns_422() -> None: - r = _client().post("/notes", json={"title": "", "body": "b"}) +def test_create_note_empty_title_returns_422(client: TestClient) -> None: + r = client.post("/notes", json={"title": "", "body": "b"}) assert r.status_code == 422 assert r.json()["errors"][0]["field"] == "title" -def test_get_nonexistent_note_returns_404() -> None: - r = _client().get("/notes/9999") +def test_get_nonexistent_note_returns_404(client: TestClient) -> None: + r = client.get("/notes/9999") assert r.status_code == 404 -def test_update_note_returns_200() -> None: - client = _client() +def test_update_note_returns_200(client: TestClient) -> None: r = client.post("/notes", json={"title": "Old", "body": "Old body"}) note_id = r.json()["id"] @@ -53,21 +45,19 @@ def test_update_note_returns_200() -> None: assert r2.json()["body"] == "New body" -def test_update_nonexistent_note_returns_404() -> None: - r = _client().put("/notes/9999", json={"title": "T", "body": "B"}) +def test_update_nonexistent_note_returns_404(client: TestClient) -> None: + r = client.put("/notes/9999", json={"title": "T", "body": "B"}) assert r.status_code == 404 -def test_update_note_empty_title_returns_422() -> None: - client = _client() +def test_update_note_empty_title_returns_422(client: TestClient) -> None: r = client.post("/notes", json={"title": "T", "body": "B"}) note_id = r.json()["id"] r2 = client.put(f"/notes/{note_id}", json={"title": "", "body": "B"}) assert r2.status_code == 422 -def test_delete_note_returns_204() -> None: - client = _client() +def test_delete_note_returns_204(client: TestClient) -> None: r = client.post("/notes", json={"title": "T", "body": "B"}) note_id = r.json()["id"] @@ -78,12 +68,13 @@ def test_delete_note_returns_204() -> None: assert r3.status_code == 404 -def test_delete_nonexistent_note_returns_404() -> None: - r = _client().delete("/notes/9999") +def test_delete_nonexistent_note_returns_404(client: TestClient) -> None: + r = client.delete("/notes/9999") assert r.status_code == 404 -def test_health() -> None: - r = _client().get("/health") +@pytest.mark.usefixtures("client") +def test_health(client: TestClient) -> None: + r = client.get("/health") assert r.status_code == 200 assert r.json()["status"] == "ok" diff --git a/tests/example/tag/test_tags.py b/tests/example/tag/test_tags.py index ea4d2f5..609ceee 100644 --- a/tests/example/tag/test_tags.py +++ b/tests/example/tag/test_tags.py @@ -2,24 +2,16 @@ from fastapi.testclient import TestClient -from nene2.config import AppSettings -from src.example.app import create_app - -def _client() -> TestClient: - return TestClient(create_app(AppSettings())) - - -def test_list_tags_empty() -> None: - r = _client().get("/tags") +def test_list_tags_empty(client: TestClient) -> None: + r = client.get("/tags") assert r.status_code == 200 body = r.json() assert body["items"] == [] assert body["total"] == 0 -def test_create_and_get_tag() -> None: - client = _client() +def test_create_and_get_tag(client: TestClient) -> None: r = client.post("/tags", json={"name": "python"}) assert r.status_code == 201 tag_id = r.json()["id"] @@ -29,19 +21,18 @@ def test_create_and_get_tag() -> None: assert r2.json()["name"] == "python" -def test_create_tag_empty_name_returns_422() -> None: - r = _client().post("/tags", json={"name": ""}) +def test_create_tag_empty_name_returns_422(client: TestClient) -> None: + r = client.post("/tags", json={"name": ""}) assert r.status_code == 422 assert r.json()["errors"][0]["field"] == "name" -def test_get_nonexistent_tag_returns_404() -> None: - r = _client().get("/tags/9999") +def test_get_nonexistent_tag_returns_404(client: TestClient) -> None: + r = client.get("/tags/9999") assert r.status_code == 404 -def test_update_tag_returns_200() -> None: - client = _client() +def test_update_tag_returns_200(client: TestClient) -> None: r = client.post("/tags", json={"name": "old"}) tag_id = r.json()["id"] @@ -50,21 +41,19 @@ def test_update_tag_returns_200() -> None: assert r2.json()["name"] == "new" -def test_update_nonexistent_tag_returns_404() -> None: - r = _client().put("/tags/9999", json={"name": "x"}) +def test_update_nonexistent_tag_returns_404(client: TestClient) -> None: + r = client.put("/tags/9999", json={"name": "x"}) assert r.status_code == 404 -def test_update_tag_empty_name_returns_422() -> None: - client = _client() +def test_update_tag_empty_name_returns_422(client: TestClient) -> None: r = client.post("/tags", json={"name": "t"}) tag_id = r.json()["id"] r2 = client.put(f"/tags/{tag_id}", json={"name": ""}) assert r2.status_code == 422 -def test_delete_tag_returns_204() -> None: - client = _client() +def test_delete_tag_returns_204(client: TestClient) -> None: r = client.post("/tags", json={"name": "temp"}) tag_id = r.json()["id"] @@ -75,13 +64,12 @@ def test_delete_tag_returns_204() -> None: assert r3.status_code == 404 -def test_delete_nonexistent_tag_returns_404() -> None: - r = _client().delete("/tags/9999") +def test_delete_nonexistent_tag_returns_404(client: TestClient) -> None: + r = client.delete("/tags/9999") assert r.status_code == 404 -def test_list_tags_pagination() -> None: - client = _client() +def test_list_tags_pagination(client: TestClient) -> None: for name in ["a", "b", "c"]: client.post("/tags", json={"name": name})