Merge feature/pentest-remediation: harden infrastructure (7 pentest findings)

This commit is contained in:
Kyle 2026-02-25 20:15:34 +08:00
commit 22d8d5414f
7 changed files with 96 additions and 17 deletions

View File

@ -15,8 +15,12 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY . . COPY . .
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
# Expose port # Expose port
EXPOSE 8000 EXPOSE 8000
# Run migrations and start server # 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"] 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 name shown in authenticator apps
TOTP_ISSUER: str = "UMBRA" TOTP_ISSUER: str = "UMBRA"
# CORS allowed origins (comma-separated)
CORS_ORIGINS: str = "http://localhost:5173"
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",

View File

@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings
from app.database import engine 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 auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
from app.routers import totp from app.routers import totp
@ -32,20 +33,25 @@ async def lifespan(app: FastAPI):
await engine.dispose() await engine.dispose()
_is_dev = settings.ENVIRONMENT == "development"
app = FastAPI( app = FastAPI(
title="UMBRA API", title="UMBRA API",
description="Backend API for UMBRA application", description="Backend API for UMBRA application",
version="1.0.0", 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173"], allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["Content-Type", "Authorization", "Cookie", "X-Requested-With"],
) )
# Include routers with /api prefix # Include routers with /api prefix

View File

@ -409,14 +409,22 @@ async def verify_password(
@router.post("/change-password") @router.post("/change-password")
async def change_password( async def change_password(
data: ChangePasswordRequest, data: ChangePasswordRequest,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Change the current user's password. Requires old password verification.""" """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) valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
if not valid: if not valid:
_record_ip_failure(client_ip)
await _record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid current password") raise HTTPException(status_code=401, detail="Invalid current password")
_failed_attempts.pop(client_ip, None)
current_user.password_hash = hash_password(data.new_password) current_user.password_hash = hash_password(data.new_password)
await db.commit() await db.commit()

View File

@ -14,8 +14,6 @@ services:
backend: backend:
build: ./backend build: ./backend
restart: unless-stopped restart: unless-stopped
ports:
- "8000:8000"
env_file: .env env_file: .env
depends_on: depends_on:
db: db:
@ -30,7 +28,7 @@ services:
build: ./frontend build: ./frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "80:8080"
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy

View File

@ -15,8 +15,8 @@ COPY . .
# Build the application # Build the application
RUN npm run build RUN npm run build
# Production stage # Production stage — unprivileged nginx (runs as non-root, listens on 8080)
FROM nginx:alpine FROM nginxinc/nginx-unprivileged:alpine
# Copy built files from build stage # Copy built files from build stage
COPY --from=build /app/dist /usr/share/nginx/html 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 configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80 # Expose port 8080 (unprivileged)
EXPOSE 80 EXPOSE 8080
# Start nginx # Start nginx
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,15 +1,75 @@
# Rate limiting zones (before server block)
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
server { server {
listen 80; listen 8080;
server_name localhost; server_name localhost;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Suppress nginx version in Server header
server_tokens off;
# 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)
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 # API proxy
location /api { location /api {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
@ -35,12 +95,12 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
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'; 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 # Security headers
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
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'; 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;
} }