193 lines
7.6 KiB
Python
Executable File
193 lines
7.6 KiB
Python
Executable File
#!/usr/bin/python3
|
|
from flask import Flask, make_response, request, redirect
|
|
import os
|
|
import requests as r
|
|
app = Flask(__name__)
|
|
database_node_port = 5002
|
|
logic_node_port = 5003
|
|
database_node = "database:" + str(database_node_port)
|
|
|
|
IMG_MIN, IMG_MAX = 1, 16
|
|
INTERNAL = os.environ.get("RUSGRAM_INTERNAL", "e9b2f6d14a8c7035e1d0a6b8c4f2e7a9")
|
|
DBH = {"X-Internal": INTERNAL}
|
|
|
|
|
|
def bad_request(status="error", code=400):
|
|
return make_response({"status": status}, code)
|
|
|
|
|
|
def unauthorized():
|
|
"""PATCH V3/V6: раньше здесь был redirect(request.headers.get("Origin","") + "/login.html").
|
|
Origin контролирует атакующий, а proxy ходит по Location серверсайд =>
|
|
SSRF во внутреннюю сеть. Location теперь фиксированный и относительный."""
|
|
return redirect("/login.html", code=302)
|
|
|
|
|
|
def valid_username(username):
|
|
"""PATCH V1/V8 (defense in depth): запрещаем только то, что реально опасно.
|
|
Остальное (юникод, точки внутри имени) оставляем — чтобы не сломать чекер."""
|
|
if not username or len(username) > 64:
|
|
return False
|
|
if "||" in username: # ломает формат куки session=<login>||<password>
|
|
return False
|
|
if "/" in username or "\\" in username or username in (".", ".."):
|
|
return False # имя юзера = имя файла в db/users/
|
|
if username.lower().endswith((".html", ".htm")):
|
|
return False # имя, которое content мог бы отрендерить как шаблон
|
|
if "{{" in username or "{%" in username or "{#" in username:
|
|
return False
|
|
if any(ord(c) < 0x20 for c in username): # control chars / newlines
|
|
return False
|
|
return True
|
|
|
|
|
|
def clean_value(value):
|
|
"""Не даём записать Jinja-конструкции в поля, которые когда-то рендерились как шаблон."""
|
|
return isinstance(value, str) and "{{" not in value and "{%" not in value and "{#" not in value
|
|
|
|
|
|
def current_user():
|
|
"""Возвращает имя авторизованного юзера или None. Не бросает исключений."""
|
|
session = request.cookies.get("session")
|
|
if session is None: # PATCH V8: раньше KeyError -> 500
|
|
return None
|
|
parts = session.split("||")
|
|
if len(parts) != 2: # PATCH V8: раньше ValueError -> 500
|
|
return None
|
|
username, password = parts
|
|
if not username:
|
|
return None
|
|
try:
|
|
resp = r.get(f"http://{database_node}/users/{username}/password",
|
|
headers=DBH, timeout=5)
|
|
if resp.status_code != 200: # PATCH V8: несуществующий юзер -> KeyError -> 500
|
|
return None
|
|
if resp.json().get("data") != password:
|
|
return None
|
|
except (r.RequestException, ValueError):
|
|
return None
|
|
return username
|
|
|
|
|
|
def json_body():
|
|
data = request.get_json(silent=True)
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
@app.route('/login', methods=["POST"])
|
|
def login():
|
|
data = json_body()
|
|
if data is None or "login" not in data or "password" not in data:
|
|
return bad_request()
|
|
username, password = data["login"], data["password"]
|
|
if not isinstance(username, str) or not isinstance(password, str):
|
|
return bad_request()
|
|
try:
|
|
resp = r.get(f"http://{database_node}/users/{username}/password",
|
|
headers=DBH, timeout=5)
|
|
correct_password = resp.json()["data"] if resp.status_code == 200 else None
|
|
except (r.RequestException, ValueError, KeyError):
|
|
return bad_request()
|
|
if correct_password is not None and correct_password == password:
|
|
out = make_response({"status": "ok"}, 200)
|
|
out.set_cookie("session", f"{username}||{password}", httponly=True, samesite="Lax")
|
|
return out
|
|
return make_response({"status": "wrong_creds"}, 401)
|
|
|
|
|
|
@app.route('/register', methods=["POST"])
|
|
def register():
|
|
data = json_body()
|
|
if data is None or "login" not in data or "password" not in data:
|
|
return bad_request()
|
|
username, password = data["login"], data["password"]
|
|
if not isinstance(username, str) or not isinstance(password, str):
|
|
return bad_request()
|
|
if not valid_username(username):
|
|
return make_response({"status": "bad_login"}, 400)
|
|
|
|
# PATCH V2: раньше регистрация существующего юзера просто ПЕРЕЗАПИСЫВАЛА
|
|
# его пароль (остальные поля, включая флаг, сохранялись) => полный захват
|
|
# чужого аккаунта без единого запроса на аутентификацию.
|
|
try:
|
|
exists = r.get(f"http://{database_node}/users/{username}/password",
|
|
headers=DBH, timeout=5)
|
|
if exists.status_code == 200:
|
|
return make_response({"status": "user_exists"}, 409)
|
|
except r.RequestException:
|
|
return bad_request()
|
|
|
|
try:
|
|
resp = r.post(f"http://{database_node}/users/{username}/password",
|
|
json={"value": password}, headers=DBH, timeout=5)
|
|
except r.RequestException:
|
|
return bad_request()
|
|
if resp.status_code == 200:
|
|
out = make_response({"status": "ok"}, 200)
|
|
out.set_cookie("session", f"{username}||{password}", httponly=True, samesite="Lax")
|
|
return out
|
|
return make_response({"status": "error"}, resp.status_code)
|
|
|
|
|
|
@app.route('/update_personal', methods=["POST"])
|
|
def update_personal():
|
|
username = current_user()
|
|
if username is None:
|
|
return unauthorized()
|
|
data = json_body()
|
|
if data is None:
|
|
return bad_request()
|
|
for field in ("first_name", "second_name", "email"):
|
|
if field not in data or not clean_value(data[field]):
|
|
return bad_request()
|
|
try:
|
|
for field in ("first_name", "second_name", "email"):
|
|
r.post(f"http://{database_node}/users/{username}/{field}",
|
|
json={"value": data[field]}, headers=DBH, timeout=5)
|
|
except r.RequestException:
|
|
return bad_request()
|
|
return make_response({"status": "ok"}, 200)
|
|
|
|
|
|
@app.route('/send_msg', methods=["POST"])
|
|
def send_msg():
|
|
username = current_user()
|
|
if username is None:
|
|
return unauthorized()
|
|
data = json_body()
|
|
if data is None or not clean_value(data.get("msg")):
|
|
return bad_request()
|
|
try:
|
|
r.post(f"http://{database_node}/users/{username}/msg",
|
|
json={"value": data["msg"]}, headers=DBH, timeout=5)
|
|
except r.RequestException:
|
|
return bad_request()
|
|
return make_response({"status": "ok"}, 200)
|
|
|
|
|
|
@app.route('/post_comment', methods=["POST"])
|
|
def post_comment():
|
|
username = current_user()
|
|
if username is None:
|
|
return unauthorized()
|
|
data = json_body()
|
|
if data is None or not clean_value(data.get("msg")):
|
|
return bad_request()
|
|
# PATCH V8: img_id раньше уходил в URL внутреннего запроса как есть
|
|
try:
|
|
img_id = int(data.get("img_id"))
|
|
except (TypeError, ValueError):
|
|
return bad_request()
|
|
if not (IMG_MIN <= img_id <= IMG_MAX):
|
|
return bad_request()
|
|
try:
|
|
r.post(f"http://{database_node}/images/{img_id}",
|
|
json={"value": [username, data["msg"]]}, headers=DBH, timeout=5)
|
|
except r.RequestException:
|
|
return bad_request()
|
|
return make_response({"status": "ok"}, 200)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app.run(host="0.0.0.0", port=logic_node_port)
|