Skip to content

Commit 7e0529f

Browse files
authored
Merge pull request #7 from baseVISION/sync/backport-2026-05b
[BV] Backport security fixes from upstream hotfix_v2.4.29
2 parents 8e19192 + fe13db4 commit 7e0529f

17 files changed

Lines changed: 382 additions & 102 deletions

File tree

docker-compose.bv.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ services:
5151
context: .
5252
dockerfile: docker/webApp/Dockerfile
5353
image: bv-iriswebapp-app:develop
54-
ports: []
54+
ports:
55+
- "127.0.0.1:8000:8000"
5556
volumes:
5657
- ./source/app:/iriswebapp/app:z
5758
- ./ui/dist:/iriswebapp/static:z
@@ -99,6 +100,8 @@ services:
99100
NGINX_CONF_GID: 1234
100101
NGINX_CONF_FILE: ${NGINX_CONF_FILE:-nginx.conf}
101102
image: bv-iriswebapp-nginx:develop
103+
security_opt:
104+
- "label=disable"
102105
volumes:
103106
- "./certificates/web_certificates/:/www/certs/:z"
104107
depends_on:

docker/nginx/nginx.conf

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,6 @@ http {
7777
proxy_set_header X-Real-IP $remote_addr;
7878
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
7979

80-
# FULLY DISABLE SERVER CACHE
81-
add_header Last-Modified $date_gmt;
82-
add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
83-
if_modified_since off;
84-
expires off;
85-
etag off;
86-
proxy_no_cache 1;
87-
proxy_cache_bypass 1;
88-
8980
# SSL CONF, STRONG CIPHERS ONLY
9081
ssl_protocols TLSv1.2 TLSv1.3;
9182

@@ -119,6 +110,45 @@ http {
119110
add_header Strict-Transport-Security "max-age=31536000: includeSubDomains" always;
120111
add_header Front-End-Https on;
121112

113+
# FULLY DISABLE SERVER CACHE
114+
# Moved to server block to fix inheritance issues.
115+
# 'always' ensures headers are sent even on error pages.
116+
add_header Last-Modified $date_gmt always;
117+
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
118+
add_header Pragma "no-cache" always;
119+
add_header Expires "0" always;
120+
if_modified_since off;
121+
expires off;
122+
etag off;
123+
proxy_no_cache 1;
124+
proxy_cache_bypass 1;
125+
126+
location /static {
127+
# Enable Caching (Overrides server "off" settings) for static
128+
# ----------------------------------------------------
129+
expires 1y;
130+
add_header Cache-Control "public, max-age=31536000, immutable";
131+
132+
# Re-enable ETags and Last-Modified so browsers can check for updates
133+
etag on;
134+
if_modified_since exact;
135+
136+
# Re-apply Security Headers
137+
add_header X-XSS-Protection "1; mode=block";
138+
add_header X-Frame-Options DENY;
139+
add_header X-Content-Type-Options nosniff;
140+
add_header Strict-Transport-Security "max-age=31536000: includeSubDomains" always;
141+
add_header Front-End-Https on;
142+
add_header Content-Security-Policy $csp_header;
143+
144+
145+
# Standard Proxy Config
146+
proxy_pass http://${IRIS_UPSTREAM_SERVER}:${IRIS_UPSTREAM_PORT};
147+
148+
proxy_no_cache 0;
149+
proxy_cache_bypass 0;
150+
}
151+
122152
location / {
123153
proxy_pass http://${IRIS_UPSTREAM_SERVER}:${IRIS_UPSTREAM_PORT};
124154

source/app/__init__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1818

1919
import os
20+
import bleach
21+
from markupsafe import Markup
2022
from flask import Flask
2123
from flask import g
2224
from flask import session
@@ -88,7 +90,37 @@ def ac_current_user_has_manage_perms():
8890
return False
8991

9092

93+
# Allowlist for user-defined HTML custom attributes. Matches the frontend
94+
# do_md_filter_xss() allowlist closely so behavior is consistent across sinks.
95+
# Explicitly excludes script / iframe / event handlers / javascript: URLs.
96+
_ATTR_HTML_ALLOWED_TAGS = [
97+
'a', 'abbr', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'h1', 'h2', 'h3',
98+
'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong',
99+
'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul',
100+
]
101+
_ATTR_HTML_ALLOWED_ATTRS = {
102+
'*': ['class', 'title'],
103+
'a': ['href', 'title', 'target', 'rel'],
104+
'img': ['src', 'alt', 'title', 'width', 'height'],
105+
}
106+
_ATTR_HTML_ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
107+
108+
109+
def _sanitize_attribute_html(value):
110+
if value is None:
111+
return ''
112+
cleaned = bleach.clean(
113+
str(value),
114+
tags=_ATTR_HTML_ALLOWED_TAGS,
115+
attributes=_ATTR_HTML_ALLOWED_ATTRS,
116+
protocols=_ATTR_HTML_ALLOWED_PROTOCOLS,
117+
strip=True,
118+
)
119+
return Markup(cleaned)
120+
121+
91122
register_jinja_filters(app.jinja_env)
123+
app.jinja_env.filters['sanitize_attribute_html'] = _sanitize_attribute_html
92124

93125
app.jinja_env.globals.update(user_has_perm=ac_current_user_has_permission)
94126
app.jinja_env.globals.update(user_has_manage_perms=ac_current_user_has_manage_perms)

source/app/blueprints/pages/login/login_routes.py

Lines changed: 113 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import random
2424
import string
2525
import json
26+
import time
2627
from flask import Blueprint, flash
2728
from flask import redirect
2829
from flask import render_template
@@ -354,15 +355,98 @@ def oidc_authorise():
354355
return wrap_login_user(user, is_oidc=True)
355356

356357

358+
# MFA hardening constants. Values are deliberately conservative — a legitimate
359+
# user mistypes their token occasionally; an attacker brute-forcing 10^6 TOTP
360+
# codes should not be able to linearly grind them.
361+
_MFA_MAX_ATTEMPTS = 5
362+
_MFA_LOCKOUT_SECONDS = 15 * 60
363+
364+
365+
def _get_pre_mfa_user():
366+
"""Return the user this session just passed password auth for, or None.
367+
368+
The pre_mfa_user_id marker is set exclusively by wrap_login_user after a
369+
successful password (or LDAP) check. Any MFA handler that runs without it
370+
is being hit directly by an attacker and must be refused.
371+
"""
372+
pre_mfa_user_id = session.get('pre_mfa_user_id')
373+
if pre_mfa_user_id is None:
374+
return None
375+
return get_user(pre_mfa_user_id, id_key='id')
376+
377+
378+
def _clear_pre_mfa_state(preserve_lockout=False):
379+
"""Clear the markers that prove this session just passed password auth.
380+
381+
preserve_lockout: when True, keep the lockout timestamp and fail counter
382+
so that an attacker cannot wipe a brute-force lockout simply by hitting
383+
/login again.
384+
"""
385+
session.pop('pre_mfa_user_id', None)
386+
session.pop('pending_mfa_secret', None)
387+
if not preserve_lockout:
388+
session.pop('mfa_fail_count', None)
389+
session.pop('mfa_lockout_until', None)
390+
391+
392+
def _mfa_is_locked_out():
393+
locked_until = session.get('mfa_lockout_until')
394+
if locked_until and locked_until > time.time():
395+
return True
396+
if locked_until and locked_until <= time.time():
397+
# Lockout expired — reset the counter so the user gets a fresh window.
398+
session.pop('mfa_lockout_until', None)
399+
session['mfa_fail_count'] = 0
400+
return False
401+
402+
403+
def _register_mfa_failure(user, reason):
404+
session['mfa_fail_count'] = session.get('mfa_fail_count', 0) + 1
405+
track_activity(
406+
f'Failed MFA {reason} for user {user.user} (attempt {session["mfa_fail_count"]}/{_MFA_MAX_ATTEMPTS})',
407+
ctx_less=True, display_in_ui=False
408+
)
409+
if session['mfa_fail_count'] >= _MFA_MAX_ATTEMPTS:
410+
session['mfa_lockout_until'] = time.time() + _MFA_LOCKOUT_SECONDS
411+
# Drop the pending-MFA marker so the attacker has to go back through
412+
# password auth before they get another burst of attempts. Preserve
413+
# the lockout timestamp so a fresh /login cannot wipe it.
414+
_clear_pre_mfa_state(preserve_lockout=True)
415+
session.pop('username', None)
416+
417+
357418
@app.route("/auth/mfa-setup", methods=["GET", "POST"])
358419
def mfa_setup():
359-
user = retrieve_user_by_username(username=session["username"])
420+
user = _get_pre_mfa_user()
421+
if user is None:
422+
return redirect(url_for("login.login"))
423+
424+
# mfa_setup is only for users who haven't completed MFA yet (fresh
425+
# accounts, or admin-reset accounts where mfa_setup_complete was cleared).
426+
# A user who already has MFA enrolled must go through mfa_verify — this
427+
# prevents the "I know the password, let me overwrite the enrolled secret
428+
# with one I control" bypass.
429+
if user.mfa_setup_complete and user.mfa_secrets:
430+
return redirect(url_for("mfa_verify"))
431+
432+
if _mfa_is_locked_out():
433+
flash('Too many attempts. Please try again later.', 'danger')
434+
return redirect(url_for("login.login"))
435+
360436
form = MFASetupForm()
361437

362438
if form.submit() and form.validate():
439+
363440
token = form.token.data
364-
mfa_secret = form.mfa_secret.data
365441
user_password = form.user_password.data
442+
# The secret MUST come from the server-side session, never from the
443+
# submitted form. Trusting form.mfa_secret let an attacker enrol a
444+
# secret of their choosing and immediately log in.
445+
mfa_secret = session.get('pending_mfa_secret')
446+
if not mfa_secret:
447+
flash('MFA setup expired. Please restart the login flow.', 'danger')
448+
return redirect(url_for("login.login"))
449+
366450
totp = pyotp.TOTP(mfa_secret)
367451

368452
if totp.verify(token):
@@ -379,36 +463,36 @@ def mfa_setup():
379463
has_valid_password = True
380464

381465
if not has_valid_password:
382-
track_activity(
383-
f"Failed MFA setup for user {user.user}. Invalid password.",
384-
ctx_less=True,
385-
display_in_ui=False,
386-
)
466+
_register_mfa_failure(user, 'setup (invalid password)')
387467
flash("Invalid password. Please try again.", "danger")
388468
return render_template("mfa_setup.html", form=form)
389469

390470
user.mfa_secrets = mfa_secret
391471
user.mfa_setup_complete = True
392472
db.session.commit()
393-
session["mfa_verified"] = False
394473
track_activity(
395474
f"MFA setup successful for user {user.user}",
396475
ctx_less=True,
397476
display_in_ui=False,
398477
)
478+
# Setup succeeded — promote this session to MFA-verified for this
479+
# user and clear the pre-MFA markers. Without this, wrap_login_user
480+
# would loop straight back to mfa_verify.
481+
session['mfa_verified_for_user_id'] = user.id
482+
_clear_pre_mfa_state()
399483
return wrap_login_user(user)
400-
track_activity(
401-
f"Failed MFA setup for user {user.user}. Invalid token.",
402-
ctx_less=True,
403-
display_in_ui=False,
404-
)
405-
flash("Invalid token or password. Please try again.", "danger")
484+
else:
485+
_register_mfa_failure(user, 'setup (invalid token)')
486+
flash("Invalid token or password. Please try again.", "danger")
406487

488+
# Generate a fresh secret on every GET and stash it in the session. The
489+
# client only sees the QR code and the base32 key for manual entry; it
490+
# never sends the secret back.
407491
temp_otp_secret = pyotp.random_base32()
492+
session['pending_mfa_secret'] = temp_otp_secret
408493
otp_uri = pyotp.TOTP(temp_otp_secret).provisioning_uri(
409494
user.email, issuer_name="IRIS"
410495
)
411-
form.mfa_secret.data = temp_otp_secret
412496
img = qrcode.make(otp_uri)
413497
buf = io.BytesIO()
414498
img.save(buf, format="PNG")
@@ -421,11 +505,10 @@ def mfa_setup():
421505

422506
@app.route("/auth/mfa-verify", methods=["GET", "POST"])
423507
def mfa_verify():
424-
if "username" not in session:
508+
user = _get_pre_mfa_user()
509+
if user is None:
425510
return redirect(url_for("login.login"))
426511

427-
user = retrieve_user_by_username(username=session["username"])
428-
429512
# Redirect user to MFA setup if MFA is not fully set up
430513
if not user.mfa_secrets or not user.mfa_setup_complete:
431514
track_activity(
@@ -435,6 +518,10 @@ def mfa_verify():
435518
)
436519
return redirect(url_for("mfa_setup"))
437520

521+
if _mfa_is_locked_out():
522+
flash('Too many attempts. Please try again later.', 'danger')
523+
return redirect(url_for("login.login"))
524+
438525
form = MFASetupForm()
439526
form.user_password.data = "not required for verification"
440527

@@ -446,19 +533,19 @@ def mfa_verify():
446533

447534
totp = pyotp.TOTP(user.mfa_secrets)
448535
if totp.verify(token):
449-
session.pop("username", None)
450-
session["mfa_verified"] = True
451536
track_activity(
452537
f"MFA verification successful for user {user.user}",
453538
ctx_less=True,
454539
display_in_ui=False,
455540
)
541+
# Bind the MFA-verified marker to this specific user id so that a
542+
# later login attempt for a different user on the same browser
543+
# session cannot reuse it.
544+
session['mfa_verified_for_user_id'] = user.id
545+
_clear_pre_mfa_state()
456546
return wrap_login_user(user)
457-
track_activity(
458-
f"Failed MFA verification for user {user.user}. Invalid token.",
459-
ctx_less=True,
460-
display_in_ui=False,
461-
)
462-
flash("Invalid token. Please try again.", "danger")
547+
else:
548+
_register_mfa_failure(user, 'verification (invalid token)')
549+
flash("Invalid token. Please try again.", "danger")
463550

464551
return render_template("mfa_verify.html", form=form)

source/app/blueprints/pages/login/templates/mfa_setup.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ <h3 class="login__form_title">Your organisation requires to setup MFA</h3>
3232
<div class="form-row ml-2">
3333
<div class="form-group col-12">
3434
{{ form.hidden_tag() }}
35-
{{ form.mfa_secret(size=32, class="hidden", style="display:None;") }}
3635
<label for="token">Password</label>
3736
{{ form.user_password(class="form-control") }}
3837

0 commit comments

Comments
 (0)