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:
@@ -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.
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 روی شبکهی
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user