diff --git a/.dockerignore b/.dockerignore index 4ab932a..3e63f8a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,3 +28,4 @@ tests/ # Docs / misc *.md +!README.md diff --git a/Dockerfile b/Dockerfile index d429074..fdc8a11 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,8 @@ WORKDIR /app COPY pyproject.toml . COPY uv.lock . +COPY LICENSE . +COPY README.md . COPY src/ src/ # Copy pre-built frontend assets (required for wheel artifacts). @@ -41,8 +43,6 @@ RUN groupadd --gid 1001 app && \ WORKDIR /app COPY --from=builder /app/.venv .venv -COPY entrypoint.sh . -RUN chmod +x /app/entrypoint.sh ENV PATH="/app/.venv/bin:$PATH" diff --git a/examples/01-local-dev/example.env b/examples/01-local-dev/example.env index c7ae1da..e140d49 100644 --- a/examples/01-local-dev/example.env +++ b/examples/01-local-dev/example.env @@ -23,14 +23,14 @@ APP_ENV=dev # First-run administrator account, created automatically on startup. # Sign in with these, then create more users from the admin panel at /admin. ADMIN_BOOTSTRAP_USERNAME=admin -ADMIN_BOOTSTRAP_PASSWORD=changeme +ADMIN_BOOTSTRAP_PASSWORD=changeme # pragma: allowlist secret # Title shown in the app header and browser tab. # CUSTOM_HEADER_TITLE=My Chat # Database connection. When unset, a local SQLite file (./strands-chat.sqlite) # is used, which is ideal for development. Uncomment to use Postgres instead. -# DATABASE_URL=postgresql+psycopg://user:password@localhost:5432/dbname +# DATABASE_URL=postgresql+psycopg://user:password@localhost:5432/dbname # pragma: allowlist secret # ----------------------------------------------------------------------------- # OPTIONAL — OIDC single sign-on (usually not needed for local development) diff --git a/src/strands_compose_chat/app.py b/src/strands_compose_chat/app.py index f6ac9d9..dd80a88 100644 --- a/src/strands_compose_chat/app.py +++ b/src/strands_compose_chat/app.py @@ -8,7 +8,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware -from fastapi.middleware.trustedhost import TrustedHostMiddleware from fastapi.responses import JSONResponse from sqladmin import Admin from sqlalchemy import select @@ -39,7 +38,7 @@ from .frontend import mount_frontend from .logging import configure_logging from .media.routes import router as media_router -from .middleware import SecurityHeadersMiddleware +from .middleware import HealthExemptTrustedHostMiddleware, SecurityHeadersMiddleware from .sessions.routes import router as sessions_router logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) @@ -94,7 +93,18 @@ def create_app(settings: Settings | None = None) -> FastAPI: same_site="lax", ) app.add_middleware(SecurityHeadersMiddleware) - app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.TRUSTED_HOSTS) + app.add_middleware( + HealthExemptTrustedHostMiddleware, + allowed_hosts=settings.TRUSTED_HOSTS, + exempt_paths=frozenset( + { + f"{prefix}/health", + f"{prefix}/ready", # routes mounted under the URL prefix + "/health", + "/ready", # fallback when ASGI root_path is used + } + ), + ) app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ALLOWED_ORIGINS, diff --git a/src/strands_compose_chat/middleware/__init__.py b/src/strands_compose_chat/middleware/__init__.py index 4374af2..a3bbe82 100644 --- a/src/strands_compose_chat/middleware/__init__.py +++ b/src/strands_compose_chat/middleware/__init__.py @@ -1,7 +1,9 @@ -"""Middleware: security headers.""" +"""Middleware: security headers and trusted-host exemptions.""" from .security_headers import SecurityHeadersMiddleware +from .trusted_host import HealthExemptTrustedHostMiddleware __all__ = [ + "HealthExemptTrustedHostMiddleware", "SecurityHeadersMiddleware", ] diff --git a/src/strands_compose_chat/middleware/trusted_host.py b/src/strands_compose_chat/middleware/trusted_host.py new file mode 100644 index 0000000..d071b7d --- /dev/null +++ b/src/strands_compose_chat/middleware/trusted_host.py @@ -0,0 +1,40 @@ +"""TrustedHostMiddleware variant that exempts LB probe paths from host validation.""" + +from starlette.middleware.trustedhost import TrustedHostMiddleware +from starlette.types import Receive, Scope, Send + + +class HealthExemptTrustedHostMiddleware(TrustedHostMiddleware): + """TrustedHostMiddleware that skips host validation for LB health/readiness probes. + + ALB target-group health checks reach the container via its private IP, so + the probe's Host header is the container IP — never a value in + TRUSTED_HOSTS. We bypass host validation only for the exact probe paths; + every other request is still strictly enforced. + + Args: + app: The ASGI application to wrap. + allowed_hosts: Sequence of hostnames allowed through host validation. + www_redirect: When True, redirect non-www to www (passed to parent). + exempt_paths: Frozenset of path strings that skip host validation. + Both the prefixed and un-prefixed probe paths should be included so + the exemption works regardless of whether the app uses ASGI + ``root_path`` or mounts routes directly under the prefix. + """ + + def __init__( + self, + app, # type: ignore[override] # starlette parent uses untyped ASGIApp alias + allowed_hosts: list[str] | None = None, + www_redirect: bool = True, + exempt_paths: frozenset[str] = frozenset(), + ) -> None: + super().__init__(app, allowed_hosts=allowed_hosts, www_redirect=www_redirect) + self.exempt_paths = exempt_paths + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Pass probe paths through without host validation; enforce all others.""" + if scope["type"] == "http" and scope.get("path", "").rstrip("/") in self.exempt_paths: + await self.app(scope, receive, send) + return + await super().__call__(scope, receive, send) diff --git a/src/strands_compose_chat/templates/login.html b/src/strands_compose_chat/templates/login.html index 9022aa0..725e6ce 100644 --- a/src/strands_compose_chat/templates/login.html +++ b/src/strands_compose_chat/templates/login.html @@ -8,7 +8,7 @@ {{ app_title }} - +
diff --git a/tests/conftest.py b/tests/conftest.py index f76883a..3d6f0af 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,6 @@ import asyncio import inspect -import sys from collections.abc import AsyncIterator from datetime import UTC, datetime @@ -18,12 +17,6 @@ from sqlalchemy import event from sqlalchemy.ext.asyncio import AsyncSession -# Windows' default Proactor loop mismanages aiosqlite's worker-thread -# connections (and noisily fails to close itself). The Selector loop matches -# Linux/macOS behaviour and works cleanly with aiosqlite. -if sys.platform == "win32": - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - from strands_compose_chat import config _TEST_SETTINGS = config.Settings( diff --git a/tests/media/test_service.py b/tests/media/test_service.py index 536cd6a..d671eee 100644 --- a/tests/media/test_service.py +++ b/tests/media/test_service.py @@ -13,6 +13,7 @@ _SESSION_SECRET = "t" * 43 _SETTINGS = Settings( + APP_ENV="dev", SESSION_SECRET_KEY=_SESSION_SECRET, CHAT_MEDIA_MAX_FILE_BYTES=5 * 1024 * 1024, CHAT_MEDIA_MAX_TOTAL_BYTES=20 * 1024 * 1024, @@ -29,6 +30,7 @@ def test_build_capabilities_result_is_valid_schema() -> None: def test_build_capabilities_limit_fields_reflect_custom_settings() -> None: custom = Settings( + APP_ENV="dev", SESSION_SECRET_KEY=_SESSION_SECRET, CHAT_MEDIA_MAX_FILE_BYTES=1 * 1024 * 1024, CHAT_MEDIA_MAX_TOTAL_BYTES=4 * 1024 * 1024, diff --git a/tests/unit/test_auth_invariants.py b/tests/unit/test_auth_invariants.py index 547c40b..54306a7 100644 --- a/tests/unit/test_auth_invariants.py +++ b/tests/unit/test_auth_invariants.py @@ -8,7 +8,7 @@ from strands_compose_chat.auth.passwords import hash_password, verify_password from strands_compose_chat.config import Settings -_SETTINGS = Settings(SESSION_SECRET_KEY="t" * 43) +_SETTINGS = Settings(APP_ENV="dev", SESSION_SECRET_KEY="t" * 43) def test_a_password_verifies_against_its_own_hash() -> None: @@ -24,7 +24,7 @@ def test_a_wrong_password_does_not_verify() -> None: def test_argon2_parameters_meet_the_security_floor() -> None: # Guards against silently weakening the hashing cost. Floors, not exact # values, so deliberately strengthening the parameters stays green. - settings = Settings(SESSION_SECRET_KEY="t" * 43) + settings = Settings(APP_ENV="dev", SESSION_SECRET_KEY="t" * 43) assert settings.ARGON2_MEMORY_KIB >= 65536 assert settings.ARGON2_TIME_COST >= 3 assert settings.ARGON2_PARALLELISM >= 4 diff --git a/uv.lock b/uv.lock index 9f21e8b..cc73589 100644 --- a/uv.lock +++ b/uv.lock @@ -2014,7 +2014,7 @@ wheels = [ [[package]] name = "strands-compose-chat" -version = "0.1.2" +version = "0.1.3" source = { editable = "." } dependencies = [ { name = "aiosqlite" },