Harden infrastructure: address 7 pentest findings

- Remove external backend port 8000 exposure (VULN-01)
- Conditionally disable Swagger/ReDoc/OpenAPI in non-dev (VULN-01)
- Suppress nginx and uvicorn server version headers (VULN-07)
- Fix CSP to allow Google Fonts (fonts.googleapis.com/gstatic) (VULN-08)
- Add nginx rate limiting on auth endpoints (10r/m burst=5) (VULN-09)
- Block dotfile access (/.env, /.git) while preserving .well-known (VULN-10)
- Make CORS origins configurable, tighten methods/headers (VULN-11)
- Run both containers as non-root users (VULN-12)
- Add IP rate limit + account lockout to /change-password

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-25 19:57:29 +08:00
parent 17643d54ea
commit f372e7bd99
7 changed files with 83 additions and 17 deletions

View File

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

View File

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

View File

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

View File

@ -409,12 +409,19 @@ 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")
current_user.password_hash = hash_password(data.new_password)

View File

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

View File

@ -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;"]

View File

@ -1,15 +1,63 @@
# 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/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 +83,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;
}