hueta2
This commit is contained in:
@@ -17,6 +17,14 @@ ALLOWED_TEMPLATES = {"index.html", "login.html", "register.html", "specific.html
|
|||||||
# PATCH V8: сколько картинок реально есть в db/images
|
# PATCH V8: сколько картинок реально есть в db/images
|
||||||
IMG_MIN, IMG_MAX = 1, 16
|
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():
|
def get_login():
|
||||||
"""Возвращает имя юзера или None. Никогда не бросает исключение."""
|
"""Возвращает имя юзера или None. Никогда не бросает исключение."""
|
||||||
@@ -70,7 +78,8 @@ def route(path):
|
|||||||
user["msg"] = _field(username, "msg")
|
user["msg"] = _field(username, "msg")
|
||||||
return render_template("index.html", data=imgs, user=user)
|
return render_template("index.html", data=imgs, user=user)
|
||||||
elif path == "specific.html":
|
elif path == "specific.html":
|
||||||
if get_login() is None:
|
username = get_login()
|
||||||
|
if username is None:
|
||||||
return redirect("/login.html", code=302)
|
return redirect("/login.html", code=302)
|
||||||
# PATCH V8: img приходит из URL. Раньше любое нечисловое/вне диапазона
|
# PATCH V8: img приходит из URL. Раньше любое нечисловое/вне диапазона
|
||||||
# значение давало 500, а незакавыченная подстановка {{img['id']}} внутрь
|
# значение давало 500, а незакавыченная подстановка {{img['id']}} внутрь
|
||||||
@@ -87,6 +96,11 @@ def route(path):
|
|||||||
except (r.RequestException, ValueError, KeyError):
|
except (r.RequestException, ValueError, KeyError):
|
||||||
abort(404)
|
abort(404)
|
||||||
img["id"] = str(img_id).zfill(2)
|
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)
|
return render_template("specific.html", img=img)
|
||||||
else:
|
else:
|
||||||
# PATCH V1/V5: всё, чего нет в белом списке, до Jinja не доходит.
|
# PATCH V1/V5: всё, чего нет в белом списке, до Jinja не доходит.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def safe_username(username):
|
|||||||
return False
|
return False
|
||||||
if "/" in username or "\\" in username or username in (".", ".."):
|
if "/" in username or "\\" in username or username in (".", ".."):
|
||||||
return False
|
return False
|
||||||
if "\x00" in username:
|
if "\x00" in username or any(ord(c) < 0x20 for c in username):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ def valid_username(username):
|
|||||||
return False # имя, которое content мог бы отрендерить как шаблон
|
return False # имя, которое content мог бы отрендерить как шаблон
|
||||||
if "{{" in username or "{%" in username or "{#" in username:
|
if "{{" in username or "{%" in username or "{#" in username:
|
||||||
return False
|
return False
|
||||||
|
if any(ord(c) < 0x20 for c in username): # control chars / newlines
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,22 +24,40 @@ def safe_path(path: str):
|
|||||||
return True
|
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('/', defaults={'path': 'index.html'})
|
||||||
@app.route('/<path:path>', methods=["GET", "POST"])
|
@app.route('/<path:path>', methods=["GET", "POST"])
|
||||||
def catch_all(path: str):
|
def catch_all(path: str):
|
||||||
if not safe_path(path):
|
if not safe_path(path):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
if request.content_length and request.content_length > 1_000_000:
|
||||||
|
abort(413)
|
||||||
ext = path.split('.')[-1]
|
ext = path.split('.')[-1]
|
||||||
if ext == "html":
|
if ext == "html":
|
||||||
if path not in ALLOWED_HTML:
|
if path not in ALLOWED_HTML:
|
||||||
abort(404)
|
abort(404)
|
||||||
qs = request.full_path.split('?')[1] if '?' in request.full_path else ''
|
qs = request.full_path.split('?')[1] if '?' in request.full_path else ''
|
||||||
resp = r.get(f"http://{content_node}/{path}?{qs}", headers=request.headers,
|
try:
|
||||||
timeout=10)
|
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()
|
return resp.content, resp.status_code, resp.headers.items()
|
||||||
if ext in STATIC_EXT:
|
if ext in STATIC_EXT:
|
||||||
resp = r.get(f"http://{static_node}/{path}", stream=True, timeout=5)
|
try:
|
||||||
headers = [("Cache-Control", "max-age=36000;"), ("Content-Type", resp.headers["Content-Type"])]
|
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
|
return resp.content, resp.status_code, headers
|
||||||
if path.startswith("api/"):
|
if path.startswith("api/"):
|
||||||
action = path[4:].split("/", 1)[0]
|
action = path[4:].split("/", 1)[0]
|
||||||
@@ -51,8 +69,11 @@ def catch_all(path: str):
|
|||||||
# PATCH V3: allow_redirects=False. requests по умолчанию ХОДИТ по Location,
|
# PATCH V3: allow_redirects=False. requests по умолчанию ХОДИТ по Location,
|
||||||
# который отдаёт logic, а Location раньше строился из заголовка Origin =>
|
# который отдаёт logic, а Location раньше строился из заголовка Origin =>
|
||||||
# proxy делал запрос во внутреннюю сеть за атакующего (SSRF).
|
# proxy делал запрос во внутреннюю сеть за атакующего (SSRF).
|
||||||
resp = passcall(f"http://{logic_node}/{path[4:]}", json=request.get_json(silent=True),
|
try:
|
||||||
headers=request.headers, allow_redirects=False, timeout=8)
|
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 resp.content, resp.status_code, resp.headers.items()
|
||||||
return redirect("/", code=302)
|
return redirect("/", code=302)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user