Skip to content

Commit 2c8da1b

Browse files
Merge pull request #28 from codewithme-py/feat/security-fixes
feat: implement login/signup rate limiting, add background image sanitization tasks, and enhance order detail access auditing.
2 parents 455923d + 4b79b54 commit 2c8da1b

18 files changed

Lines changed: 352 additions & 36 deletions

File tree

.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ REFRESH_TOKEN_EXPIRE_DAYS=7
3434
RATE_LIMIT_USER_RPS=10
3535
RATE_LIMIT_GLOBAL_RPS=1000
3636
RATE_LIMIT_TTL_SECONDS=1
37+
LOGIN_RATE_LIMIT_ATTEMPTS=7
38+
LOGIN_RATE_LIMIT_TTL=60
39+
SIGNUP_RATE_LIMIT_ATTEMPTS=3
40+
SIGNUP_RATE_LIMIT_TTL=3600
3741
IDEMPOTENT_KEY_LIFETIME_SEC=86400
3842
RESERVE_TIMEOUT_MINUTES=15
3943
#db_engine_layer

app/core/admin/admin.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ def docs_link_formatter(model: Any, name: Any) -> Any:
8282
return 'No docs'
8383
links = []
8484
if isinstance(docs, dict):
85-
for doc_type, s3_key in docs.items():
85+
for doc_type in docs.keys():
8686
links.append(
87-
f'<a href="/api/v1/media/view?key={s3_key}" '
88-
f'target="_blank">{doc_type}</a>'
87+
f'<a href="/api/v1/media/view/verification_doc/{model.id}'
88+
f'?doc_key={doc_type}" target="_blank">{doc_type}</a>'
8989
)
9090
return Markup(', '.join(links))
9191

app/core/audit_log/service.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77

88
from app.core.audit_log.models import AuditLog
99

10+
SENSITIVE_FIELDS = {
11+
'shipping_address',
12+
'email',
13+
}
14+
1015

