added LFI protection for thumbnails
This commit is contained in:
parent
c727ab3446
commit
45227be6bf
2 changed files with 60 additions and 8 deletions
|
|
@ -4,7 +4,6 @@ import functools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .DownloadQueue import DownloadQueue
|
from .DownloadQueue import DownloadQueue
|
||||||
|
|
@ -20,6 +19,7 @@ from .common import common
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from .encoder import Encoder
|
from .encoder import Encoder
|
||||||
from .Emitter import Emitter
|
from .Emitter import Emitter
|
||||||
|
from .Utils import validate_url
|
||||||
|
|
||||||
LOG = logging.getLogger('app')
|
LOG = logging.getLogger('app')
|
||||||
MIME = magic.Magic(mime=True)
|
MIME = magic.Magic(mime=True)
|
||||||
|
|
@ -527,6 +527,11 @@ class HttpAPI(common):
|
||||||
if not url:
|
if not url:
|
||||||
return web.json_response({"error": "URL is required."}, status=400)
|
return web.json_response({"error": "URL is required."}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_url(url)
|
||||||
|
except Exception as e:
|
||||||
|
return web.json_response({"error": str(e)}, status=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
opts = {
|
opts = {
|
||||||
'proxy': self.config.ytdl_options.get('proxy', None),
|
'proxy': self.config.ytdl_options.get('proxy', None),
|
||||||
|
|
@ -539,13 +544,13 @@ class HttpAPI(common):
|
||||||
async with httpx.AsyncClient(**opts) as client:
|
async with httpx.AsyncClient(**opts) as client:
|
||||||
LOG.info(f"Fetching thumbnail from '{url}'.")
|
LOG.info(f"Fetching thumbnail from '{url}'.")
|
||||||
response = await client.request(method='GET', url=url)
|
response = await client.request(method='GET', url=url)
|
||||||
return web.Response(body=response.content, headers={
|
return web.Response(body=response.content,
|
||||||
'Content-Type': response.headers.get('Content-Type'),
|
headers={'Content-Type': response.headers.get('Content-Type'),
|
||||||
'Pragma': 'public',
|
'Pragma': 'public', 'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Cache-Control': f"public, max-age={time.time() + 31536000}",
|
||||||
'Cache-Control': f"public, max-age={time.time() + 31536000}",
|
'Expires': time.strftime(
|
||||||
'Expires': time.strftime('%a, %d %b %Y %H:%M:%S GMT', datetime.fromtimestamp(time.time() + 31536000).timetuple()),
|
'%a, %d %b %Y %H:%M:%S GMT', datetime.fromtimestamp(
|
||||||
})
|
time.time() + 31536000).timetuple()), })
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'")
|
LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'")
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import copy
|
import copy
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from functools import lru_cache
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import uuid
|
import uuid
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
|
@ -359,3 +362,47 @@ def ag(array: dict | list, path: list[str | int] | str | int, default: Any = Non
|
||||||
return get_value(default)
|
return get_value(default)
|
||||||
|
|
||||||
return current
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=512)
|
||||||
|
def is_private_address(hostname: str) -> bool:
|
||||||
|
try:
|
||||||
|
ip = socket.gethostbyname(hostname)
|
||||||
|
ip_obj = ipaddress.ip_address(ip)
|
||||||
|
return (ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local)
|
||||||
|
except socket.gaierror:
|
||||||
|
# Could not resolve - treat as invalid or restricted
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url(url: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate if the url is valid and allowed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url (str): URL to validate.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the URL is invalid or not allowed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the URL is valid and allowed.
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
raise ValueError("URL is required.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from yarl import URL
|
||||||
|
parsed_url = URL(url)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("Invalid URL.")
|
||||||
|
|
||||||
|
# Check allowed schemes
|
||||||
|
if parsed_url.scheme not in ["http", "https"]:
|
||||||
|
raise ValueError("Invalid scheme usage. Only HTTP or HTTPS allowed.")
|
||||||
|
|
||||||
|
hostname = parsed_url.host
|
||||||
|
if not hostname or is_private_address(hostname):
|
||||||
|
raise ValueError("Access to internal urls or private networks is not allowed.")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue