refactoring-backend #3
98
Docs/Security.md
Normal file
98
Docs/Security.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Security — Guidelines and Implementation
|
||||
|
||||
Security considerations and implementation details for BanGUI.
|
||||
|
||||
---
|
||||
|
||||
## HTTP Security Headers
|
||||
|
||||
BanGUI implements defense-in-depth against client-side attacks by sending security-related HTTP response headers on all responses.
|
||||
|
||||
### Headers Implemented
|
||||
|
||||
| Header | Value | Purpose |
|
||||
|---|---|---|
|
||||
| `Content-Security-Policy` | `default-src 'self'` | Prevents XSS attacks by restricting script, style, font, image, and other resource origins to `self` only. Browsers refuse to load resources from other origins. |
|
||||
| `X-Frame-Options` | `DENY` | Prevents clickjacking attacks by forbidding the page from being embedded in `<iframe>` tags on any origin. |
|
||||
| `X-Content-Type-Options` | `nosniff` | Prevents MIME-type sniffing attacks by forcing browsers to respect the declared `Content-Type`. Blocks execution of misidentified scripts. |
|
||||
| `X-XSS-Protection` | `1; mode=block` | Enables browser XSS filters (legacy header for older browsers). Modern browsers prioritize CSP. |
|
||||
|
||||
### Implementation
|
||||
|
||||
**Backend:** The `SecurityHeadersMiddleware` in `backend/app/main.py` adds these headers to every HTTP response, including error responses and non-API routes.
|
||||
|
||||
```python
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
```
|
||||
|
||||
**Frontend:** The `<meta http-equiv="Content-Security-Policy" content="default-src 'self'" />` tag in `frontend/index.html` provides an additional defense layer in case the backend headers are ever stripped (e.g., by a proxy).
|
||||
|
||||
### CSP Policy Details
|
||||
|
||||
The current policy `default-src 'self'` means:
|
||||
|
||||
- **Allowed:** Inline scripts, stylesheets, fonts, images, and other resources from the same origin (`self`)
|
||||
- **Blocked:** Resources from external domains, inline event handlers, `eval()`, and `setTimeout(string)`
|
||||
|
||||
**Why no `'unsafe-inline'`?**
|
||||
- `'unsafe-inline'` defeats CSP's primary purpose (XSS prevention) by allowing arbitrarily-embedded scripts
|
||||
- All scripts and styles must be in separate files (never inline), which is best practice anyway
|
||||
- The frontend build system (Vite) automatically handles asset bundling and file separation
|
||||
|
||||
**If external CDN resources are needed:**
|
||||
1. Explicitly add the CDN origin to the CSP policy, e.g.: `default-src 'self' https://cdn.example.com`
|
||||
2. Document the CDN addition with a justification comment
|
||||
3. Ensure the CDN certificate chain is valid and trusted
|
||||
4. Consider using Subresource Integrity (SRI) to verify resource authenticity
|
||||
|
||||
### Verification
|
||||
|
||||
To verify headers are being sent correctly:
|
||||
|
||||
1. **Chrome DevTools:**
|
||||
- Open DevTools (F12)
|
||||
- Go to Network tab
|
||||
- Reload the page
|
||||
- Click on any request and open the Response Headers section
|
||||
- Look for `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, `X-XSS-Protection`
|
||||
|
||||
2. **Command line (curl):**
|
||||
```bash
|
||||
curl -I http://localhost:8000/
|
||||
curl -I http://localhost:5173/
|
||||
```
|
||||
|
||||
3. **Online tools:**
|
||||
- Use [securityheaders.com](https://securityheaders.com) or [csp-evaluator.withgoogle.com](https://csp-evaluator.withgoogle.com)
|
||||
|
||||
### Future Improvements
|
||||
|
||||
- **Stricter CSP:** If functionality allows, tighten to `default-src 'none'` and explicitly allow individual resources
|
||||
- **SRI (Subresource Integrity):** Add integrity attributes to external script/style tags to prevent tampering
|
||||
- **Preload headers:** Use `Link: <...>; rel=preload` to optimize critical resource delivery
|
||||
- **HSTS:** Consider adding `Strict-Transport-Security` for production deployments to force HTTPS
|
||||
|
||||
---
|
||||
|
||||
## Session Security
|
||||
|
||||
See `backend/app/middleware/csrf.py` and `backend/app/middleware/rate_limit.py` for CSRF protection and rate limiting.
|
||||
|
||||
---
|
||||
|
||||
## Password Security
|
||||
|
||||
- Passwords are hashed with SHA256 on the frontend before transmission
|
||||
- The backend never stores plain-text passwords
|
||||
- See `backend/app/services/auth.py` for authentication implementation
|
||||
|
||||
---
|
||||
|
||||
## Database Security
|
||||
|
||||
- The SQLite database contains no sensitive data (no passwords, API keys, or tokens stored)
|
||||
- Database queries use parameterized statements to prevent SQL injection
|
||||
- See `backend/app/repositories/` for data access patterns
|
||||
@@ -1,50 +1,3 @@
|
||||
## [CRITICAL] Global rate limiting missing
|
||||
|
||||
**Where found**
|
||||
|
||||
- `backend/app/routers/auth.py` — only `/api/auth/login` has rate limiting
|
||||
- All other routers have no rate limiting
|
||||
|
||||
**Why this is needed**
|
||||
|
||||
Without rate limiting, attackers can spam endpoints to cause CPU spike, database overload, or network bandwidth exhaustion.
|
||||
|
||||
**Goal**
|
||||
|
||||
Implement global per-IP rate limiting on all endpoints.
|
||||
|
||||
**What to do**
|
||||
|
||||
1. Add rate limiting middleware to `backend/app/main.py`:
|
||||
```python
|
||||
from slowapi import Limiter
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=["200 per minute"])
|
||||
app.state.limiter = limiter
|
||||
```
|
||||
|
||||
2. Apply to all routers with appropriate limits per endpoint
|
||||
3. Return proper HTTP 429 with `Retry-After` header
|
||||
4. Document limits in API docs
|
||||
|
||||
**Possible traps and issues**
|
||||
|
||||
- Limits set too low block legitimate users
|
||||
- Distributed deployments need shared limiter state (Redis-backed)
|
||||
- Different endpoints may need different limits
|
||||
- Trusted IPs should bypass limiting
|
||||
|
||||
**Docs changes needed**
|
||||
|
||||
- Add section in `Docs/Backend-Development.md` § Rate Limiting
|
||||
- Document default limits in deployment guide
|
||||
|
||||
**Doc references**
|
||||
|
||||
- `Docs/Backend-Development.md` (rate limiting)
|
||||
- `backend/app/main.py` (middleware setup)
|
||||
|
||||
---
|
||||
|
||||
## [CRITICAL] Missing security headers (CSP, X-Frame-Options, etc.)
|
||||
|
||||
**Where found**
|
||||
|
||||
@@ -649,6 +649,50 @@ _PREFIX_ALLOWED: frozenset[str] = frozenset(
|
||||
)
|
||||
|
||||
|
||||
# Security headers constants
|
||||
_CSP_POLICY: str = "default-src 'self'"
|
||||
_X_FRAME_OPTIONS: str = "DENY"
|
||||
_X_CONTENT_TYPE_OPTIONS: str = "nosniff"
|
||||
_X_XSS_PROTECTION: str = "1; mode=block"
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Add security-related HTTP response headers to prevent common attacks.
|
||||
|
||||
This middleware adds the following headers to every HTTP response:
|
||||
- Content-Security-Policy: Prevents XSS by restricting resource origins
|
||||
- X-Frame-Options: Prevents clickjacking by controlling iframe embedding
|
||||
- X-Content-Type-Options: Prevents MIME-sniffing attacks
|
||||
- X-XSS-Protection: Enables browser XSS protection (legacy header)
|
||||
|
||||
These headers implement defense-in-depth against client-side attacks
|
||||
by relying on browser security policies rather than server-side logic alone.
|
||||
"""
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[StarletteResponse]],
|
||||
) -> StarletteResponse:
|
||||
"""Intercept responses to inject security headers.
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request.
|
||||
call_next: The next middleware / router handler.
|
||||
|
||||
Returns:
|
||||
The response from the next middleware / router with security headers added.
|
||||
"""
|
||||
response: StarletteResponse = await call_next(request)
|
||||
|
||||
response.headers["Content-Security-Policy"] = _CSP_POLICY
|
||||
response.headers["X-Frame-Options"] = _X_FRAME_OPTIONS
|
||||
response.headers["X-Content-Type-Options"] = _X_CONTENT_TYPE_OPTIONS
|
||||
response.headers["X-XSS-Protection"] = _X_XSS_PROTECTION
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
||||
"""Redirect all API requests to ``/api/setup`` until setup is done.
|
||||
|
||||
@@ -783,12 +827,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
|
||||
# --- Middleware ---
|
||||
# Note: middleware is applied in reverse order of registration.
|
||||
# The setup-redirect must run *after* CSRF, so it is added last.
|
||||
# CSRF middleware protects cookie-authenticated state-mutating requests.
|
||||
# RateLimitMiddleware checks per-IP request limits and must run early.
|
||||
# SecurityHeadersMiddleware must run early but after CORS/CSRF so headers
|
||||
# are added to all responses including error responses.
|
||||
# CorrelationIdMiddleware must run first (added last) so correlation ID
|
||||
# is available to all downstream handlers and loggers.
|
||||
app.add_middleware(CorrelationIdMiddleware)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(SetupRedirectMiddleware)
|
||||
app.add_middleware(CsrfMiddleware)
|
||||
app.add_middleware(
|
||||
|
||||
110
backend/tests/test_security_headers_middleware.py
Normal file
110
backend/tests/test_security_headers_middleware.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Unit tests for security headers middleware."""
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def test_security_headers_middleware_adds_csp_header() -> None:
|
||||
"""Security headers middleware adds Content-Security-Policy header to responses."""
|
||||
settings = Settings(
|
||||
database_path="/tmp/test.db",
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
fail2ban_config_dir="/tmp/fail2ban",
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
|
||||
app = create_app(settings=settings)
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert "Content-Security-Policy" in response.headers
|
||||
assert response.headers["Content-Security-Policy"] == "default-src 'self'"
|
||||
|
||||
|
||||
def test_security_headers_middleware_adds_x_frame_options() -> None:
|
||||
"""Security headers middleware adds X-Frame-Options header to responses."""
|
||||
settings = Settings(
|
||||
database_path="/tmp/test.db",
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
fail2ban_config_dir="/tmp/fail2ban",
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
|
||||
app = create_app(settings=settings)
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert "X-Frame-Options" in response.headers
|
||||
assert response.headers["X-Frame-Options"] == "DENY"
|
||||
|
||||
|
||||
def test_security_headers_middleware_adds_x_content_type_options() -> None:
|
||||
"""Security headers middleware adds X-Content-Type-Options header to responses."""
|
||||
settings = Settings(
|
||||
database_path="/tmp/test.db",
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
fail2ban_config_dir="/tmp/fail2ban",
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
|
||||
app = create_app(settings=settings)
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert "X-Content-Type-Options" in response.headers
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
|
||||
def test_security_headers_middleware_adds_x_xss_protection() -> None:
|
||||
"""Security headers middleware adds X-XSS-Protection header to responses."""
|
||||
settings = Settings(
|
||||
database_path="/tmp/test.db",
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
fail2ban_config_dir="/tmp/fail2ban",
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
|
||||
app = create_app(settings=settings)
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert "X-XSS-Protection" in response.headers
|
||||
assert response.headers["X-XSS-Protection"] == "1; mode=block"
|
||||
|
||||
|
||||
def test_security_headers_on_all_response_types() -> None:
|
||||
"""Security headers are added to all response types (success and error)."""
|
||||
settings = Settings(
|
||||
database_path="/tmp/test.db",
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
fail2ban_config_dir="/tmp/fail2ban",
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
|
||||
app = create_app(settings=settings)
|
||||
client = TestClient(app)
|
||||
|
||||
# Test on successful response
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert "Content-Security-Policy" in response.headers
|
||||
assert "X-Frame-Options" in response.headers
|
||||
assert "X-Content-Type-Options" in response.headers
|
||||
assert "X-XSS-Protection" in response.headers
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'" />
|
||||
<meta name="description" content="BanGUI — fail2ban management interface." />
|
||||
<meta name="theme-color" content="#0F6CBD" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
|
||||
Reference in New Issue
Block a user