This commit is contained in:
frogolonso
2026-08-26 12:53:27 +03:00
parent 12b1d02f39
commit 0d1b49ac1b
7 changed files with 133 additions and 34 deletions
+11 -4
View File
@@ -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)
+16
View File
@@ -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/<string:username>/<string:field>', 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/<string:id>', 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)
+17 -8
View File
@@ -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"
+26
View File
@@ -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"
+23 -10
View File
@@ -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)
+27 -7
View File
@@ -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('/<path:path>', 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)
+13 -5
View File
@@ -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):
# <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/<name>')
def img(name):
return send_file(f'files/static/img/{name}')
return send_file(f'files/static/img/{safe_name(name)}')
@app.route('/css/<name>')
def css(name):
return send_file(f'files/static/css/{name}')
return send_file(f'files/static/css/{safe_name(name)}')
@app.route('/fonts/<name>')
def fonts(name):
return send_file(f'files/static/fonts/{name}')
return send_file(f'files/static/fonts/{safe_name(name)}')
@app.route('/js/<name>')
def js(name):
return send_file(f'files/static/js/{name}')
return send_file(f'files/static/js/{safe_name(name)}')
if __name__ == '__main__':