Merge security/pentest-remediation-20260302: production hardening + pentest remediation
- Auto-derive COOKIE_SECURE from ENVIRONMENT and CORS_ORIGINS from UMBRA_URL - Fix 503s behind reverse proxy with uvicorn --proxy-headers - Harden nginx: real client IP restoration, HSTS, custom dotfile 404 - Multi-stage Dockerfile: remove build tools from runtime image - Concurrent session limiting with oldest-first eviction - LIKE wildcard escaping in admin audit log filter - Cookie path=/ for consistent scope - Restructure .env.example for better onboarding UX Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
3e39c709b7
42
.env.example
42
.env.example
@ -1,32 +1,38 @@
|
|||||||
|
# ──────────────────────────────────────
|
||||||
# Database
|
# Database
|
||||||
|
# ──────────────────────────────────────
|
||||||
POSTGRES_USER=umbra
|
POSTGRES_USER=umbra
|
||||||
POSTGRES_PASSWORD=changeme_in_production
|
POSTGRES_PASSWORD=changeme_in_production
|
||||||
POSTGRES_DB=umbra
|
POSTGRES_DB=umbra
|
||||||
|
|
||||||
# Backend
|
|
||||||
DATABASE_URL=postgresql+asyncpg://umbra:changeme_in_production@db:5432/umbra
|
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)
|
# development | production — controls Swagger/ReDoc visibility and cookie defaults
|
||||||
# CORS_ORIGINS=http://localhost:5173
|
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)
|
# Timezone (applied to backend + db containers via env_file)
|
||||||
TZ=Australia/Perth
|
TZ=Australia/Perth
|
||||||
|
|
||||||
# Session cookie security
|
# ──────────────────────────────────────
|
||||||
# Set to true when serving over HTTPS. Required before any TLS deployment.
|
|
||||||
# COOKIE_SECURE=true
|
|
||||||
|
|
||||||
# Integrations
|
# Integrations
|
||||||
|
# ──────────────────────────────────────
|
||||||
OPENWEATHERMAP_API_KEY=your-openweathermap-api-key
|
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
|
# Overrides (rarely needed)
|
||||||
# 2. Set POSTGRES_PASSWORD to a strong unique value
|
# ──────────────────────────────────────
|
||||||
# 3. Set ENVIRONMENT=production (disables Swagger/ReDoc on backend:8000)
|
# COOKIE_SECURE auto-derives from ENVIRONMENT (production → true).
|
||||||
# 4. Set COOKIE_SECURE=true (requires TLS termination at nginx or upstream)
|
# Only set explicitly to override, e.g. false for a non-TLS prod behind a proxy.
|
||||||
# 5. Add HSTS to nginx.conf: add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
# COOKIE_SECURE=false
|
||||||
# 6. Complete user_id migration (migration 026) before enabling multi-user accounts
|
|
||||||
|
# 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
|
||||||
|
|||||||
@ -143,17 +143,13 @@ python3 -c "import secrets; print(secrets.token_hex(32))"
|
|||||||
python3 -c "import secrets; print(secrets.token_urlsafe(24))"
|
python3 -c "import secrets; print(secrets.token_urlsafe(24))"
|
||||||
# or: openssl rand -base64 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
|
ENVIRONMENT=production
|
||||||
|
|
||||||
# Enable secure cookies (requires HTTPS)
|
|
||||||
COOKIE_SECURE=true
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Additionally for production:
|
Additionally for production:
|
||||||
- Place behind a reverse proxy with TLS termination (e.g., Caddy, Traefik, or nginx with Let's Encrypt)
|
- 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` — 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 `ENVIRONMENT=production` to disable API documentation endpoints
|
|
||||||
- Set `CORS_ORIGINS` to your actual domain (e.g., `https://umbra.example.com`)
|
- Set `CORS_ORIGINS` to your actual domain (e.g., `https://umbra.example.com`)
|
||||||
- Consider adding HSTS headers at the TLS-terminating proxy layer
|
- Consider adding HSTS headers at the TLS-terminating proxy layer
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
|
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
|
||||||
|
|||||||
@ -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
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Copy pre-built Python packages from builder
|
||||||
RUN apt-get update && apt-get install -y \
|
COPY --from=builder /install /usr/local
|
||||||
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 application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
@ -19,8 +25,10 @@ COPY . .
|
|||||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run migrations and start server (--no-server-header suppresses uvicorn version disclosure)
|
# Run migrations and start server
|
||||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header"]
|
# --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 '*'"]
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import sys
|
import sys
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@ -6,7 +7,7 @@ class Settings(BaseSettings):
|
|||||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/umbra"
|
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/umbra"
|
||||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||||
ENVIRONMENT: str = "development"
|
ENVIRONMENT: str = "development"
|
||||||
COOKIE_SECURE: bool = False
|
COOKIE_SECURE: bool | None = None # Auto-derives from ENVIRONMENT if not explicitly set
|
||||||
OPENWEATHERMAP_API_KEY: str = ""
|
OPENWEATHERMAP_API_KEY: str = ""
|
||||||
|
|
||||||
# Session config — sliding window
|
# Session config — sliding window
|
||||||
@ -19,8 +20,15 @@ class Settings(BaseSettings):
|
|||||||
# TOTP issuer name shown in authenticator apps
|
# TOTP issuer name shown in authenticator apps
|
||||||
TOTP_ISSUER: str = "UMBRA"
|
TOTP_ISSUER: str = "UMBRA"
|
||||||
|
|
||||||
# CORS allowed origins (comma-separated)
|
# CORS allowed origins (comma-separated). Auto-derives from UMBRA_URL in
|
||||||
CORS_ORIGINS: str = "http://localhost:5173"
|
# 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(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
@ -28,8 +36,26 @@ class Settings(BaseSettings):
|
|||||||
case_sensitive=True
|
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()
|
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.SECRET_KEY == "your-secret-key-change-in-production":
|
||||||
if settings.ENVIRONMENT != "development":
|
if settings.ENVIRONMENT != "development":
|
||||||
|
|||||||
@ -35,7 +35,7 @@ from app.services.ntfy_templates import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
UMBRA_URL = "http://10.0.69.35"
|
from app.config import settings as app_settings
|
||||||
|
|
||||||
|
|
||||||
# ── Dedup helpers ─────────────────────────────────────────────────────────────
|
# ── Dedup helpers ─────────────────────────────────────────────────────────────
|
||||||
@ -92,7 +92,7 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim
|
|||||||
)
|
)
|
||||||
sent = await send_ntfy_notification(
|
sent = await send_ntfy_notification(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
click_url=UMBRA_URL,
|
click_url=app_settings.UMBRA_URL,
|
||||||
**payload,
|
**payload,
|
||||||
)
|
)
|
||||||
if sent:
|
if sent:
|
||||||
@ -144,7 +144,7 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime,
|
|||||||
)
|
)
|
||||||
sent = await send_ntfy_notification(
|
sent = await send_ntfy_notification(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
click_url=UMBRA_URL,
|
click_url=app_settings.UMBRA_URL,
|
||||||
**payload,
|
**payload,
|
||||||
)
|
)
|
||||||
if sent:
|
if sent:
|
||||||
@ -184,7 +184,7 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today, sent_keys
|
|||||||
)
|
)
|
||||||
sent = await send_ntfy_notification(
|
sent = await send_ntfy_notification(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
click_url=UMBRA_URL,
|
click_url=app_settings.UMBRA_URL,
|
||||||
**payload,
|
**payload,
|
||||||
)
|
)
|
||||||
if sent:
|
if sent:
|
||||||
@ -223,7 +223,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today, sent_k
|
|||||||
)
|
)
|
||||||
sent = await send_ntfy_notification(
|
sent = await send_ntfy_notification(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
click_url=UMBRA_URL,
|
click_url=app_settings.UMBRA_URL,
|
||||||
**payload,
|
**payload,
|
||||||
)
|
)
|
||||||
if sent:
|
if sent:
|
||||||
|
|||||||
@ -770,7 +770,9 @@ async def get_audit_log(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if action:
|
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:
|
if target_user_id is not None:
|
||||||
base_q = base_q.where(AuditLog.target_user_id == target_user_id)
|
base_q = base_q.where(AuditLog.target_user_id == target_user_id)
|
||||||
|
|
||||||
|
|||||||
@ -66,6 +66,7 @@ def _set_session_cookie(response: Response, token: str) -> None:
|
|||||||
secure=app_settings.COOKIE_SECURE,
|
secure=app_settings.COOKIE_SECURE,
|
||||||
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
|
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -219,6 +220,26 @@ async def _create_db_session(
|
|||||||
)
|
)
|
||||||
db.add(db_session)
|
db.add(db_session)
|
||||||
await db.flush()
|
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)
|
token = create_session_token(user.id, session_id)
|
||||||
return session_id, token
|
return session_id, token
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.config import settings as app_settings
|
||||||
from app.models.settings import Settings
|
from app.models.settings import Settings
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.settings import SettingsUpdate, SettingsResponse
|
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.",
|
message="If you see this, your ntfy integration is working correctly.",
|
||||||
tags=["white_check_mark"],
|
tags=["white_check_mark"],
|
||||||
priority=3,
|
priority=3,
|
||||||
click_url="http://10.0.69.35",
|
click_url=app_settings.UMBRA_URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
|
|||||||
@ -21,15 +21,26 @@ server {
|
|||||||
# Suppress nginx version in Server header
|
# Suppress nginx version in Server header
|
||||||
server_tokens off;
|
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 compression
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
gzip_min_length 1024;
|
gzip_min_length 1024;
|
||||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
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) {
|
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)
|
# 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 X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" 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 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
|
# Security headers
|
||||||
@ -111,4 +123,5 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" 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 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user