SSRF Protection Implementation
This commit is contained in:
+21
-16
@@ -375,6 +375,13 @@ PERMISSION_ACTION_ENDPOINTS = {
|
|||||||
'logs': 'can_view_logs',
|
'logs': 'can_view_logs',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ALLOWED_COVER_DOMAINS = {
|
||||||
|
"books.google.com",
|
||||||
|
"covers.openlibrary.org",
|
||||||
|
"images-na.ssl-images-amazon.com",
|
||||||
|
"m.media-amazon.com"
|
||||||
|
}
|
||||||
|
|
||||||
# Apply the configuration for general use throughout the app
|
# Apply the configuration for general use throughout the app
|
||||||
APP_VERSION = __version__
|
APP_VERSION = __version__
|
||||||
RELEASE_STATE_FILE = os.path.join(os.path.dirname(BASE_DIR), '.release-version')
|
RELEASE_STATE_FILE = os.path.join(os.path.dirname(BASE_DIR), '.release-version')
|
||||||
@@ -9737,9 +9744,6 @@ def fetch_book_info(isbn):
|
|||||||
def download_book_cover():
|
def download_book_cover():
|
||||||
"""
|
"""
|
||||||
API endpoint to download and save a book cover image from URL
|
API endpoint to download and save a book cover image from URL
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Success status and filename or error message
|
|
||||||
"""
|
"""
|
||||||
if 'username' not in session:
|
if 'username' not in session:
|
||||||
return jsonify({"error": "Not authorized"}), 403
|
return jsonify({"error": "Not authorized"}), 403
|
||||||
@@ -9759,17 +9763,17 @@ def download_book_cover():
|
|||||||
if parsed_url.scheme != 'https' or not parsed_url.netloc:
|
if parsed_url.scheme != 'https' or not parsed_url.netloc:
|
||||||
return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400
|
return jsonify({"error": "Only public HTTPS URLs are allowed"}), 400
|
||||||
|
|
||||||
hostname = parsed_url.hostname or ''
|
# 2. SSRF Protection: Strict Allowlist Check
|
||||||
if not _is_public_host(hostname):
|
if parsed_url.netloc not in ALLOWED_COVER_DOMAINS:
|
||||||
return jsonify({"error": "Target host is not allowed"}), 400
|
return jsonify({"error": "Target host is not an allowed book cover provider"}), 403
|
||||||
|
|
||||||
# Download the image
|
# Download the image (allow_redirects=False prevents redirecting to internal IPs)
|
||||||
response = requests.get(image_url, stream=True, timeout=10, allow_redirects=False)
|
response = requests.get(image_url, stream=True, timeout=10, allow_redirects=False)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
return jsonify({"error": f"Failed to download image: Status {response.status_code}"}), 400
|
return jsonify({"error": f"Failed to download image: Status {response.status_code}"}), 400
|
||||||
|
|
||||||
# Check content type to ensure it's an image of allowed format
|
# Check content type
|
||||||
content_type = response.headers.get('content-type', '')
|
content_type = response.headers.get('content-type', '')
|
||||||
allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']
|
allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']
|
||||||
|
|
||||||
@@ -9778,6 +9782,7 @@ def download_book_cover():
|
|||||||
"error": f"Nicht unterstütztes Bildformat: {content_type}. Erlaubte Formate: JPG, JPEG, PNG, GIF"
|
"error": f"Nicht unterstütztes Bildformat: {content_type}. Erlaubte Formate: JPG, JPEG, PNG, GIF"
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
|
# Check content length header
|
||||||
content_length = response.headers.get('Content-Length')
|
content_length = response.headers.get('Content-Length')
|
||||||
if content_length:
|
if content_length:
|
||||||
try:
|
try:
|
||||||
@@ -9786,14 +9791,10 @@ def download_book_cover():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Generate a fully unique filename using UUID
|
# Generate a fully unique filename
|
||||||
import uuid
|
|
||||||
import time
|
|
||||||
|
|
||||||
unique_id = str(uuid.uuid4())
|
unique_id = str(uuid.uuid4())
|
||||||
timestamp = time.strftime("%Y%m%d%H%M%S")
|
timestamp = time.strftime("%Y%m%d%H%M%S")
|
||||||
|
|
||||||
# Use appropriate extension based on content type
|
|
||||||
extension = '.jpg' # default
|
extension = '.jpg' # default
|
||||||
if 'image/png' in content_type.lower():
|
if 'image/png' in content_type.lower():
|
||||||
extension = '.png'
|
extension = '.png'
|
||||||
@@ -9801,15 +9802,16 @@ def download_book_cover():
|
|||||||
extension = '.gif'
|
extension = '.gif'
|
||||||
|
|
||||||
filename = f"book_cover_{unique_id}_{timestamp}{extension}"
|
filename = f"book_cover_{unique_id}_{timestamp}{extension}"
|
||||||
|
|
||||||
# Save the image to uploads folder
|
|
||||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||||
|
|
||||||
|
# Save image in chunks (prevents memory exhaustion and enforces size limits)
|
||||||
with open(filepath, 'wb') as f:
|
with open(filepath, 'wb') as f:
|
||||||
written = 0
|
written = 0
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
written += len(chunk)
|
written += len(chunk)
|
||||||
if written > 5 * 1024 * 1024:
|
if written > 5 * 1024 * 1024:
|
||||||
|
# Clean up the partial file before aborting
|
||||||
|
os.remove(filepath)
|
||||||
return jsonify({"error": "Image is too large"}), 413
|
return jsonify({"error": "Image is too large"}), 413
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
@@ -9819,9 +9821,12 @@ def download_book_cover():
|
|||||||
"message": "Image downloaded successfully"
|
"message": "Image downloaded successfully"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"Network error downloading book cover: {e}")
|
||||||
|
return jsonify({"error": "Netzwerkfehler beim Herunterladen des Bildes."}), 500
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error downloading book cover: {e}")
|
print(f"Error downloading book cover: {e}")
|
||||||
|
# Fixed syntax here: Removed the injected HTML that was appended to this line
|
||||||
return jsonify({"error": f"Failed to download image: {str(e)}"}), 500
|
return jsonify({"error": f"Failed to download image: {str(e)}"}), 500
|
||||||
"""
|
"""
|
||||||
@app.route('/proxy_image')
|
@app.route('/proxy_image')
|
||||||
|
|||||||
Reference in New Issue
Block a user