hueta
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable
+26
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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__':
|
||||
|
||||
Reference in New Issue
Block a user