diff --git a/services/rusgram/content/content.py b/services/rusgram/content/content.py index 68db7f2..203f69a 100755 --- a/services/rusgram/content/content.py +++ b/services/rusgram/content/content.py @@ -17,6 +17,14 @@ ALLOWED_TEMPLATES = {"index.html", "login.html", "register.html", "specific.html # PATCH V8: сколько картинок реально есть в db/images IMG_MIN, IMG_MAX = 1, 16 +# PATCH V9: чекер хранит флаг как КОММЕНТАРИЙ к картинке, а specific.html +# раньше отдавал ВСЕ комментарии любому залогиненному юзеру => атакующий +# регистрируется и читает чужие флаги через парадный вход. Показываем юзеру +# только его собственные комментарии. Чекер постит и читает свой коммент одной +# сессией (см. test_rusgram.py) — легальный флоу не ломается. +# Отключить (если чекер читает чужие комменты): RUSGRAM_COMMENT_PRIVACY=0 +COMMENT_PRIVACY = os.environ.get("RUSGRAM_COMMENT_PRIVACY", "1") != "0" + def get_login(): """Возвращает имя юзера или None. Никогда не бросает исключение.""" @@ -70,7 +78,8 @@ def route(path): user["msg"] = _field(username, "msg") return render_template("index.html", data=imgs, user=user) elif path == "specific.html": - if get_login() is None: + username = get_login() + if username is None: return redirect("/login.html", code=302) # PATCH V8: img приходит из URL. Раньше любое нечисловое/вне диапазона # значение давало 500, а незакавыченная подстановка {{img['id']}} внутрь @@ -87,6 +96,11 @@ def route(path): except (r.RequestException, ValueError, KeyError): abort(404) img["id"] = str(img_id).zfill(2) + # PATCH V9: не отдаём чужие комментарии (в них лежат флаги). + if COMMENT_PRIVACY: + comments = img.get("comments", []) + img["comments"] = [c for c in comments + if isinstance(c, list) and len(c) == 2 and c[0] == username] return render_template("specific.html", img=img) else: # PATCH V1/V5: всё, чего нет в белом списке, до Jinja не доходит. diff --git a/services/rusgram/database/database.py b/services/rusgram/database/database.py index d00a0a5..af215b0 100755 --- a/services/rusgram/database/database.py +++ b/services/rusgram/database/database.py @@ -29,7 +29,7 @@ def safe_username(username): return False if "/" in username or "\\" in username or username in (".", ".."): return False - if "\x00" in username: + if "\x00" in username or any(ord(c) < 0x20 for c in username): return False return True diff --git a/services/rusgram/logic/logic.py b/services/rusgram/logic/logic.py index c316e8b..80401b8 100755 --- a/services/rusgram/logic/logic.py +++ b/services/rusgram/logic/logic.py @@ -36,6 +36,8 @@ def valid_username(username): 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 diff --git a/services/rusgram/proxy/proxy.py b/services/rusgram/proxy/proxy.py index 51ccbc4..26b0637 100755 --- a/services/rusgram/proxy/proxy.py +++ b/services/rusgram/proxy/proxy.py @@ -24,22 +24,40 @@ def safe_path(path: str): return True +def fwd_headers(): + """PATCH V4 (defense in depth): не пропускаем клиентский X-Internal внутрь — + иначе клиент мог бы попытаться выдать себя за внутренний узел. Также режем + hop-by-hop заголовки, которые ломают проксирование.""" + drop = {"x-internal", "host", "content-length", "connection", + "transfer-encoding", "keep-alive", "te", "upgrade"} + return {k: v for k, v in request.headers.items() if k.lower() not in drop} + + @app.route('/', defaults={'path': 'index.html'}) @app.route('/', methods=["GET", "POST"]) def catch_all(path: str): if not safe_path(path): abort(404) + if request.content_length and request.content_length > 1_000_000: + abort(413) ext = path.split('.')[-1] if ext == "html": if path not in ALLOWED_HTML: abort(404) qs = request.full_path.split('?')[1] if '?' in request.full_path else '' - resp = r.get(f"http://{content_node}/{path}?{qs}", headers=request.headers, - timeout=10) + try: + resp = r.get(f"http://{content_node}/{path}?{qs}", headers=fwd_headers(), + timeout=10) + except r.RequestException: + abort(502) return resp.content, resp.status_code, resp.headers.items() if ext in STATIC_EXT: - resp = r.get(f"http://{static_node}/{path}", stream=True, timeout=5) - headers = [("Cache-Control", "max-age=36000;"), ("Content-Type", resp.headers["Content-Type"])] + try: + resp = r.get(f"http://{static_node}/{path}", stream=True, timeout=5) + except r.RequestException: + abort(502) + ct = resp.headers.get("Content-Type", "application/octet-stream") + headers = [("Cache-Control", "max-age=36000;"), ("Content-Type", ct)] return resp.content, resp.status_code, headers if path.startswith("api/"): action = path[4:].split("/", 1)[0] @@ -51,8 +69,11 @@ def catch_all(path: str): # PATCH V3: allow_redirects=False. requests по умолчанию ХОДИТ по Location, # который отдаёт logic, а Location раньше строился из заголовка Origin => # proxy делал запрос во внутреннюю сеть за атакующего (SSRF). - resp = passcall(f"http://{logic_node}/{path[4:]}", json=request.get_json(silent=True), - headers=request.headers, allow_redirects=False, timeout=8) + try: + resp = passcall(f"http://{logic_node}/{path[4:]}", json=request.get_json(silent=True), + headers=fwd_headers(), allow_redirects=False, timeout=8) + except r.RequestException: + abort(502) return resp.content, resp.status_code, resp.headers.items() return redirect("/", code=302)