feat(hardening): login rate limiting, per-service memory limits, pinned images

- slowapi limiter on POST /auth/login (10/min per IP, Persian error msg);
  edge-level coarse limits stay in Traefik
- mem_limit on every main-compose service (~4.4GB worst case, protects the
  8GB shared host during rebuilds)
- monitoring images pinned to verified tags (prom v3.4.1, grafana 11.6.3,
  loki/promtail 3.5.0, node-exporter v1.9.1, postgres-exporter v0.17.1,
  cadvisor v0.49.1); exporter image name fixed to postgres-exporter
This commit is contained in:
MMD
2026-09-18 22:27:59 +03:30
parent e5d6c77c3c
commit 32323565e6
6 changed files with 40 additions and 10 deletions
+7
View File
@@ -16,6 +16,8 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from prometheus_fastapi_instrumentator import Instrumentator from prometheus_fastapi_instrumentator import Instrumentator
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from sqlalchemy import text as sql_text from sqlalchemy import text as sql_text
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -34,6 +36,7 @@ from app.observability import RequestContextMiddleware, setup_logging
from app.routers import ROUTERS from app.routers import ROUTERS
from app.services.quote_service import auto_expire_quotes from app.services.quote_service import auto_expire_quotes
from app.utils.errors import AppError from app.utils.errors import AppError
from app.utils.ratelimit import limiter
setup_logging(settings.LOG_FORMAT) setup_logging(settings.LOG_FORMAT)
logger = logging.getLogger("procurement") logger = logging.getLogger("procurement")
@@ -131,6 +134,10 @@ app.add_middleware(
) )
app.add_middleware(RequestContextMiddleware) app.add_middleware(RequestContextMiddleware)
# Rate limiting — login brute-force brake (edge rate limit lives in Traefik).
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Prometheus /metrics (requests, latency, errors) — mounted at app level, # Prometheus /metrics (requests, latency, errors) — mounted at app level,
# outside the authenticated /api/v1 prefix; scrape target for the # outside the authenticated /api/v1 prefix; scrape target for the
# monitoring stack. # monitoring stack.
+4 -2
View File
@@ -13,7 +13,7 @@ Error mapping (documented in docs/casdoor-auth.md):
import logging import logging
import httpx import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -23,6 +23,7 @@ from app.db.engine import get_db
from app.routers.users import _user_projects, user_to_dict from app.routers.users import _user_projects, user_to_dict
from app.services import analytics from app.services import analytics
from app.utils.errors import ServiceUnavailableError, UnauthorizedError from app.utils.errors import ServiceUnavailableError, UnauthorizedError
from app.utils.ratelimit import limiter
logger = logging.getLogger("procurement.auth") logger = logging.getLogger("procurement.auth")
@@ -35,7 +36,8 @@ class LoginRequest(BaseModel):
@router.post("/login") @router.post("/login")
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)): @limiter.limit("10/minute", error_message="تلاش‌های ورود بیش از حد مجاز — کمی بعد دوباره امتحان کنید")
async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Exchange username/password for a Casdoor JWT (OAuth password grant).""" """Exchange username/password for a Casdoor JWT (OAuth password grant)."""
url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token" url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token"
form = { form = {
+11
View File
@@ -0,0 +1,11 @@
"""Shared rate limiter (slowapi) — in-memory, single-worker friendly.
Login brute-force brake: the edge (Traefik) has a coarse per-IP limit for
the whole API host; this adds a tight limit on the credential endpoint
itself. Kept in its own module so routers can import it without a circular
import on app.main.
"""
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+3
View File
@@ -17,3 +17,6 @@ bcrypt>=4.1.0
# Observability — metrics + Sentry/GlitchTip exception capture # Observability — metrics + Sentry/GlitchTip exception capture
prometheus-fastapi-instrumentator>=7.0.0 prometheus-fastapi-instrumentator>=7.0.0
sentry-sdk[fastapi]>=2.0.0 sentry-sdk[fastapi]>=2.0.0
# Rate limiting (login brute-force brake; edge limit lives in Traefik)
slowapi>=0.1.9
+7
View File
@@ -29,6 +29,7 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
mem_limit: 1g
# ========================================================================== # ==========================================================================
# Traefik v3 — public reverse proxy (replaces nginx as the edge) # Traefik v3 — public reverse proxy (replaces nginx as the edge)
@@ -42,6 +43,7 @@ services:
image: traefik:v3.6 image: traefik:v3.6
container_name: procurement-traefik container_name: procurement-traefik
restart: unless-stopped restart: unless-stopped
mem_limit: 128m
command: command:
- --providers.docker=true - --providers.docker=true
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
@@ -103,6 +105,7 @@ services:
- default - default
restart: unless-stopped restart: unless-stopped
mem_limit: 512m
# MinIO Client (mc): waits for the server, creates the bucket, exits. # MinIO Client (mc): waits for the server, creates the bucket, exits.
# Use for ad-hoc ops too: docker compose run --rm minio-init ... # Use for ad-hoc ops too: docker compose run --rm minio-init ...
@@ -163,6 +166,7 @@ services:
done done
" "
restart: unless-stopped restart: unless-stopped
mem_limit: 256m
# ========================================================================== # ==========================================================================
# v2.6 — Casdoor (OIDC identity provider) # v2.6 — Casdoor (OIDC identity provider)
@@ -201,6 +205,7 @@ services:
- default - default
restart: unless-stopped restart: unless-stopped
mem_limit: 768m
api: api:
build: build:
@@ -272,6 +277,7 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
restart: unless-stopped restart: unless-stopped
mem_limit: 768m
frontend: frontend:
build: build:
@@ -300,6 +306,7 @@ services:
- api - api
restart: unless-stopped restart: unless-stopped
mem_limit: 128m
networks: networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی # شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
+8 -8
View File
@@ -19,7 +19,7 @@
services: services:
# ---------------------------------------------------------------- errors -- # ---------------------------------------------------------------- errors --
glitchtip: glitchtip:
image: glitchtip/glitchtip:latest image: glitchtip/glitchtip:latest # pinned upstream: version tags are dated, keep an eye on updates
container_name: monitoring-glitchtip container_name: monitoring-glitchtip
mem_limit: 640m mem_limit: 640m
environment: environment:
@@ -70,7 +70,7 @@ services:
# --------------------------------------------------------------- metrics -- # --------------------------------------------------------------- metrics --
prometheus: prometheus:
image: prom/prometheus:latest image: prom/prometheus:v3.4.1
container_name: monitoring-prometheus container_name: monitoring-prometheus
mem_limit: 512m mem_limit: 512m
command: command:
@@ -85,7 +85,7 @@ services:
restart: unless-stopped restart: unless-stopped
grafana: grafana:
image: grafana/grafana:latest image: grafana/grafana:11.6.3
container_name: monitoring-grafana container_name: monitoring-grafana
mem_limit: 256m mem_limit: 256m
environment: environment:
@@ -105,7 +105,7 @@ services:
# ------------------------------------------------------------------ logs -- # ------------------------------------------------------------------ logs --
loki: loki:
image: grafana/loki:latest image: grafana/loki:3.5.0
container_name: monitoring-loki container_name: monitoring-loki
mem_limit: 384m mem_limit: 384m
command: -config.file=/etc/loki/loki-config.yml command: -config.file=/etc/loki/loki-config.yml
@@ -117,7 +117,7 @@ services:
restart: unless-stopped restart: unless-stopped
promtail: promtail:
image: grafana/promtail:latest image: grafana/promtail:3.5.0
container_name: monitoring-promtail container_name: monitoring-promtail
mem_limit: 64m mem_limit: 64m
command: -config.file=/etc/promtail/promtail-config.yml command: -config.file=/etc/promtail/promtail-config.yml
@@ -131,7 +131,7 @@ services:
# -------------------------------------------------------------- exporters -- # -------------------------------------------------------------- exporters --
node-exporter: node-exporter:
image: prom/node-exporter:latest image: prom/node-exporter:v1.9.1
container_name: monitoring-node-exporter container_name: monitoring-node-exporter
mem_limit: 32m mem_limit: 32m
command: command:
@@ -144,7 +144,7 @@ services:
restart: unless-stopped restart: unless-stopped
postgres-exporter: postgres-exporter:
image: prometheuscommunity/postgres-postgres-exporter:latest image: prometheuscommunity/postgres-exporter:v0.17.1
container_name: monitoring-postgres-exporter container_name: monitoring-postgres-exporter
mem_limit: 48m mem_limit: 48m
environment: environment:
@@ -156,7 +156,7 @@ services:
restart: unless-stopped restart: unless-stopped
cadvisor: cadvisor:
image: gcr.io/cadvisor/cadvisor:latest image: gcr.io/cadvisor/cadvisor:v0.49.1
container_name: monitoring-cadvisor container_name: monitoring-cadvisor
mem_limit: 128m mem_limit: 128m
privileged: true privileged: true