Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ tests/

# Docs / misc
*.md
!README.md
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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"

Expand Down
4 changes: 2 additions & 2 deletions examples/01-local-dev/example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 13 additions & 3 deletions src/strands_compose_chat/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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__)
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/strands_compose_chat/middleware/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
40 changes: 40 additions & 0 deletions src/strands_compose_chat/middleware/trusted_host.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion src/strands_compose_chat/templates/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_title }}</title>
<script type="module" crossorigin src="{{ url_prefix }}/static/js/login.js"></script>
<link rel="stylesheet" crossorigin href="{{ url_prefix }}/static/css/login.css">
<link rel="stylesheet" crossorigin href="{{ url_prefix }}/static/css/index.css">
</head>
<body>
<div id="auth-root"></div>
Expand Down
7 changes: 0 additions & 7 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

import asyncio
import inspect
import sys
from collections.abc import AsyncIterator
from datetime import UTC, datetime

Expand All @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions tests/media/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test_auth_invariants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.