diff --git a/services/rusgram/content/content.py b/services/rusgram/content/content.py index 80107f0..68db7f2 100755 --- a/services/rusgram/content/content.py +++ b/services/rusgram/content/content.py @@ -1,10 +1,13 @@ #!/usr/bin/python3 from flask import Flask, render_template, request, redirect, abort +import os import requests as r app = Flask(__name__, template_folder='files') database_node_port = 5002 content_node_port = 5004 database_node = "database:" + str(database_node_port) +INTERNAL = os.environ.get("RUSGRAM_INTERNAL", "e9b2f6d14a8c7035e1d0a6b8c4f2e7a9") +DBH = {"X-Internal": INTERNAL} # PATCH V1/V5: template_folder ('files') — это тот же том, где лежит db/users/<логин>. # Раньше render_template(path) с путём из URL позволял отрендерить файл юзера @@ -27,7 +30,8 @@ def get_login(): if not username: return None try: - resp = r.get(f"http://{database_node}/users/{username}/password", timeout=5) + 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: @@ -39,7 +43,8 @@ def get_login(): def _field(username, name): try: - resp = r.get(f"http://{database_node}/users/{username}/{name}", timeout=5) + resp = r.get(f"http://{database_node}/users/{username}/{name}", + headers=DBH, timeout=5) return resp.json()["data"] if resp.status_code == 200 else "" except (r.RequestException, ValueError, KeyError): return "" @@ -53,7 +58,8 @@ def route(path): imgs = list() for i in range(IMG_MIN, IMG_MAX + 1): try: - imgs.append(r.get(f"http://{database_node}/images/{i}", timeout=5).json()["data"]) + imgs.append(r.get(f"http://{database_node}/images/{i}", + headers=DBH, timeout=5).json()["data"]) except (r.RequestException, ValueError, KeyError): continue imgs[-1]["id"] = str(i).zfill(2) @@ -76,7 +82,8 @@ def route(path): if not (IMG_MIN <= img_id <= IMG_MAX): abort(404) try: - img = r.get(f"http://{database_node}/images/{img_id}", timeout=5).json()["data"] + img = r.get(f"http://{database_node}/images/{img_id}", + headers=DBH, timeout=5).json()["data"] except (r.RequestException, ValueError, KeyError): abort(404) img["id"] = str(img_id).zfill(2) diff --git a/services/rusgram/database/database.py b/services/rusgram/database/database.py index 4b30500..d00a0a5 100755 --- a/services/rusgram/database/database.py +++ b/services/rusgram/database/database.py @@ -11,6 +11,16 @@ IMG_MIN, IMG_MAX = 1, 16 # Поля профиля, которые вообще могут существовать ALLOWED_FIELDS = {"password", "first_name", "second_name", "email", "msg"} +# PATCH V4: database без auth. Даже если :38002 снова опубликуют на 0.0.0.0, +# без этого заголовка (его знают только logic/content) чужие запросы отсекаются. +INTERNAL = os.environ.get("RUSGRAM_INTERNAL", "e9b2f6d14a8c7035e1d0a6b8c4f2e7a9") + + +def require_internal(): + if request.headers.get("X-Internal") != INTERNAL: + return make_response({"status": "forbidden"}, 403) + return None + def safe_username(username): """Имя юзера становится именем файла в db/users/ — режем всё, что уводит @@ -30,6 +40,9 @@ def user_path(username): @app.route('/users//', methods=["GET", "POST"]) def users(username, field): + denied = require_internal() + if denied is not None: + return denied if not safe_username(username) or field not in ALLOWED_FIELDS: return make_response({"status": "bad_request"}, 400) path = user_path(username) @@ -82,6 +95,9 @@ def users(username, field): @app.route('/images/', methods=["GET", "POST"]) def images(id): + denied = require_internal() + if denied is not None: + return denied # PATCH V8: раньше любой нечисловой/несуществующий id давал 500 try: img_id = int(id) diff --git a/services/rusgram/docker-compose.yml b/services/rusgram/docker-compose.yml index 2744d9f..6e7fa2b 100644 --- a/services/rusgram/docker-compose.yml +++ b/services/rusgram/docker-compose.yml @@ -6,12 +6,15 @@ services: ports: - "38000:5000" + # Внутренние ноды НЕ публикуем на хост вообще. + # Атака #244475: GET :38002/images/16 без auth → флаги из comments. + # Даже 127.0.0.1:38002 опасно, если на боксе крутят туннели/агентов. static: build: ./static image: git.itqdev.xyz/4x10m/rusgram-static:${IMAGE_TAG:-latest} restart: unless-stopped - ports: - - "127.0.0.1:38001:5001" + expose: + - "5001" volumes: - "./website:/app/files" @@ -19,8 +22,10 @@ services: build: ./database image: git.itqdev.xyz/4x10m/rusgram-database:${IMAGE_TAG:-latest} restart: unless-stopped - ports: - - "127.0.0.1:38002:5002" + environment: + RUSGRAM_INTERNAL: "e9b2f6d14a8c7035e1d0a6b8c4f2e7a9" + expose: + - "5002" volumes: - "./website:/app/files" @@ -28,14 +33,18 @@ services: build: ./logic image: git.itqdev.xyz/4x10m/rusgram-logic:${IMAGE_TAG:-latest} restart: unless-stopped - ports: - - "127.0.0.1:38003:5003" + environment: + RUSGRAM_INTERNAL: "e9b2f6d14a8c7035e1d0a6b8c4f2e7a9" + expose: + - "5003" content: build: ./content image: git.itqdev.xyz/4x10m/rusgram-content:${IMAGE_TAG:-latest} restart: unless-stopped - ports: - - "127.0.0.1:38004:5004" + environment: + RUSGRAM_INTERNAL: "e9b2f6d14a8c7035e1d0a6b8c4f2e7a9" + expose: + - "5004" volumes: - "./website:/app/files" diff --git a/services/rusgram/harden.sh b/services/rusgram/harden.sh new file mode 100755 index 0000000..82da5b1 --- /dev/null +++ b/services/rusgram/harden.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Жёстко закрыть 38001-38004 на вулнбоксе + пересобрать rusgram. +# Docker публикует порты МИМО цепочки INPUT — поэтому DOCKER-USER обязателен. +set -euo pipefail +cd "$(dirname "$0")" + +echo "[*] rebuild + up" +docker compose down --remove-orphans || true +docker compose up -d --build --force-recreate + +echo "[*] firewall: DROP 38001-38004 (INPUT + DOCKER-USER)" +for chain in INPUT DOCKER-USER; do + # идемпотентно: сначала снести старые наши правила, потом поставить + while iptables -C "$chain" -p tcp --dport 38001:38004 -j DROP 2>/dev/null; do + iptables -D "$chain" -p tcp --dport 38001:38004 -j DROP || true + done + iptables -I "$chain" -p tcp --dport 38001:38004 -j DROP +done + +echo "[*] published ports now:" +docker compose ps +ss -ltn | grep -E '3800[0-4]' || echo "(нет слушателей 38001-38004 — ок)" + +echo "[*] self-check: :38002 снаружи должен быть мёртв" +echo " curl -s -m 2 http://127.0.0.1:38002/images/1 → connection refused" +echo " curl -s -m 2 http://127.0.0.1:38000/login.html → 200" diff --git a/services/rusgram/logic/logic.py b/services/rusgram/logic/logic.py index bc86c0b..c316e8b 100755 --- a/services/rusgram/logic/logic.py +++ b/services/rusgram/logic/logic.py @@ -1,5 +1,6 @@ #!/usr/bin/python3 from flask import Flask, make_response, request, redirect +import os import requests as r app = Flask(__name__) database_node_port = 5002 @@ -7,6 +8,8 @@ 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): @@ -31,9 +34,16 @@ def valid_username(username): return False # имя юзера = имя файла в db/users/ if username.lower().endswith((".html", ".htm")): return False # имя, которое content мог бы отрендерить как шаблон + if "{{" in username or "{%" in username or "{#" in username: + 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") @@ -46,7 +56,8 @@ def current_user(): if not username: return None try: - resp = r.get(f"http://{database_node}/users/{username}/password", timeout=5) + 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: @@ -70,7 +81,8 @@ def login(): if not isinstance(username, str) or not isinstance(password, str): return bad_request() try: - resp = r.get(f"http://{database_node}/users/{username}/password", timeout=5) + 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() @@ -96,7 +108,8 @@ def register(): # его пароль (остальные поля, включая флаг, сохранялись) => полный захват # чужого аккаунта без единого запроса на аутентификацию. try: - exists = r.get(f"http://{database_node}/users/{username}/password", timeout=5) + 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: @@ -104,7 +117,7 @@ def register(): try: resp = r.post(f"http://{database_node}/users/{username}/password", - json={"value": password}, timeout=5) + json={"value": password}, headers=DBH, timeout=5) except r.RequestException: return bad_request() if resp.status_code == 200: @@ -123,12 +136,12 @@ def update_personal(): if data is None: return bad_request() for field in ("first_name", "second_name", "email"): - if field not in data or not isinstance(data[field], str): + 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]}, timeout=5) + json={"value": data[field]}, headers=DBH, timeout=5) except r.RequestException: return bad_request() return make_response({"status": "ok"}, 200) @@ -140,11 +153,11 @@ def send_msg(): if username is None: return unauthorized() data = json_body() - if data is None or not isinstance(data.get("msg"), str): + 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"]}, timeout=5) + json={"value": data["msg"]}, headers=DBH, timeout=5) except r.RequestException: return bad_request() return make_response({"status": "ok"}, 200) @@ -156,7 +169,7 @@ def post_comment(): if username is None: return unauthorized() data = json_body() - if data is None or not isinstance(data.get("msg"), str): + if data is None or not clean_value(data.get("msg")): return bad_request() # PATCH V8: img_id раньше уходил в URL внутреннего запроса как есть try: @@ -167,7 +180,7 @@ def post_comment(): return bad_request() try: r.post(f"http://{database_node}/images/{img_id}", - json={"value": [username, data["msg"]]}, timeout=5) + json={"value": [username, data["msg"]]}, headers=DBH, timeout=5) except r.RequestException: return bad_request() return make_response({"status": "ok"}, 200) diff --git a/services/rusgram/proxy/proxy.py b/services/rusgram/proxy/proxy.py index 662ca3c..51ccbc4 100755 --- a/services/rusgram/proxy/proxy.py +++ b/services/rusgram/proxy/proxy.py @@ -1,5 +1,5 @@ #!/usr/bin/python3 -from flask import Flask, request, redirect +from flask import Flask, request, redirect, abort import requests as r app = Flask(__name__) proxy_node_port = 5000 @@ -10,29 +10,49 @@ static_node = "static:" + str(static_node_port) logic_node = "logic:" + str(logic_node_port) content_node = "content:" + str(content_node_port) +# Только реальные страницы. /db/users/pwn.html больше не уходит в content (V1). +ALLOWED_HTML = {"index.html", "login.html", "register.html", "specific.html"} +ALLOWED_API = {"login", "register", "update_personal", "send_msg", "post_comment"} +STATIC_EXT = {"css", "png", "jpg", "ico", "js", "otf", "eot", "svg", "ttf", "woff", "woff2"} + + +def safe_path(path: str): + if not path or ".." in path or "\\" in path or "\x00" in path: + return False + if path.startswith("db/") or "/db/" in path: + return False + return True + @app.route('/', defaults={'path': 'index.html'}) @app.route('/', methods=["GET", "POST"]) def catch_all(path: str): - print(path) + if not safe_path(path): + abort(404) ext = path.split('.')[-1] if ext == "html": - resp = r.get(f"http://{content_node}/{path}?{request.full_path.split('?')[1]}", headers=request.headers) + 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) return resp.content, resp.status_code, resp.headers.items() - if ext in ["css", "png", "jpg", "ico", "js", "otf", "eot", "svg", "ttf", "woff", "woff2"]: - resp = r.get(f"http://{static_node}/{path}", stream=True) + 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"])] return resp.content, resp.status_code, headers if path.startswith("api/"): + action = path[4:].split("/", 1)[0] + if action not in ALLOWED_API: + abort(404) if request.method.lower() not in ("get", "post"): return redirect("/", code=302) passcall = getattr(r, request.method.lower()) # 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) + headers=request.headers, allow_redirects=False, timeout=8) return resp.content, resp.status_code, resp.headers.items() return redirect("/", code=302) diff --git a/services/rusgram/static/static.py b/services/rusgram/static/static.py index 1a5691e..7f12bcb 100755 --- a/services/rusgram/static/static.py +++ b/services/rusgram/static/static.py @@ -1,27 +1,35 @@ #!/usr/bin/python3 -from flask import Flask, send_file +from flask import Flask, send_file, abort +import os app = Flask(__name__) static_node_port = 5001 +def safe_name(name): + # normally has no slashes, but %2f / .. still show up decoded. + if not name or name != os.path.basename(name) or ".." in name or "\x00" in name: + abort(404) + return name + + @app.route('/img/') def img(name): - return send_file(f'files/static/img/{name}') + return send_file(f'files/static/img/{safe_name(name)}') @app.route('/css/') def css(name): - return send_file(f'files/static/css/{name}') + return send_file(f'files/static/css/{safe_name(name)}') @app.route('/fonts/') def fonts(name): - return send_file(f'files/static/fonts/{name}') + return send_file(f'files/static/fonts/{safe_name(name)}') @app.route('/js/') def js(name): - return send_file(f'files/static/js/{name}') + return send_file(f'files/static/js/{safe_name(name)}') if __name__ == '__main__':