From f9ac9cab129bc989c63452da1cb16a33bfac5c2a Mon Sep 17 00:00:00 2001 From: hideyukiMORI Date: Tue, 19 May 2026 20:36:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SecurityHeadersMiddleware=20=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=81=99=E3=82=8B=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- docs/roadmap.md | 46 +++++++++---------- src/example/app.py | 4 +- src/nene2/config/settings.py | 1 + src/nene2/middleware/__init__.py | 3 +- src/nene2/middleware/security_headers.py | 26 +++++++++++ .../nene2/middleware/test_security_headers.py | 43 +++++++++++++++++ 6 files changed, 96 insertions(+), 27 deletions(-) create mode 100644 src/nene2/middleware/security_headers.py create mode 100644 tests/nene2/middleware/test_security_headers.py diff --git a/docs/roadmap.md b/docs/roadmap.md index 7cd301c..c10692a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -45,40 +45,36 @@ PHP 版 NENE2 との機能同等性を達成し、さらに Python 固有の強 ## ロードマップ -### v0.2.0 — Write Operations & Domain Exceptions +### v0.2.0 — Write Operations & Domain Exceptions ✅ DONE **ゴール**: CRUD を完成させ、ドメイン例外パターンを確立する。PHP 版の Note フルセットに追いつく。 -- [ ] Note: `UpdateNoteUseCase` + `UpdateNoteHandler` (PUT) -- [ ] Note: `DeleteNoteUseCase` + `DeleteNoteHandler` (DELETE → 204) -- [ ] `NoteNotFoundException` + `DomainExceptionHandlerInterface` パターン実装 -- [ ] Note: `NoteRepositoryInterface` を Protocol として整備 -- [ ] Tag: `TagEntity`, `TagRepositoryInterface`, `InMemoryTagRepository` -- [ ] Tag: `ListTagsUseCase`, `GetTagUseCase`, `CreateTagUseCase`, `UpdateTagUseCase`, `DeleteTagUseCase` -- [ ] Tag: 5 handler (List, Get, Create, Update, Delete) -- [ ] `TagNotFoundException` -- [ ] `/health` エンドポイント (`HealthCheckInterface` + 基本応答) -- [ ] GitHub Actions CI(pytest + mypy + ruff + pip-audit) -- [ ] `.env.example` - -**完了定義**: `GET/POST/PUT/DELETE /notes` と `GET/POST/PUT/DELETE /tags` が InMemory で動作し、CI が green になること。 +- [x] Note: `UpdateNoteUseCase` + `UpdateNoteHandler` (PUT) +- [x] Note: `DeleteNoteUseCase` + `DeleteNoteHandler` (DELETE → 204) +- [x] `NoteNotFoundException` + `DomainExceptionHandlerInterface` パターン実装 +- [x] Note: `NoteRepositoryInterface` を Protocol として整備 +- [x] Tag: `TagEntity`, `TagRepositoryInterface`, `InMemoryTagRepository` +- [x] Tag: `ListTagsUseCase`, `GetTagUseCase`, `CreateTagUseCase`, `UpdateTagUseCase`, `DeleteTagUseCase` +- [x] Tag: 5 handler (List, Get, Create, Update, Delete) +- [x] `TagNotFoundException` +- [x] `/health` エンドポイント (`HealthCheckInterface` + 基本応答) +- [x] GitHub Actions CI(pytest + mypy + ruff + pip-audit) +- [x] `.env.example` --- -### v0.3.0 — Real Database +### v0.3.0 — Real Database ✅ DONE **ゴール**: InMemory を SQLite に差し替えて本番利用可能な状態にする。 -- [ ] `nene2.database`: `DatabaseConnectionInterface`, `DatabaseQueryExecutorInterface`, `DatabaseTransactionManagerInterface` -- [ ] `SqliteDatabaseConnection` 実装 -- [ ] `SqliteNoteRepository`(CRUD 全操作) -- [ ] `SqliteTagRepository`(CRUD 全操作) -- [ ] Alembic セットアップ(`alembic init`, initial migration) -- [ ] `DatabaseHealthCheck`(接続確認 → `/health` に統合) -- [ ] テスト: DB テストを InMemory と SQLite の両方で実行する戦略を確立 -- [ ] `DB_ADAPTER=sqlite` の場合の環境変数ドキュメント - -**完了定義**: `DB_ADAPTER=sqlite DB_NAME=./data/nene2.db` で起動し、Note/Tag の CRUD が永続化されること。 +- [x] `nene2.database`: `DatabaseQueryExecutorInterface`, `DatabaseTransactionManagerInterface` +- [x] `SqlAlchemyQueryExecutor` 実装 +- [x] `SqliteNoteRepository`(CRUD 全操作) +- [x] `SqliteTagRepository`(CRUD 全操作) +- [x] Alembic セットアップ(`alembic init`, initial migration) +- [x] `DatabaseHealthCheck`(接続確認 → `/health` に統合) +- [x] テスト: DB テストを InMemory と SQLite の両方で実行する戦略を確立 +- [x] `DB_ADAPTER=sqlite` の場合の環境変数ドキュメント --- diff --git a/src/example/app.py b/src/example/app.py index 0d8078a..49fd021 100644 --- a/src/example/app.py +++ b/src/example/app.py @@ -12,7 +12,7 @@ SqlAlchemyQueryExecutor, ) from nene2.http import HealthStatus -from nene2.middleware import ErrorHandlerMiddleware +from nene2.middleware import ErrorHandlerMiddleware, SecurityHeadersMiddleware from nene2.validation.exceptions import ValidationException from .note.exceptions import NoteNotFoundExceptionHandler @@ -67,6 +67,8 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: openapi_url="/openapi.json", ) + if cfg.security_headers_enabled: + app.add_middleware(SecurityHeadersMiddleware) app.add_middleware( ErrorHandlerMiddleware, debug=cfg.app_debug, diff --git a/src/nene2/config/settings.py b/src/nene2/config/settings.py index 68ac150..7c5cbae 100644 --- a/src/nene2/config/settings.py +++ b/src/nene2/config/settings.py @@ -12,6 +12,7 @@ class AppSettings(BaseSettings): app_env: str = "local" app_debug: bool = False app_name: str = "nene2-python" + security_headers_enabled: bool = True db_adapter: str = "sqlite" db_name: str = ":memory:" diff --git a/src/nene2/middleware/__init__.py b/src/nene2/middleware/__init__.py index a3a1232..13a7fe0 100644 --- a/src/nene2/middleware/__init__.py +++ b/src/nene2/middleware/__init__.py @@ -2,5 +2,6 @@ from .domain_exception import DomainExceptionHandlerProtocol from .error_handler import ErrorHandlerMiddleware +from .security_headers import SecurityHeadersMiddleware -__all__ = ["DomainExceptionHandlerProtocol", "ErrorHandlerMiddleware"] +__all__ = ["DomainExceptionHandlerProtocol", "ErrorHandlerMiddleware", "SecurityHeadersMiddleware"] diff --git a/src/nene2/middleware/security_headers.py b/src/nene2/middleware/security_headers.py new file mode 100644 index 0000000..05b6cb8 --- /dev/null +++ b/src/nene2/middleware/security_headers.py @@ -0,0 +1,26 @@ +"""Security headers middleware. + +Adds defensive HTTP headers to every response. +""" + +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response + +_HEADERS: dict[str, str] = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Content-Security-Policy": "default-src 'self'", + "Permissions-Policy": "geolocation=(), microphone=()", +} + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Attach security headers to every HTTP response.""" + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + response = await call_next(request) + for header, value in _HEADERS.items(): + response.headers[header] = value + return response diff --git a/tests/nene2/middleware/test_security_headers.py b/tests/nene2/middleware/test_security_headers.py new file mode 100644 index 0000000..aea581d --- /dev/null +++ b/tests/nene2/middleware/test_security_headers.py @@ -0,0 +1,43 @@ +"""Tests for SecurityHeadersMiddleware.""" + +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient + +from nene2.middleware import SecurityHeadersMiddleware + + +def _make_app() -> FastAPI: + app = FastAPI() + app.add_middleware(SecurityHeadersMiddleware) + + @app.get("/ping") + async def ping() -> JSONResponse: + return JSONResponse({"ok": True}) + + return app + + +def test_security_headers_present() -> None: + client = TestClient(_make_app()) + response = client.get("/ping") + assert response.status_code == 200 + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["X-Frame-Options"] == "DENY" + assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin" + assert "Content-Security-Policy" in response.headers + assert "Permissions-Policy" in response.headers + + +def test_security_headers_on_error_response() -> None: + app = FastAPI() + app.add_middleware(SecurityHeadersMiddleware) + + @app.get("/boom") + async def boom() -> JSONResponse: + return JSONResponse({}, status_code=404) + + client = TestClient(app) + response = client.get("/boom") + assert response.status_code == 404 + assert response.headers["X-Content-Type-Options"] == "nosniff"