Skip to content

Commit 847e5eb

Browse files
committed
feat: add hashed token support, security headers, and HTTPS enforcement
- Migrate existing plaintext tokens to Argon2-hashed format - Implement token hashing and verification utilities using Argon2 - Update `.env.example` with security-focused configurations - Add `SecurityHeadersMiddleware` for enhanced HTTP security - Introduce `HTTPSRedirectMiddleware` to ensure HTTPS enforcement - Update paste service to support hashed tokens for edit and delete operations - Opportunistically hash legacy tokens during edit operations - Enhance Swagger and API endpoint security via content security policy (CSP)
1 parent 13bdc24 commit 847e5eb

File tree

9 files changed

+526
-10
lines changed

9 files changed

+526
-10
lines changed

backend/.env.example

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# DevBin Backend Environment Configuration
2+
3+
# Server Configuration
4+
APP_PORT=8000
5+
APP_HOST=0.0.0.0
6+
APP_WORKERS=1
7+
APP_RELOAD=false
8+
APP_DEBUG=false
9+
10+
# Database Configuration
11+
APP_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/devbin
12+
13+
# Security - HTTPS
14+
# Set to true to redirect HTTP to HTTPS
15+
# Only enable if your deployment terminates SSL directly
16+
# Keep false if using reverse proxy (nginx, traefik, caddy, etc.)
17+
APP_ENFORCE_HTTPS=false
18+
19+
# Security - CORS
20+
# For development (allows all origins):
21+
APP_CORS_DOMAINS=["*"]
22+
APP_ALLOW_CORS_WILDCARD=true
23+
24+
# For production (specify exact domains):
25+
# APP_CORS_DOMAINS=["https://devbin.example.com","https://app.devbin.example.com"]
26+
# APP_ALLOW_CORS_WILDCARD=false
27+
28+
# Trusted Hosts (for X-Forwarded-For header)
29+
APP_TRUSTED_HOSTS=["127.0.0.1"]
30+
31+
# Paste Configuration
32+
APP_MAX_CONTENT_LENGTH=10000
33+
APP_BASE_FOLDER_PATH=./files
34+
APP_MIN_STORAGE_MB=1024
35+
APP_KEEP_DELETED_PASTES_TIME_HOURS=336
36+
37+
# Caching
38+
APP_CACHE_SIZE_LIMIT=1000
39+
APP_CACHE_TTL=300
40+
41+
# Privacy Settings
42+
APP_SAVE_USER_AGENT=false
43+
APP_SAVE_IP_ADDRESS=false
44+
45+
# Optional: Rate limit bypass token for testing
46+
# APP_BYPASS_TOKEN=your_secret_bypass_token_here
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Hash existing tokens
2+
3+
Revision ID: 0ed6c1042110
4+
Revises: 08393764144d
5+
Create Date: 2025-12-19 20:39:17.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
from argon2 import PasswordHasher
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "0ed6c1042110"
17+
down_revision: Union[str, Sequence[str], None] = "08393764144d"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
# Configure Argon2 with same parameters as token_utils.py
22+
ph = PasswordHasher(
23+
time_cost=2,
24+
memory_cost=19456,
25+
parallelism=1,
26+
hash_len=32,
27+
salt_len=16,
28+
)
29+
30+
31+
def upgrade() -> None:
32+
"""Hash all existing plaintext tokens."""
33+
# Get database connection
34+
connection = op.get_bind()
35+
36+
# Fetch all pastes with tokens
37+
result = connection.execute(
38+
sa.text(
39+
"SELECT id, edit_token, delete_token FROM pastes WHERE edit_token IS NOT NULL OR delete_token IS NOT NULL"
40+
)
41+
)
42+
43+
pastes = result.fetchall()
44+
45+
print(f"Hashing tokens for {len(pastes)} pastes...")
46+
47+
for paste in pastes:
48+
paste_id, edit_token, delete_token = paste
49+
50+
# Hash tokens if they exist and are not already hashed
51+
new_edit_token = None
52+
new_delete_token = None
53+
54+
if edit_token and not edit_token.startswith("$argon2"):
55+
new_edit_token = ph.hash(edit_token)
56+
57+
if delete_token and not delete_token.startswith("$argon2"):
58+
new_delete_token = ph.hash(delete_token)
59+
60+
# Update if any token was hashed
61+
if new_edit_token or new_delete_token:
62+
update_stmt = "UPDATE pastes SET"
63+
params = {"paste_id": paste_id}
64+
updates = []
65+
66+
if new_edit_token:
67+
updates.append("edit_token = :edit_token")
68+
params["edit_token"] = new_edit_token
69+
70+
if new_delete_token:
71+
updates.append("delete_token = :delete_token")
72+
params["delete_token"] = new_delete_token
73+
74+
update_stmt += " " + ", ".join(updates) + " WHERE id = :paste_id"
75+
76+
connection.execute(sa.text(update_stmt), params)
77+
78+
print(f"Successfully hashed tokens for {len(pastes)} pastes")
79+
80+
81+
def downgrade() -> None:
82+
"""Cannot downgrade - hashed tokens cannot be reversed to plaintext."""
83+
print("WARNING: Cannot downgrade token hashing migration.")
84+
print("Hashed tokens cannot be converted back to plaintext.")
85+
print("If you need to downgrade, you must:")
86+
print(" 1. Restore from a backup taken before this migration")
87+
print(" 2. Or, accept that existing tokens will be invalidated")
88+
raise Exception("Irreversible migration - cannot downgrade token hashing")

