diff --git a/.env.example b/.env.example index ce2fbff..9ac77b4 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,38 @@ +# ────────────────────────────────────── # Database +# ────────────────────────────────────── POSTGRES_USER=umbra POSTGRES_PASSWORD=changeme_in_production POSTGRES_DB=umbra - -# Backend DATABASE_URL=postgresql+asyncpg://umbra:changeme_in_production@db:5432/umbra -SECRET_KEY=change-this-to-a-random-secret-key-in-production -# Environment (development|production — controls Swagger/ReDoc visibility) -# ENVIRONMENT=development +# ────────────────────────────────────── +# Application +# ────────────────────────────────────── +# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=your-secret-key-change-in-production -# CORS allowed origins (comma-separated, default: http://localhost:5173) -# CORS_ORIGINS=http://localhost:5173 +# development | production — controls Swagger/ReDoc visibility and cookie defaults +ENVIRONMENT=development + +# Public URL — used for ntfy click links and auto-derives CORS_ORIGINS in production +# UMBRA_URL=https://umbra.example.com # Timezone (applied to backend + db containers via env_file) TZ=Australia/Perth -# Session cookie security -# Set to true when serving over HTTPS. Required before any TLS deployment. -# COOKIE_SECURE=true - +# ────────────────────────────────────── # Integrations +# ────────────────────────────────────── OPENWEATHERMAP_API_KEY=your-openweathermap-api-key -# Production security checklist (enable all before any non-internal deployment): -# 1. Set SECRET_KEY to output of: openssl rand -hex 32 -# 2. Set POSTGRES_PASSWORD to a strong unique value -# 3. Set ENVIRONMENT=production (disables Swagger/ReDoc on backend:8000) -# 4. Set COOKIE_SECURE=true (requires TLS termination at nginx or upstream) -# 5. Add HSTS to nginx.conf: add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; -# 6. Complete user_id migration (migration 026) before enabling multi-user accounts +# ────────────────────────────────────── +# Overrides (rarely needed) +# ────────────────────────────────────── +# COOKIE_SECURE auto-derives from ENVIRONMENT (production → true). +# Only set explicitly to override, e.g. false for a non-TLS prod behind a proxy. +# COOKIE_SECURE=false + +# CORS_ORIGINS auto-derives from UMBRA_URL in production, http://localhost:5173 in dev. +# Only set explicitly if you need a different origin or multiple origins. +# CORS_ORIGINS=https://custom-domain.example.com diff --git a/README.md b/README.md index 01d27ac..db33f82 100644 --- a/README.md +++ b/README.md @@ -143,17 +143,13 @@ python3 -c "import secrets; print(secrets.token_hex(32))" python3 -c "import secrets; print(secrets.token_urlsafe(24))" # or: openssl rand -base64 24 -# Set ENVIRONMENT to disable Swagger/ReDoc +# Set ENVIRONMENT to disable Swagger/ReDoc and auto-enable secure cookies ENVIRONMENT=production - -# Enable secure cookies (requires HTTPS) -COOKIE_SECURE=true ``` Additionally for production: - Place behind a reverse proxy with TLS termination (e.g., Caddy, Traefik, or nginx with Let's Encrypt) -- Set `COOKIE_SECURE=true` to enforce HTTPS-only session cookies -- Set `ENVIRONMENT=production` to disable API documentation endpoints +- Set `ENVIRONMENT=production` — this disables API docs and auto-enables HTTPS-only session cookies (`COOKIE_SECURE` derives from `ENVIRONMENT`; override with `COOKIE_SECURE=false` if running non-TLS prod behind a proxy) - Set `CORS_ORIGINS` to your actual domain (e.g., `https://umbra.example.com`) - Consider adding HSTS headers at the TLS-terminating proxy layer diff --git a/backend/.env.example b/backend/.env.example index 0f23dea..396bfaa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,2 +1,2 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra -SECRET_KEY=your-secret-key-change-in-production-use-a-long-random-string +SECRET_KEY=your-secret-key-change-in-production diff --git a/backend/Dockerfile b/backend/Dockerfile index a278604..c80a749 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,16 +1,22 @@ +# ── Build stage: compile C extensions ────────────────────────────────── +FROM python:3.12-slim AS builder + +WORKDIR /build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +# ── Runtime stage: lean production image ─────────────────────────────── FROM python:3.12-slim WORKDIR /app -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - postgresql-client \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements and install Python dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# Copy pre-built Python packages from builder +COPY --from=builder /install /usr/local # Copy application code COPY . . @@ -19,8 +25,10 @@ COPY . . RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app USER appuser -# Expose port EXPOSE 8000 -# Run migrations and start server (--no-server-header suppresses uvicorn version disclosure) -CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header"] +# Run migrations and start server +# --no-server-header: suppresses uvicorn version disclosure +# --proxy-headers: reads X-Forwarded-Proto/For from reverse proxy so redirects use correct scheme +# --forwarded-allow-ips '*': trusts proxy headers from any IP (nginx is on Docker bridge network) +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header --proxy-headers --forwarded-allow-ips '*'"] diff --git a/backend/app/config.py b/backend/app/config.py index ca01e9c..592af82 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,5 @@ import sys +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -6,7 +7,7 @@ class Settings(BaseSettings): DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/umbra" SECRET_KEY: str = "your-secret-key-change-in-production" ENVIRONMENT: str = "development" - COOKIE_SECURE: bool = False + COOKIE_SECURE: bool | None = None # Auto-derives from ENVIRONMENT if not explicitly set OPENWEATHERMAP_API_KEY: str = "" # Session config — sliding window @@ -19,8 +20,15 @@ class Settings(BaseSettings): # TOTP issuer name shown in authenticator apps TOTP_ISSUER: str = "UMBRA" - # CORS allowed origins (comma-separated) - CORS_ORIGINS: str = "http://localhost:5173" + # CORS allowed origins (comma-separated). Auto-derives from UMBRA_URL in + # production or http://localhost:5173 in development. Set explicitly to override. + CORS_ORIGINS: str | None = None + + # Public-facing URL used in ntfy notification click links and CORS derivation + UMBRA_URL: str = "http://localhost" + + # Concurrent session limit per user (oldest evicted when exceeded) + MAX_SESSIONS_PER_USER: int = 10 model_config = SettingsConfigDict( env_file=".env", @@ -28,8 +36,26 @@ class Settings(BaseSettings): case_sensitive=True ) + @model_validator(mode="after") + def derive_defaults(self) -> "Settings": + if self.COOKIE_SECURE is None: + self.COOKIE_SECURE = self.ENVIRONMENT == "production" + if self.CORS_ORIGINS is None: + if self.ENVIRONMENT == "production": + self.CORS_ORIGINS = self.UMBRA_URL + else: + self.CORS_ORIGINS = "http://localhost:5173" + assert self.COOKIE_SECURE is not None # type narrowing + assert self.CORS_ORIGINS is not None + return self + settings = Settings() +print( + f"INFO: COOKIE_SECURE={settings.COOKIE_SECURE} " + f"(ENVIRONMENT={settings.ENVIRONMENT})", + file=sys.stderr, +) if settings.SECRET_KEY == "your-secret-key-change-in-production": if settings.ENVIRONMENT != "development": diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index cc97c35..be463f4 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -35,7 +35,7 @@ from app.services.ntfy_templates import ( logger = logging.getLogger(__name__) -UMBRA_URL = "http://10.0.69.35" +from app.config import settings as app_settings # ── Dedup helpers ───────────────────────────────────────────────────────────── @@ -92,7 +92,7 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim ) sent = await send_ntfy_notification( settings=settings, - click_url=UMBRA_URL, + click_url=app_settings.UMBRA_URL, **payload, ) if sent: @@ -144,7 +144,7 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime, ) sent = await send_ntfy_notification( settings=settings, - click_url=UMBRA_URL, + click_url=app_settings.UMBRA_URL, **payload, ) if sent: @@ -184,7 +184,7 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today, sent_keys ) sent = await send_ntfy_notification( settings=settings, - click_url=UMBRA_URL, + click_url=app_settings.UMBRA_URL, **payload, ) if sent: @@ -223,7 +223,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today, sent_k ) sent = await send_ntfy_notification( settings=settings, - click_url=UMBRA_URL, + click_url=app_settings.UMBRA_URL, **payload, ) if sent: diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 78f1906..b355344 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -770,7 +770,9 @@ async def get_audit_log( ) if action: - base_q = base_q.where(AuditLog.action.like(f"{action}%")) + # Escape LIKE metacharacters so user input is treated literally + safe_action = action.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + base_q = base_q.where(AuditLog.action.like(f"{safe_action}%", escape="\\")) if target_user_id is not None: base_q = base_q.where(AuditLog.target_user_id == target_user_id) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 72cdbbc..b982e48 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -66,6 +66,7 @@ def _set_session_cookie(response: Response, token: str) -> None: secure=app_settings.COOKIE_SECURE, max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400, samesite="lax", + path="/", ) @@ -219,6 +220,26 @@ async def _create_db_session( ) db.add(db_session) await db.flush() + + # Enforce concurrent session limit: revoke oldest sessions beyond the cap + active_sessions = ( + await db.execute( + select(UserSession) + .where( + UserSession.user_id == user.id, + UserSession.revoked == False, # noqa: E712 + UserSession.expires_at > datetime.now(), + ) + .order_by(UserSession.created_at.asc()) + ) + ).scalars().all() + + max_sessions = app_settings.MAX_SESSIONS_PER_USER + if len(active_sessions) > max_sessions: + for old_session in active_sessions[: len(active_sessions) - max_sessions]: + old_session.revoked = True + await db.flush() + token = create_session_token(user.id, session_id) return session_id, token diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index 6ac79a3..afba67d 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db +from app.config import settings as app_settings from app.models.settings import Settings from app.models.user import User from app.schemas.settings import SettingsUpdate, SettingsResponse @@ -108,7 +109,7 @@ async def test_ntfy( message="If you see this, your ntfy integration is working correctly.", tags=["white_check_mark"], priority=3, - click_url="http://10.0.69.35", + click_url=app_settings.UMBRA_URL, ) if not success: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 1e8bbd8..3a39185 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -21,15 +21,26 @@ server { # Suppress nginx version in Server header server_tokens off; + # ── Real client IP restoration (PT-01) ──────────────────────────── + # Pangolin (TLS-terminating reverse proxy) connects via Docker bridge. + # Restore the real client IP from X-Forwarded-For so that limit_req_zone + # (which keys on $binary_remote_addr) throttles per-client, not per-proxy. + # Safe to trust all sources: nginx is only reachable via Docker networking, + # never directly internet-facing. Tighten if deployment model changes. + set_real_ip_from 0.0.0.0/0; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + # Gzip compression gzip on; gzip_vary on; gzip_min_length 1024; gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json; - # Block dotfiles (except .well-known for ACME/Let's Encrypt) + # Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04) location ~ /\.(?!well-known) { - return 404; + default_type application/json; + return 404 '{"detail":"Not Found"}'; } # Rate-limited auth endpoints (keep in sync with proxy-params.conf) @@ -104,6 +115,7 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; } # Security headers @@ -111,4 +123,5 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; }