1116
class AuditLogService:
1217
async def log_event(
@@ -41,20 +46,24 @@ def get_diff(
4146
old_model: BaseModel | None,
4247
new_model: BaseModel | None,
4348
) -> dict[str, Any]:
49+
diff: dict[str, Any] = {}
4450
if old_model is None and new_model is not None:
4551
return {k: [None, v] for k, v in new_model.model_dump(mode='json').items()}
4652
if old_model is not None and new_model is None:
4753
return {k: [v, None] for k, v in old_model.model_dump(mode='json').items()}
4854
if old_model is not None and new_model is not None:
4955
old_data = old_model.model_dump(mode='json')
5056
new_data = new_model.model_dump(mode='json')
51-
diff = {}
5257
for key, value in new_data.items():
5358
old_val = old_data.get(key)
5459
if value != old_val:
55-
diff[key] = [old_val, value]
60+
if key in SENSITIVE_FIELDS:
61+
masked = '[SENSITIVE_DATA_HIDDEN]'
62+
diff[key] = [masked, masked]
63+
else:
64+
diff[key] = [old_val, value]
5665
return diff
57-
return {}
66+
return diff
5867

5968
async def log_object_change(
6069
self,
@@ -79,5 +88,22 @@ async def log_object_change(
7988
extra_data=extra_data,
8089
)
8190

91+
async def log_pii_access(
92+
self,
93+
session: AsyncSession,
94+
actor_id: UUID,
95+
target_id: UUID,
96+
target_type: str,
97+
reason: str | None = None,
98+
) -> None:
99+
await self.log_event(
100+
session=session,
101+
actor_id=actor_id,
102+
target_id=target_id,
103+
target_type=target_type,
104+
action='pii_access',
105+
changes={'pii_accessed': True, 'reason': reason},
106+
)
107+
82108

83109
audit_log_service = AuditLogService()

app/core/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ class Settings(BaseSettings):
2828
rate_limit_user_rps: int = Field(alias='RATE_LIMIT_USER_RPS')
2929
rate_limit_global_rps: int = Field(alias='RATE_LIMIT_GLOBAL_RPS')
3030
rate_limit_ttl_seconds: int = Field(alias='RATE_LIMIT_TTL_SECONDS')
31+
login_rate_limit_attempts: int = Field(alias='LOGIN_RATE_LIMIT_ATTEMPTS')
32+
login_rate_limit_ttl: int = Field(alias='LOGIN_RATE_LIMIT_TTL')
33+
signup_rate_limit_attempts: int = Field(alias='SIGNUP_RATE_LIMIT_ATTEMPTS')
34+
signup_rate_limit_ttl: int = Field(alias='SIGNUP_RATE_LIMIT_TTL')
3135
idempotent_key_lifetime_sec: int = Field(alias='IDEMPOTENT_KEY_LIFETIME_SEC')
3236
reserve_timeout_minutes: int = Field(alias='RESERVE_TIMEOUT_MINUTES')
3337
presigned_url_expire_seconds: int = Field(alias='PRESIGNED_URL_EXPIRE_SECONDS')

app/core/s3.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
from collections.abc import AsyncGenerator
3+
from contextlib import asynccontextmanager
34
from typing import Any
45

56
import aioboto3 # type: ignore
@@ -12,6 +13,7 @@
1213
session = aioboto3.Session()
1314

1415

16+
@asynccontextmanager
1517
async def get_s3_client() -> AsyncGenerator[Any, None]:
1618
async with session.client(
1719
's3',

app/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from contextlib import asynccontextmanager
44

55
import structlog
6+
from arq import create_pool
7+
from arq.connections import RedisSettings
68
from fastapi import FastAPI, Request, Response
79
from fastapi.responses import ORJSONResponse
810
from prometheus_fastapi_instrumentator import Instrumentator
@@ -36,11 +38,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
3638
client = Redis.from_url(settings.redis_url, decode_responses=True, encoding='utf-8')
3739
app.state.redis = client
3840
app.state.rate_limit_script = client.register_script(RATE_LIMIT_LUA_SCRIPT)
41+
app.state.arq_redis = await create_pool(RedisSettings.from_dsn(settings.redis_url))
3942
await init_s3_bucket()
4043
try:
4144
logger.info('redis connected')
45+
logger.info('arq pool created')
4246
yield
4347
finally:
48+
await app.state.arq_redis.close()
4449
await client.aclose()
4550
logger.info('redis disconnected')
4651

app/services/inventory/routes.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33

44
from fastapi import APIRouter, Depends, Header, Query, Request, status
55

6+
from app.core.config import settings
67
from app.core.database import SessionDep
78
from app.core.security import RoleChecker, UserRole
89
from app.services.inventory.models import ProductStatus
9-
from app.services.inventory.rate_limit import check_rate_limit
1010
from app.services.inventory.schemas import (
1111
ProductCreate,
1212
ProductRead,
@@ -18,6 +18,7 @@
1818
from app.services.user.models import User
1919
from app.shared.decorators import idempotent
2020
from app.shared.deps import get_current_user
21+
from app.shared.rate_limit import check_rate_limit
2122

2223
from .deps import (
2324
get_inventory_admin_service,
@@ -153,8 +154,11 @@ async def reservation_data(
153154
) -> ReservationResponse:
154155
await check_rate_limit(
155156
rate_limit_script=request.app.state.rate_limit_script,
156-
user_id=str(current_user.id),
157-
item_id=str(reservation_data.product_id),
157+
keys=[
158+
f'rate_limit:user:{current_user.id}',
159+
f'rate_limit:item:{reservation_data.product_id}',
160+
],
161+
limits=[settings.rate_limit_user_rps, settings.rate_limit_global_rps],
158162
)
159163
result = await service.reserve_items(
160164
session=session,

app/services/media/routes.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
from http import HTTPStatus
12
from typing import Any
23
from uuid import UUID
34

4-
from fastapi import APIRouter, Depends
5+
from fastapi import APIRouter, Depends, HTTPException, Request
56
from fastapi.responses import RedirectResponse
67
from sqlalchemy.ext.asyncio import AsyncSession
78

9+
from app.core.audit_log.service import audit_log_service
810
from app.core.database import get_session
911
from app.core.s3 import get_s3_client
1012
from app.services.media.schemas import (
@@ -15,6 +17,7 @@
1517
from app.services.media.service import (
1618
generate_presigned_get_url,
1719
generate_upload_url,
20+
get_secure_file_path,
1821
handle_minio_webhook,
1922
)
2023
from app.services.user.models import User, UserRole
@@ -36,23 +39,38 @@ async def create_upload_url(
3639

3740
@router_v1.post('/webhook/minio')
3841
async def minio_webhook(
42+
request: Request,
3943
event: MinioWebhookEvent,
4044
session: AsyncSession = Depends(get_session),
4145
) -> dict[str, str]:
42-
await handle_minio_webhook(session, event)
46+
await handle_minio_webhook(session, event, arq_redis=request.app.state.arq_redis)
4347
return {'status': 'ok'}
4448

4549

46-
@router_v1.get('/view', response_class=RedirectResponse)
50+
@router_v1.get('/view/{target_type}/{target_id}', response_class=RedirectResponse)
4751
async def view_private_file(
48-
key: str,
52+
target_type: str,
53+
target_id: UUID,
54+
doc_key: str | None = None,
55+
session: AsyncSession = Depends(get_session),
4956
s3_client: Any = Depends(get_s3_client),
5057
current_user: User = Depends(get_current_user),
5158
) -> RedirectResponse:
5259
if current_user.role not in (UserRole.ADMIN, UserRole.MODERATOR):
53-
from fastapi import HTTPException
54-
55-
raise HTTPException(status_code=403, detail='Not authorized to view this file')
56-
57-
url = await generate_presigned_get_url(s3_client, key)
58-
return RedirectResponse(url=url, status_code=307)
60+
raise HTTPException(
61+
status_code=HTTPStatus.FORBIDDEN, detail='Not authorized to view this file'
62+
)
63+
file_path = await get_secure_file_path(session, target_type, target_id, doc_key)
64+
if not file_path:
65+
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail='File not found')
66+
if target_type == 'verification_doc':
67+
await audit_log_service.log_pii_access(
68+
session=session,
69+
actor_id=current_user.id,
70+
target_id=target_id,
71+
target_type='verification_request',
72+
reason=f'viewing_doc_{doc_key}' if doc_key else 'viewing_verification_doc',
73+
)
74+
await session.commit()
75+
url = await generate_presigned_get_url(s3_client, file_path)
76+
return RedirectResponse(url=url, status_code=HTTPStatus.TEMPORARY_REDIRECT)

app/services/media/service.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
ImageUploadResponse,
1515
MinioWebhookEvent,
1616
)
17+
from app.services.user.models import VerificationRequest
1718

1819
logger = structlog.get_logger(__name__)
1920

@@ -34,6 +35,39 @@ async def generate_presigned_get_url(
3435
)
3536

3637

38+
async def get_secure_file_path(
39+
session: AsyncSession,
40+
target_type: str,
41+
target_id: UUID,
42+
doc_key: str | None = None,
43+
) -> str | None:
44+
"""Resolves a secure S3 path from a database resource."""
45+
if target_type == 'verification_doc':
46+
result_v = await session.execute(
47+
select(VerificationRequest).where(VerificationRequest.id == target_id)
48+
)
49+
v_req = result_v.scalar_one_or_none()
50+
if v_req and v_req.docs_url and doc_key:
51+
return str(v_req.docs_url.get(doc_key))
52+
elif target_type == 'product_image':
53+
result_i = await session.execute(
54+
select(ProductImage).where(ProductImage.id == target_id)
55+
)
56+
img = result_i.scalar_one_or_none()
57+
if img:
58+
return str(img.file_path)
59+
return None
60+
61+
62+
async def sanitize_image_metadata(
63+
s3_client: Any,
64+
bucket: str,
65+
key: str,
66+
) -> None:
67+
"""OBSOLETE: Moved to tasks.py"""
68+
pass
69+
70+
3771
async def generate_upload_url(
3872
session: AsyncSession,
3973
s3_client: Any,
@@ -74,6 +108,7 @@ async def generate_upload_url(
74108
async def handle_minio_webhook(
75109
session: AsyncSession,
76110
event: MinioWebhookEvent,
111+
arq_redis: Any = None,
77112
) -> None:
78113
if not event.records:
79114
return
@@ -88,7 +123,16 @@ async def handle_minio_webhook(
88123
)
89124
image = result.scalar_one_or_none()
90125
if image is not None and image.status == ImageStatus.PENDING:
91-
image.status = ImageStatus.ACTIVE
92-
await session.commit()
126+
# Point 3: Enqueue background sanitization task
127+
if arq_redis:
128+
await arq_redis.enqueue_job(
129+
'sanitize_and_activate_image_task',
130+
image_id=image.id,
131+
bucket=settings.minio_bucket_name,
132+
object_key=object_key,
133+
)
134+
logger.info('enqueued image sanitization task', key=object_key)
135+
else:
136+
logger.warning('arq_redis pool not provided to webhook handler')
93137
else:
94138
logger.warning('image not found or not in pending status')

app/services/media/tasks.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import io
2+
from uuid import UUID
3+
4+
from PIL import Image
5+
from sqlalchemy import select
6+
7+
from app.core.s3 import get_s3_client
8+
from app.services.media.models import ImageStatus, ProductImage
9+
10+
11+
async def sanitize_and_activate_image_task(
12+
ctx: dict,
13+
image_id: UUID,
14+
bucket: str,
15+
object_key: str,
16+
) -> None:
17+
"""Background task to strip EXIF and activate an image."""
18+
session_maker = ctx['session_maker']
19+
20+
# 1. Sanitize the image
21+
async with get_s3_client() as s3_client:
22+
response = await s3_client.get_object(Bucket=bucket, Key=object_key)
23+
image_data = await response['Body'].read()
24+
25+
with Image.open(io.BytesIO(image_data)) as img:
26+
output = io.BytesIO()
27+
img.save(output, format=img.format)
28+
output.seek(0)
29+
30+
await s3_client.put_object(
31+
Bucket=bucket,
32+
Key=object_key,
33+
Body=output,
34+
ContentType=response.get('ContentType', 'image/jpeg'),
35+
)
36+
37+
# 2. Update status in database
38+
async with session_maker() as session:
39+
result = await session.execute(
40+
select(ProductImage).where(ProductImage.id == image_id)
41+
)
42+
image = result.scalar_one_or_none()
43+
if image:
44+
image.status = ImageStatus.ACTIVE
45+
await session.commit()

0 commit comments

Comments
 (0)