backend/app/api/middlewares.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,101 @@ async def dispatch(self, request: Request, call_next):
5959
# Continue with the request
6060
response = await call_next(request)
6161
return response
62+
63+
64+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
65+
"""
66+
Adds security headers to all responses.
67+
68+
Headers added:
69+
- Strict-Transport-Security (HSTS) - Only on HTTPS
70+
- X-Content-Type-Options - Prevent MIME sniffing
71+
- X-Frame-Options - Prevent clickjacking
72+
- Content-Security-Policy - XSS protection (relaxed for /docs, /redoc)
73+
- X-XSS-Protection - Legacy XSS protection
74+
- Referrer-Policy - Control referrer information
75+
76+
Note: CSP is more permissive for Swagger/ReDoc endpoints to allow
77+
UI functionality while remaining strict for API endpoints.
78+
"""
79+
80+
async def dispatch(self, request: Request, call_next):
81+
response = await call_next(request)
82+
83+
# X-Content-Type-Options: Prevent MIME type sniffing
84+
response.headers["X-Content-Type-Options"] = "nosniff"
85+
86+
# X-Frame-Options: Prevent clickjacking
87+
response.headers["X-Frame-Options"] = "DENY"
88+
89+
# Content-Security-Policy: Different policies for docs vs API
90+
# Check if request is for Swagger/ReDoc documentation
91+
is_docs_endpoint = request.url.path in ["/docs", "/redoc", "/openapi.json"]
92+
93+
if is_docs_endpoint:
94+
# Permissive CSP for Swagger UI (needs to load scripts/styles)
95+
response.headers["Content-Security-Policy"] = (
96+
"default-src 'self'; "
97+
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
98+
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
99+
"img-src 'self' data: https://fastapi.tiangolo.com; "
100+
"font-src 'self' data:; "
101+
"frame-ancestors 'none'; "
102+
"base-uri 'self'"
103+
)
104+
else:
105+
# Strict CSP for API endpoints (no scripts/styles needed)
106+
response.headers["Content-Security-Policy"] = (
107+
"default-src 'none'; "
108+
"frame-ancestors 'none'; "
109+
"base-uri 'none'; "
110+
"form-action 'none'"
111+
)
112+
113+
# X-XSS-Protection: Legacy header for older browsers
114+
# Modern browsers rely on CSP, but this doesn't hurt
115+
response.headers["X-XSS-Protection"] = "1; mode=block"
116+
117+
# Referrer-Policy: Control referrer information leakage
118+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
119+
120+
# Strict-Transport-Security (HSTS): Only add on HTTPS
121+
# Check if request came over HTTPS
122+
# Note: In production behind proxy, check X-Forwarded-Proto header
123+
is_https = (
124+
request.url.scheme == "https"
125+
or request.headers.get("X-Forwarded-Proto") == "https"
126+
)
127+
128+
if is_https:
129+
# HSTS: Force HTTPS for 1 year, include subdomains
130+
response.headers["Strict-Transport-Security"] = (
131+
"max-age=31536000; includeSubDomains"
132+
)
133+
134+
return response
135+
136+
137+
class HTTPSRedirectMiddleware(BaseHTTPMiddleware):
138+
"""
139+
Redirects all HTTP requests to HTTPS.
140+
141+
Only active when ENFORCE_HTTPS config is True.
142+
Checks X-Forwarded-Proto header for proxy deployments.
143+
"""
144+
145+
async def dispatch(self, request: Request, call_next):
146+
# Check if request is over HTTPS
147+
is_https = (
148+
request.url.scheme == "https"
149+
or request.headers.get("X-Forwarded-Proto") == "https"
150+
)
151+
152+
if not is_https:
153+
# Redirect to HTTPS
154+
from starlette.responses import RedirectResponse
155+
156+
https_url = request.url.replace(scheme="https")
157+
return RedirectResponse(url=str(https_url), status_code=301)
158+
159+
return await call_next(request)

