Security fixes: SSRF, staff role escalation, SPA-only gating, throttling, encrypted keys
Findings from the audit (50-check harness across guest/user/uploader/staff/ admin) and their fixes: - SSRF: 'Download to Library' and the staged-upload resolve path fetched any http(s) URL. services.validate_remote_url now enforces the e621 media allowlist and open_remote re-validates every redirect hop; the download-task create endpoint and the guest proxy use them, so internal addresses (127.0.0.1, LAN, metadata) are rejected with 400. - Privilege escalation: staff could promote users to staff and demote other staff. Role changes across the staff boundary now require an admin, matching the account-deletion rules; the Users page hides what the backend would refuse. - SPA-only gating: /api/storage/ and /api/duplicates/* were readable by any authenticated account (absolute paths, duplicate groups) while the SPA only shows them to uploaders. They now require CanUpload. - Throttling (REST_FRAMEWORK, env-overridable, counted in Redis): anon 120/min, user 600/min, login 5/min, register 20/hour, guest e621 proxy 60/hour. Login now goes through a throttled view. - e621 API keys are encrypted at rest with a Fernet key derived from SECRET_KEY (apps/accounts/crypto.py); a data migration encrypts existing rows and the column widens first. Reads decrypt transparently, legacy plaintext still works, and a changed SECRET_KEY reads as 'not configured' instead of leaking. Rotating SECRET_KEY now invalidates stored keys as well as signed media URLs. - Hardening: the server refuses to start with DEBUG=False while SECRET_KEY is still the development default. Verified: corrected harness 50/50 (guest visibility, IDOR, signed-URL tamper/expiry, staged-upload/similarity privacy, role matrix, SSRF), login throttles at the 6th attempt with 429, anon polling unaffected, the guest proxy still reaches allowlisted hosts, live e621 auth works with the decrypted key, and DB rows hold only ciphertext.
This commit is contained in:
@@ -2,14 +2,17 @@ import logging
|
||||
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.authtoken.views import ObtainAuthToken
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
from django.db.models import Count, Q
|
||||
|
||||
from apps.core.permissions import IsAppStaff
|
||||
from apps.library.models import MediaItem
|
||||
|
||||
from .crypto import encrypt_secret
|
||||
from .models import User
|
||||
from .serializers import (
|
||||
E621CredentialsSerializer,
|
||||
@@ -23,8 +26,17 @@ from .serializers import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginView(ObtainAuthToken):
|
||||
"""Token login, rate limited per IP to slow down credential stuffing."""
|
||||
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "login"
|
||||
|
||||
|
||||
class RegisterView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "register"
|
||||
|
||||
def post(self, request):
|
||||
serializer = RegisterSerializer(data=request.data)
|
||||
@@ -64,7 +76,7 @@ class E621CredentialsView(APIView):
|
||||
def _payload(self, user):
|
||||
return {
|
||||
"username": user.e621_username,
|
||||
"api_key": user.e621_api_key,
|
||||
"api_key": user.e621_api_key_plain,
|
||||
"base_url": user.e621_base_url,
|
||||
"configured": user.e621_configured,
|
||||
}
|
||||
@@ -78,7 +90,7 @@ class E621CredentialsView(APIView):
|
||||
data = serializer.validated_data
|
||||
user = request.user
|
||||
user.e621_username = data["username"].strip()
|
||||
user.e621_api_key = data["api_key"].strip()
|
||||
user.e621_api_key = encrypt_secret(data["api_key"].strip())
|
||||
user.e621_base_url = (
|
||||
(data.get("base_url") or "https://e621.net").strip().rstrip("/")
|
||||
)
|
||||
@@ -172,13 +184,28 @@ class UserViewSet(
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
user = self.get_object()
|
||||
actor = request.user
|
||||
serializer = UserUpdateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
update_fields = []
|
||||
if "role" in data:
|
||||
user.role = data["role"]
|
||||
new_role = data["role"]
|
||||
# Same rule as account deletion: only admins may move accounts
|
||||
# across the staff boundary (granting or revoking staff).
|
||||
if (
|
||||
not actor.is_superuser
|
||||
and new_role != user.role
|
||||
and (user.is_app_staff or new_role == User.ROLE_STAFF)
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"detail": "Only an admin can change staff roles.",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
user.role = new_role
|
||||
update_fields.append("role")
|
||||
if "avatar_j_id" in data:
|
||||
item, error = resolve_avatar_item(data.get("avatar_j_id"))
|
||||
|
||||
Reference in New Issue
Block a user