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 @@