backend/app/config.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ class Config(BaseSettings):
3131

3232
CORS_DOMAINS: list[str] = Field(default=["*"], validation_alias="APP_CORS_DOMAINS")
3333

34+
ALLOW_CORS_WILDCARD: bool = Field(
35+
default=False,
36+
validation_alias="APP_ALLOW_CORS_WILDCARD",
37+
description="Allow wildcard (*) in CORS domains (disable in production)",
38+
)
39+
3440
SAVE_USER_AGENT: bool = Field(default=False, validation_alias="APP_SAVE_USER_AGENT")
3541
SAVE_IP_ADDRESS: bool = Field(default=False, validation_alias="APP_SAVE_IP_ADDRESS")
3642

@@ -58,6 +64,12 @@ class Config(BaseSettings):
5864
RELOAD: bool = Field(default=False, validation_alias="APP_RELOAD")
5965
DEBUG: bool = Field(default=False, validation_alias="APP_DEBUG")
6066

67+
ENFORCE_HTTPS: bool = Field(
68+
default=False,
69+
validation_alias="APP_ENFORCE_HTTPS",
70+
description="Enforce HTTPS by redirecting HTTP requests",
71+
)
72+
6173
model_config = {
6274
"env_file": ".env",
6375
"env_file_encoding": "utf-8",
@@ -93,6 +105,31 @@ def verify_trusted_hosts(cls, hosts: list[str]) -> list[str]:
93105
logging.info("Trusted hosts: %s", validated_hosts)
94106
return validated_hosts
95107

108+
@field_validator("CORS_DOMAINS", mode="after")
109+
def validate_cors_domains(cls, domains: list[str], info) -> list[str]:
110+
"""Validate CORS domains and warn/error on wildcard."""
111+
if "*" in domains:
112+
allow_wildcard = info.data.get("ALLOW_CORS_WILDCARD", False)
113+
114+
if not allow_wildcard:
115+
logging.error(
116+
"SECURITY WARNING: CORS wildcard (*) is NOT allowed. "
117+
"Set APP_ALLOW_CORS_WILDCARD=true to enable, "
118+
"or specify exact domains in APP_CORS_DOMAINS."
119+
)
120+
raise ValueError(
121+
"CORS wildcard (*) is disabled. "
122+
"Set APP_ALLOW_CORS_WILDCARD=true or use specific domains."
123+
)
124+
else:
125+
logging.warning(
126+
"SECURITY WARNING: CORS wildcard (*) allows ANY origin. "
127+
"Use only in development, NEVER in production!"
128+
)
129+
130+
logging.info("CORS domains: %s", domains)
131+
return domains
132+
96133

97134
config = Config()
98135

0 commit comments

Comments
 (0)