From b1e60535a4ff51c77d67e25344bf6218a40a8f9f Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 15 Jun 2026 16:32:13 -0400 Subject: [PATCH] 06/15 Fix issues --- app/blueprints/auth/routes.py | 15 ++++++++++----- app/blueprints/i18n/routes.py | 4 ++-- app/blueprints/messaging/routes.py | 11 +++++++---- app/services/contact.py | 4 ++-- app/services/geo.py | 3 ++- app/static/style.v2.css | 4 ++++ app/templates/base.html | 5 ++++- app/utils/__init__.py | 15 +++++++++++++-- 8 files changed, 44 insertions(+), 17 deletions(-) diff --git a/app/blueprints/auth/routes.py b/app/blueprints/auth/routes.py index ddc9bf9..bc9d811 100644 --- a/app/blueprints/auth/routes.py +++ b/app/blueprints/auth/routes.py @@ -80,13 +80,13 @@ def login(): user.last_login_at = datetime.utcnow() db.session.commit() nxt = request.args.get("next") - if nxt and nxt.startswith("/"): + if nxt and nxt.startswith("/") and not nxt.startswith("//"): return redirect(nxt) return redirect(url_for("main.index")) return render_template("auth/login.html", form=form) -@auth_bp.route("/logout") +@auth_bp.route("/logout", methods=["POST"]) @login_required def logout(): logout_user() @@ -119,7 +119,7 @@ def reset_request(): if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data.lower()).first() if user: - token = generate_token(user.id, _RESET_SALT) + token = generate_token((user.id, user.password_hash[:20]), _RESET_SALT) link = url_for("auth.reset_password", token=token, _external=True) send_email(user.email, _("Reset your password"), _("Reset link: %(link)s", link=link)) @@ -131,14 +131,19 @@ def reset_request(): @auth_bp.route("/reset/", methods=["GET", "POST"]) def reset_password(token): - user_id = read_token(token, _RESET_SALT, + payload = read_token(token, _RESET_SALT, current_app.config["TOKEN_RESET_MAX_AGE"]) - if user_id is None: + # payload is (user_id, pw_fingerprint) — fingerprint invalidates on use + if not isinstance(payload, (list, tuple)) or len(payload) != 2: flash(_("Reset link is invalid or expired."), "danger") return redirect(url_for("auth.reset_request")) + user_id, pw_fingerprint = payload user = db.session.get(User, user_id) if user is None: abort(404) + if user.password_hash[:20] != pw_fingerprint: + flash(_("Reset link has already been used."), "danger") + return redirect(url_for("auth.reset_request")) form = ResetForm() if form.validate_on_submit(): user.set_password(form.password.data) diff --git a/app/blueprints/i18n/routes.py b/app/blueprints/i18n/routes.py index 7a05a5a..1529ff1 100644 --- a/app/blueprints/i18n/routes.py +++ b/app/blueprints/i18n/routes.py @@ -4,6 +4,7 @@ Order: explicit session choice -> authenticated user.locale -> Accept-Language - """ from flask import Blueprint, session, redirect, request, current_app, url_for from flask_login import current_user +from app.utils import safe_referrer i18n_bp = Blueprint("i18n", __name__) @@ -34,5 +35,4 @@ def set_lang(code): from app.extensions import db current_user.locale = code db.session.commit() - target = request.referrer or url_for("main.index") - return redirect(target) + return redirect(safe_referrer(url_for("main.index"))) diff --git a/app/blueprints/messaging/routes.py b/app/blueprints/messaging/routes.py index c095047..dadb723 100644 --- a/app/blueprints/messaging/routes.py +++ b/app/blueprints/messaging/routes.py @@ -11,6 +11,7 @@ from app.services import messaging as msvc from app.services import favorites as fsvc from app.services.contact import contact_revealed, mask_body from app.blueprints.messaging.forms import MessageForm +from app.utils import safe_referrer messaging_bp = Blueprint("messaging", __name__) @@ -37,6 +38,7 @@ def conversation(conv_id): abort(403) msvc.mark_conversation_read(conv, current_user) + # reveal controls the "contact info hidden" banner for the current reader reveal = contact_revealed(current_user) form = MessageForm() @@ -47,9 +49,10 @@ def conversation(conv_id): except msvc.MessagingError as e: flash(str(e), "danger") - # mask contact info in messages for low-trust users + # Mask is based on the *sender's* trust so a low-trust sender cannot + # slip contact info through to a trusted reader. masked_messages = [ - (msg, mask_body(msg.body, reveal=reveal)) + (msg, mask_body(msg.body, reveal=contact_revealed(msg.sender))) for msg in conv.messages ] return render_template("messaging/conversation.html", @@ -103,8 +106,8 @@ def toggle_favorite(listing_id): if request.headers.get("X-Requested-With") == "XMLHttpRequest": return jsonify(favorited=now_fav) flash(_("Saved.") if now_fav else _("Removed from saved."), "info") - return redirect(request.referrer or url_for("listings.detail", - listing_id=listing_id)) + return redirect(safe_referrer(url_for("listings.detail", + listing_id=listing_id))) @messaging_bp.route("/my/favorites") diff --git a/app/services/contact.py b/app/services/contact.py index b7cc625..67c40ee 100644 --- a/app/services/contact.py +++ b/app/services/contact.py @@ -14,8 +14,8 @@ from app.models.enums import TrustTier # patterns _PHONE_RE = re.compile( r"(\+?1[\s\-.]?)?" - r"(\(?\d{3}\)?[\s\-.])" - r"\d{3}[\s\-.]\d{4}" + r"(\(?\d{3}\)?[\s\-.]?)" + r"\d{3}[\s\-.]?\d{4}" ) _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}") _URL_RE = re.compile(r"https?://\S+|www\.\S+", re.I) diff --git a/app/services/geo.py b/app/services/geo.py index 138fe03..2603c34 100644 --- a/app/services/geo.py +++ b/app/services/geo.py @@ -4,6 +4,7 @@ Portable across MySQL and SQLite (no spatial extension needed). For large-scale deployments, see README for the MySQL POINT + SPATIAL INDEX upgrade. """ import math +from app.extensions import db from app.models.geo import ZipGeo EARTH_MI = 3958.7613 # mean earth radius, miles @@ -11,7 +12,7 @@ EARTH_MI = 3958.7613 # mean earth radius, miles def geocode_zip(zip_code): """Return (lat, lng, city, state, metro) or None.""" - row = ZipGeo.query.get((zip_code or "").strip()) + row = db.session.get(ZipGeo, (zip_code or "").strip()) if row is None: return None return row.lat, row.lng, row.city, row.state, row.metro diff --git a/app/static/style.v2.css b/app/static/style.v2.css index d685859..e755b46 100644 --- a/app/static/style.v2.css +++ b/app/static/style.v2.css @@ -130,3 +130,7 @@ table.list td{padding:8px 10px;border-bottom:1px solid var(--line)} .reply-box{padding:16px} .mask-notice{background:#fdf4e3;border:1px solid #f0dcae;border-radius:8px; padding:8px 12px;margin-top:8px} + +.link-btn{background:none;border:none;padding:0;color:inherit;font:inherit;cursor:pointer;text-decoration:none} +.link-btn:hover{text-decoration:underline} +.logout-form{display:inline} diff --git a/app/templates/base.html b/app/templates/base.html index fca32a3..ded36a8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -22,7 +22,10 @@ {% if unread_count %}{{ unread_count }}{% endif %} {{ current_user.display_name }} - {{ _('Sign out') }} +
+ + +
{% else %} {{ _('Sign in') }} {{ _('Register') }} diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 00cd0d6..c5b3c48 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -1,10 +1,21 @@ -"""RBAC decorators. Never trust the client; gate on server side.""" +"""RBAC decorators and shared request utilities.""" from functools import wraps -from flask import abort +from urllib.parse import urlparse +from flask import abort, request from flask_login import current_user from app.models.enums import Role +def safe_referrer(fallback: str) -> str: + """Return request.referrer only when it is same-origin; else fallback.""" + ref = request.referrer + if ref: + parsed = urlparse(ref) + if parsed.netloc in ("", request.host): + return ref + return fallback + + def role_required(*roles): """Require the current user to hold one of the given roles.""" def decorator(fn):