421 lines
18 KiB
Python
421 lines
18 KiB
Python
from socket import *
|
|
import os, sys, random
|
|
import math, datetime
|
|
import re, magic
|
|
import html
|
|
import urllib
|
|
import urllib.parse
|
|
import userManagement
|
|
import time
|
|
import threading
|
|
import calendar
|
|
import subprocess
|
|
|
|
running = True
|
|
|
|
ALLOWED_METHODS = {"GET", "POST", "HEAD", "PUT"}
|
|
|
|
script_directory = os.path.dirname(os.path.abspath(sys.argv[0]))
|
|
webroot_real = os.path.realpath(script_directory + "/webroot")
|
|
|
|
trying = True
|
|
while trying:
|
|
try:
|
|
serverSocket = socket(AF_INET, SOCK_STREAM)
|
|
serverSocket.bind(('', 8123))
|
|
serverSocket.listen(128)
|
|
trying = False
|
|
except Exception as e:
|
|
print("Waiting for socket to be free...")
|
|
print(e)
|
|
time.sleep(1)
|
|
|
|
recentConnections = []
|
|
recentConnectionsExtended = []
|
|
recentConnections_lock = threading.Lock()
|
|
|
|
|
|
def get_last_modified(path: str) -> str:
|
|
mtime = os.path.getmtime(path)
|
|
return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(mtime))
|
|
|
|
def is_newer_than(path: str, date_str: str) -> bool:
|
|
mtime = os.path.getmtime(path)
|
|
parsed = time.strptime(date_str, "%a, %d %b %Y %H:%M:%S GMT")
|
|
return mtime > calendar.timegm(parsed)
|
|
|
|
def recv_full(sock, timeout=5):
|
|
"""Receive a full HTTP request, not just the first 1024 bytes.
|
|
Reads until the end of headers, then reads Content-Length more bytes
|
|
for the body if present.
|
|
|
|
Returns (header_text, body_bytes). Headers are decoded as text (safe,
|
|
always ASCII/UTF-8 in practice); the body is returned as raw bytes so
|
|
binary uploads (images, gzip, etc.) aren't corrupted by a lossy text
|
|
decode. Callers that want the body as text can decode it themselves."""
|
|
sock.settimeout(timeout)
|
|
data = b""
|
|
try:
|
|
while b"\r\n\r\n" not in data and b"\n\n" not in data:
|
|
chunk = sock.recv(4096)
|
|
if not chunk:
|
|
break
|
|
data += chunk
|
|
# Split off headers to check Content-Length
|
|
if b"\r\n\r\n" in data:
|
|
header_bytes, body_start = data.split(b"\r\n\r\n", 1)
|
|
elif b"\n\n" in data:
|
|
header_bytes, body_start = data.split(b"\n\n", 1)
|
|
else:
|
|
return data.decode(errors="replace"), b""
|
|
|
|
# Check for Content-Length and read remaining body bytes
|
|
content_length = 0
|
|
for line in header_bytes.decode(errors="replace").splitlines():
|
|
if line.lower().startswith("content-length:"):
|
|
try:
|
|
content_length = int(line.split(":", 1)[1].strip())
|
|
except ValueError:
|
|
pass
|
|
break
|
|
|
|
# bytearray, not bytes: bytes += in a loop recopies the whole
|
|
# buffer on every chunk (quadratic time/memory as it grows).
|
|
# bytearray.extend() appends in place, so this stays linear even
|
|
# for multi-GB bodies.
|
|
body_so_far = bytearray(body_start)
|
|
while len(body_so_far) < content_length:
|
|
chunk = sock.recv(65536)
|
|
if not chunk:
|
|
break
|
|
body_so_far.extend(chunk)
|
|
|
|
# Trim to Content-Length: guards against over-reads and, when no
|
|
# Content-Length was sent at all (content_length stays 0), drops
|
|
# any stray bytes so a bodyless request doesn't pick up leftovers.
|
|
body_so_far = bytes(body_so_far[:content_length])
|
|
|
|
return header_bytes.decode(errors="replace"), body_so_far
|
|
except TimeoutError:
|
|
return data.decode(errors="replace"), b""
|
|
|
|
blocklist_lock = threading.Lock()
|
|
|
|
def addtoblocklist(IP):
|
|
with blocklist_lock:
|
|
with open("/etc/nginx/blocklist.txt", "a") as blocklistfile:
|
|
blocklistfile.write(IP + " 1;\n")
|
|
subprocess.run(["sudo","systemctl","reload","nginx"])
|
|
|
|
blocked_agents_lock = threading.Lock()
|
|
|
|
def load_blocked_agents(path: str) -> set:
|
|
try:
|
|
with open(path) as f:
|
|
return {line.strip().lower() for line in f if line.strip() and not line.startswith("#")}
|
|
except FileNotFoundError:
|
|
return set()
|
|
|
|
blocked_agents_file = script_directory + "/blocked_agents.txt"
|
|
blocked_agents = load_blocked_agents(blocked_agents_file)
|
|
|
|
def handle_connection(connectionSocket, addr):
|
|
global recentConnections
|
|
global recentConnectionsExtended
|
|
|
|
sourceIP, sourcePort = addr
|
|
|
|
responseType = "200 OK"
|
|
responseHeaders = b""
|
|
responsecontent = b""
|
|
fullRequestPath = ""
|
|
contentType = ""
|
|
|
|
header_text, requestBody = recv_full(connectionSocket)
|
|
connectionSocket.settimeout(None)
|
|
try:
|
|
# requestBody is raw bytes (unmodified body, e.g. for uploads).
|
|
# `messages` below is built only from headers, never the body, so
|
|
# it stays text-safe even when the body isn't valid UTF-8.
|
|
messages = header_text.replace('\r\n', '\n').split('\n')
|
|
|
|
requestheaders = []
|
|
for i in range(len(messages)):
|
|
if i > 0:
|
|
if len(messages[i]) == 0:
|
|
break
|
|
requestheaders.append(messages[i])
|
|
ClientTargetHost = ''
|
|
ClientUserAgent = ''
|
|
ClientContentLength = ''
|
|
ClientAcceptTypes = ''
|
|
ClientAcceptLanguage = ''
|
|
ClientAcceptEncoding = ''
|
|
ClientConnectionType = ''
|
|
ClientUseHTTPS = ''
|
|
ClientSecFetchDest = ''
|
|
ClientSecFetchMode = ''
|
|
ClientSecFetchSite = ''
|
|
ClientSecFetchUser = ''
|
|
XForwardedFor = ''
|
|
IfModifiedSince = ''
|
|
Cookies = {}
|
|
|
|
for i in requestheaders:
|
|
if i.startswith("Host: "):
|
|
ClientTargetHost = i[6:]
|
|
elif i.startswith("User-Agent: "):
|
|
ClientUserAgent = i[12:]
|
|
elif i.startswith("Content-Length: "):
|
|
ClientContentLength = i[16:]
|
|
elif i.startswith("Accept: "):
|
|
ClientAcceptTypes = i[8:]
|
|
elif i.startswith("Accept-Language: "):
|
|
ClientAcceptLanguage = i[17:]
|
|
elif i.startswith("Accept-Encoding: "):
|
|
ClientAcceptEncoding = i[17:]
|
|
elif i.startswith("Connection: "):
|
|
ClientConnectionType = i[12:]
|
|
elif i.startswith("Upgrade-Insecure-Requests: "):
|
|
ClientUseHTTPS = i[27:]
|
|
elif i.startswith("Sec-Fetch-Dest: "):
|
|
ClientSecFetchDest = i[16:]
|
|
elif i.startswith("Sec-Fetch-Mode: "):
|
|
ClientSecFetchMode = i[16:]
|
|
elif i.startswith("Sec-Fetch-Site: "):
|
|
ClientSecFetchSite = i[16:]
|
|
elif i.startswith("Sec-Fetch-User: "):
|
|
ClientSecFetchUser = i[16:]
|
|
elif i.startswith("X-Forwarded-For: "):
|
|
XForwardedFor = i[17:]
|
|
elif i.startswith("If-Modified-Since: "):
|
|
IfModifiedSince = i[19:]
|
|
elif i.startswith("Cookie: "):
|
|
# Parse cookie header correctly into a flat dict
|
|
cookie_str = i[8:]
|
|
for pair in cookie_str.split("; "):
|
|
k = pair.split("=", 1)
|
|
if len(k) == 2:
|
|
Cookies[k[0].strip()] = k[1].strip()
|
|
|
|
if XForwardedFor == '':
|
|
sourceIPvisual = sourceIP
|
|
else:
|
|
sourceIPvisual = XForwardedFor.strip()
|
|
|
|
print(sourceIPvisual + ":" + str(sourcePort) + " [" + ClientUserAgent + "] requested \"" + messages[0] + "\"")
|
|
if any(sub in messages[0] for sub in [".env","php",".git","cgi","SDK/webLanguage"]):
|
|
addtoblocklist(sourceIPvisual)
|
|
return
|
|
|
|
now = datetime.datetime.now()
|
|
with recentConnections_lock:
|
|
recentConnections.append([sourceIPvisual, now])
|
|
recentConnectionsExtended.append([sourceIPvisual, now])
|
|
recentConnections = [
|
|
entry for entry in recentConnections
|
|
if now <= entry[1] + datetime.timedelta(seconds=60)
|
|
]
|
|
recentConnectionsExtended = [
|
|
entry for entry in recentConnectionsExtended
|
|
if now <= entry[1] + datetime.timedelta(seconds=200)
|
|
]
|
|
rateLimitCount = {}
|
|
rateLimitCountExtended = {}
|
|
for entry in recentConnections:
|
|
rateLimitCount[entry[0]] = rateLimitCount.get(entry[0], 0) + 1
|
|
for entry in recentConnectionsExtended:
|
|
rateLimitCountExtended[entry[0]] = rateLimitCountExtended.get(entry[0], 0) + 1
|
|
|
|
print(rateLimitCount)
|
|
|
|
if rateLimitCountExtended[sourceIPvisual] >= 200:
|
|
addtoblocklist(sourceIPvisual)
|
|
return
|
|
|
|
elif rateLimitCount[sourceIPvisual] >= 100:
|
|
responseType = "429 Too Many Requests (please stop)"
|
|
responsecontent = b"429 Too Many Requests.\nPlease stop.\n"
|
|
responseHeaders += b"Retry-After: 9999999999999999999 \n"
|
|
|
|
elif rateLimitCount[sourceIPvisual] >= 50:
|
|
responseType = "429 Too Many Requests"
|
|
responsecontent = open(script_directory + "/webroot/429.html", "rb").read()
|
|
fullRequestPath = script_directory + "/webroot/429.html"
|
|
contentType = "text/html"
|
|
responseHeaders += b"Retry-After: 60\n"
|
|
|
|
elif len(messages[0]) < 10:
|
|
responseType = "400 Bad Request"
|
|
responsecontent = open(script_directory + "/webroot/400.html", "rb").read()
|
|
fullRequestPath = script_directory + "/webroot/400.html"
|
|
contentType = "text/html"
|
|
|
|
else:
|
|
requestType = messages[0].split()[0]
|
|
|
|
# Validate HTTP method
|
|
if requestType not in ALLOWED_METHODS:
|
|
responseType = "405 Method Not Allowed"
|
|
responsecontent = b"<h1>405 Method Not Allowed</h1>"
|
|
fullRequestPath = ""
|
|
contentType = "text/html"
|
|
responseHeaders += b"Allow: GET, POST, HEAD, PUT\n"
|
|
else:
|
|
# URL-decode the path before sanitizing to catch encoded traversal sequences
|
|
raw_path = (messages[0].split()[1] + "?").split("?")[0]
|
|
decoded_path = urllib.parse.unquote(raw_path)
|
|
|
|
# Normalize and build the candidate path, then realpath-check it
|
|
candidate_path = os.path.realpath(script_directory + "/webroot" + decoded_path)
|
|
|
|
with blocked_agents_lock:
|
|
agent_blocked = any(agent in ClientUserAgent.lower() for agent in blocked_agents)
|
|
if agent_blocked and decoded_path != "/robots.txt":
|
|
responseType = "403 Forbidden"
|
|
responsecontent = b"<h1>403 Forbidden</h1>"
|
|
contentType = "text/html"
|
|
|
|
if not candidate_path.startswith(webroot_real + os.sep) and candidate_path != webroot_real:
|
|
responseType = "403 Forbidden"
|
|
responsecontent = b"<h1>403 Forbidden</h1>"
|
|
fullRequestPath = ""
|
|
contentType = "text/html"
|
|
else:
|
|
fullRequestPath = candidate_path
|
|
requestPath = decoded_path # exposed to exec'd scripts via script_env
|
|
|
|
argumentsRaw = (messages[0].split()[1] + "?").split("?")[1]
|
|
arguments = argumentsRaw.split("&")
|
|
argumentsDict = {}
|
|
for i in arguments:
|
|
j = i.split("=")
|
|
if len(j) == 2:
|
|
argumentsDict[j[0]] = j[1]
|
|
|
|
if responseType == "200 OK":
|
|
if requestType == "PUT":
|
|
# Raw uploads (e.g. `curl <server> -T localfile`) always route
|
|
# straight to the upload handler. The requested path is
|
|
# client-controlled (curl fills it in from the local filename)
|
|
# and generally won't correspond to a real file in webroot, so
|
|
# the normal exists()/extension routing below doesn't apply.
|
|
fullRequestPath = script_directory + "/webroot/upload/index.py"
|
|
with open(fullRequestPath) as script:
|
|
local_vars = locals()
|
|
exec(script.read(), globals(), local_vars)
|
|
responsecontent = local_vars.get("responsecontent", responsecontent)
|
|
responseType = local_vars.get("responseType", responseType)
|
|
responseHeaders = local_vars.get("responseHeaders", responseHeaders)
|
|
contentType = local_vars.get("contentType", contentType)
|
|
else:
|
|
if os.path.exists(fullRequestPath):
|
|
if os.path.isdir(fullRequestPath):
|
|
indexFiles = ["/index.html", "/index.py"]
|
|
for i in indexFiles:
|
|
if os.path.exists(fullRequestPath + i):
|
|
fullRequestPath += i
|
|
break
|
|
else:
|
|
fullRequestPath = script_directory + "/webroot/Scripts/listFiles.py"
|
|
if not os.path.exists(fullRequestPath):
|
|
responseType = "404 Not Found"
|
|
fullRequestPath = script_directory + "/webroot/404.html"
|
|
|
|
plainresponse = False
|
|
extensions = [ ".html", ".json", ".png", ".gif", ".ico", ".jpg", ".css", ".txt", ".ttf", ".mp4", ".gz", ".img"]
|
|
contentTypes = ["text/html", "text/json", "image/png", "image/gif", "image/x-icon", "image/jpeg", "text/css", "text/plain", "application/octet-stream", "video/mp4", "application/gzip", "application/octet-stream"]
|
|
for i in range(len(extensions)):
|
|
if fullRequestPath.endswith(extensions[i]):
|
|
plainresponse = True
|
|
contentType = contentTypes[i]
|
|
break
|
|
|
|
if plainresponse:
|
|
with open(fullRequestPath, "rb") as file:
|
|
responsecontent = file.read()
|
|
elif fullRequestPath.endswith(".py"):
|
|
with open(fullRequestPath) as script:
|
|
local_vars = locals()
|
|
exec(script.read(), globals(), local_vars)
|
|
responsecontent = local_vars.get("responsecontent", responsecontent)
|
|
responseType = local_vars.get("responseType", responseType)
|
|
responseHeaders = local_vars.get("responseHeaders", responseHeaders)
|
|
contentType = local_vars.get("contentType", contentType)
|
|
else:
|
|
responseType = "415 Unsupported Media Type"
|
|
contentType = ""
|
|
responsecontent = b""
|
|
|
|
except Exception as e:
|
|
print("error: ", e)
|
|
responseType = "500 Internal Server Error"
|
|
fullRequestPath = script_directory + "/webroot/500.html"
|
|
contentType = "text/html"
|
|
if os.path.exists(fullRequestPath):
|
|
with open(fullRequestPath, "rb") as file:
|
|
responsecontent = file.read()
|
|
else:
|
|
responsecontent = ("<h1 style=\"color: #a00;\">Error 500: Internal Server Error.</h1><br><p>Additionally, While handling that error, we encountered error 404. (page not found!)<br>Error: " + str(e) + "</p>").encode()
|
|
|
|
print("Serving " + fullRequestPath + " to " + sourceIPvisual + ":" + str(sourcePort))
|
|
|
|
if contentType != "":
|
|
if contentType[:5] == "image":
|
|
responseHeaders += ("Last-Modified: " + get_last_modified(fullRequestPath) + "\n").encode()
|
|
print(IfModifiedSince)
|
|
if IfModifiedSince != '':
|
|
try:
|
|
if is_newer_than(fullRequestPath, IfModifiedSince):
|
|
pass
|
|
else:
|
|
responsecontent = b''
|
|
responseType = "304 Not Modified"
|
|
except Exception as e:
|
|
print("excepted: ")
|
|
print(e)
|
|
|
|
responseHeaders += ("Content-Length: " + str(len(responsecontent)) + '\n').encode()
|
|
responseHeaders += ("Content-Type: " + contentType + "\n").encode()
|
|
responseHeaders += b"Server: Xbox 360\n"
|
|
|
|
response = ("HTTP/1.1 " + responseType + "\n").encode()
|
|
response += responseHeaders
|
|
response += b"\n"
|
|
if (requestType != "HEAD"):
|
|
response += responsecontent
|
|
|
|
numchunks = math.ceil(len(response) / 1024) if len(response) > 0 else 1
|
|
responsechunks = []
|
|
for i in range(numchunks):
|
|
responsechunks.append(response[(1024 * i):(1024 * i + 1024)])
|
|
try:
|
|
transferStarted = datetime.datetime.now()
|
|
lastSentTime = datetime.datetime.now()
|
|
for i in range(len(responsechunks)):
|
|
connectionSocket.send(responsechunks[i])
|
|
if datetime.datetime.now() >= transferStarted + datetime.timedelta(seconds=10):
|
|
if datetime.datetime.now() >= lastSentTime + datetime.timedelta(seconds=0.5):
|
|
lastSentTime = datetime.datetime.now()
|
|
print("Sending chunk " + str(i + 1) + " of " + str(numchunks) + " to " + sourceIP + ":" + str(sourcePort) + " for file \"" + fullRequestPath + "\"")
|
|
except BrokenPipeError:
|
|
print("Broken Pipe!")
|
|
except ConnectionResetError:
|
|
print("Connection Reset!")
|
|
except TimeoutError:
|
|
print("Send timed out!")
|
|
print("Served " + fullRequestPath + " to " + sourceIPvisual + ":" + str(sourcePort))
|
|
|
|
connectionSocket.close()
|
|
|
|
|
|
while running:
|
|
print('Waiting for a connection...')
|
|
connectionSocket, addr = serverSocket.accept()
|
|
t = threading.Thread(target=handle_connection, args=(connectionSocket, addr))
|
|
t.daemon = True
|
|
t.start()
|
|
|
|
print("Server Stopped.")
|
|
serverSocket.close()
|