diff --git a/backend/Dockerfile b/backend/Dockerfile index 790ba30..a278604 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,8 +15,12 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy application code COPY . . +# Create non-root user +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + # Expose port EXPOSE 8000 -# Run migrations and start server -CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 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"] diff --git a/backend/app/config.py b/backend/app/config.py index cc794e0..ee2da79 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -18,6 +18,9 @@ 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" + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/backend/app/main.py b/backend/app/main.py index 8a3b1ef..a088282 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ from contextlib import asynccontextmanager from apscheduler.schedulers.asyncio import AsyncIOScheduler +from app.config import settings from app.database import engine from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates from app.routers import totp @@ -32,20 +33,25 @@ async def lifespan(app: FastAPI): await engine.dispose() +_is_dev = settings.ENVIRONMENT == "development" + app = FastAPI( title="UMBRA API", description="Backend API for UMBRA application", version="1.0.0", - lifespan=lifespan + lifespan=lifespan, + docs_url="/docs" if _is_dev else None, + redoc_url="/redoc" if _is_dev else None, + openapi_url="/openapi.json" if _is_dev else None, ) -# CORS configuration for development +# CORS configuration app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173"], + allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()], allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "Cookie", "X-Requested-With"], ) # Include routers with /api prefix diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 2b2c0b6..531d8ce 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -409,14 +409,22 @@ async def verify_password( @router.post("/change-password") async def change_password( data: ChangePasswordRequest, + request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Change the current user's password. Requires old password verification.""" + client_ip = request.client.host if request.client else "unknown" + _check_ip_rate_limit(client_ip) + await _check_account_lockout(current_user) + valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash) if not valid: + _record_ip_failure(client_ip) + await _record_failed_login(db, current_user) raise HTTPException(status_code=401, detail="Invalid current password") + _failed_attempts.pop(client_ip, None) current_user.password_hash = hash_password(data.new_password) await db.commit() diff --git a/docker-compose.yaml b/docker-compose.yaml index 23491da..1b56b01 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,8 +14,6 @@ services: backend: build: ./backend restart: unless-stopped - ports: - - "8000:8000" env_file: .env depends_on: db: @@ -30,7 +28,7 @@ services: build: ./frontend restart: unless-stopped ports: - - "80:80" + - "80:8080" depends_on: backend: condition: service_healthy diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f190830..b77e595 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -15,8 +15,8 @@ COPY . . # Build the application RUN npm run build -# Production stage -FROM nginx:alpine +# Production stage — unprivileged nginx (runs as non-root, listens on 8080) +FROM nginxinc/nginx-unprivileged:alpine # Copy built files from build stage COPY --from=build /app/dist /usr/share/nginx/html @@ -24,8 +24,8 @@ COPY --from=build /app/dist /usr/share/nginx/html # Copy nginx configuration COPY nginx.conf /etc/nginx/conf.d/default.conf -# Expose port 80 -EXPOSE 80 +# Expose port 8080 (unprivileged) +EXPOSE 8080 # Start nginx CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 0085d07..9fe9c9e 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,15 +1,75 @@ +# Rate limiting zones (before server block) +limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; + server { - listen 80; + listen 8080; server_name localhost; root /usr/share/nginx/html; index index.html; + # Suppress nginx version in Server header + server_tokens off; + # 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) + location ~ /\.(?!well-known) { + return 404; + } + + # Rate-limited auth endpoints + location /api/auth/login { + limit_req zone=auth_limit burst=5 nodelay; + limit_req_status 429; + + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/auth/verify-password { + limit_req zone=auth_limit burst=5 nodelay; + limit_req_status 429; + + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/auth/totp-verify { + limit_req zone=auth_limit burst=5 nodelay; + limit_req_status 429; + + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/auth/change-password { + limit_req zone=auth_limit burst=5 nodelay; + limit_req_status 429; + + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # API proxy location /api { proxy_pass http://backend:8000; @@ -35,12 +95,12 @@ server { add_header X-Frame-Options "SAMEORIGIN" always; 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'; img-src 'self' data:; font-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; } # Security headers add_header X-Frame-Options "SAMEORIGIN" always; 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'; img-src 'self' data:; font-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; }