diff --git a/source/app/blueprints/pages/login/login_routes.py b/source/app/blueprints/pages/login/login_routes.py
index 10dbde2d1..51353a6a1 100644
--- a/source/app/blueprints/pages/login/login_routes.py
+++ b/source/app/blueprints/pages/login/login_routes.py
@@ -22,8 +22,8 @@
import qrcode
import random
import string
-import json
import time
+import json
from flask import Blueprint, flash
from flask import redirect
from flask import render_template
@@ -139,6 +139,11 @@ def _authenticate_password(form, username, password):
@login_blueprint.route("/login", methods=["GET", "POST"])
def login():
if iris_current_user.is_authenticated:
+ # If the old Jinja UI is the one receiving the post-OIDC
+ # redirect, the SPA-only JWT exchange marker never gets
+ # consumed. Drop it here so it can't linger and be
+ # redeemed later by a rogue XHR from the same browser.
+ session.pop("oidc_authenticated", None)
return redirect(url_for("index.index"))
if (
@@ -352,12 +357,27 @@ def oidc_authorise():
]
update_user_groups(user.id, new_user_group)
- return wrap_login_user(user, is_oidc=True)
+ # Mark this session as OIDC-authenticated so the SPA can call
+ # /api/v2/auth/oidc-exchange exactly once to trade the session
+ # cookie for JWT tokens. Single-use: the exchange endpoint
+ # clears both this flag and the session immediately, so a
+ # stolen cookie can't be redeemed twice.
+ session["oidc_authenticated"] = True
+
+ wrap_login_user(user, is_oidc=True)
+
+ # Regardless of `next`, send the browser to the SPA's login
+ # page with an OIDC marker. The SPA's onMount detects the
+ # marker, calls oidc-exchange, populates the JWT store, and
+ # then navigates to `/`. Overriding wrap_login_user's redirect
+ # target here also blocks an open-redirect via ?next=... on
+ # the OIDC callback.
+ return redirect("/login?oidc=1")
# MFA hardening constants. Values are deliberately conservative — a legitimate
# user mistypes their token occasionally; an attacker brute-forcing 10^6 TOTP
-# codes should not be able to linearly grind them.
+# codes should not be able to linearly grind them. Backport of f596481b.
_MFA_MAX_ATTEMPTS = 5
_MFA_LOCKOUT_SECONDS = 15 * 60
@@ -369,50 +389,50 @@ def _get_pre_mfa_user():
successful password (or LDAP) check. Any MFA handler that runs without it
is being hit directly by an attacker and must be refused.
"""
- pre_mfa_user_id = session.get('pre_mfa_user_id')
+ pre_mfa_user_id = session.get("pre_mfa_user_id")
if pre_mfa_user_id is None:
return None
- return get_user(pre_mfa_user_id, id_key='id')
+ return get_user(pre_mfa_user_id, id_key="id")
def _clear_pre_mfa_state(preserve_lockout=False):
"""Clear the markers that prove this session just passed password auth.
preserve_lockout: when True, keep the lockout timestamp and fail counter
- so that an attacker cannot wipe a brute-force lockout simply by hitting
- /login again.
+ so an attacker cannot wipe a brute-force lockout simply by hitting /login
+ again.
"""
- session.pop('pre_mfa_user_id', None)
- session.pop('pending_mfa_secret', None)
+ session.pop("pre_mfa_user_id", None)
+ session.pop("pending_mfa_secret", None)
if not preserve_lockout:
- session.pop('mfa_fail_count', None)
- session.pop('mfa_lockout_until', None)
+ session.pop("mfa_fail_count", None)
+ session.pop("mfa_lockout_until", None)
def _mfa_is_locked_out():
- locked_until = session.get('mfa_lockout_until')
+ locked_until = session.get("mfa_lockout_until")
if locked_until and locked_until > time.time():
return True
if locked_until and locked_until <= time.time():
- # Lockout expired — reset the counter so the user gets a fresh window.
- session.pop('mfa_lockout_until', None)
- session['mfa_fail_count'] = 0
+ session.pop("mfa_lockout_until", None)
+ session["mfa_fail_count"] = 0
return False
def _register_mfa_failure(user, reason):
- session['mfa_fail_count'] = session.get('mfa_fail_count', 0) + 1
+ session["mfa_fail_count"] = session.get("mfa_fail_count", 0) + 1
track_activity(
- f'Failed MFA {reason} for user {user.user} (attempt {session["mfa_fail_count"]}/{_MFA_MAX_ATTEMPTS})',
- ctx_less=True, display_in_ui=False
+ f"Failed MFA {reason} for user {user.user} "
+ f"(attempt {session['mfa_fail_count']}/{_MFA_MAX_ATTEMPTS})",
+ ctx_less=True, display_in_ui=False,
)
- if session['mfa_fail_count'] >= _MFA_MAX_ATTEMPTS:
- session['mfa_lockout_until'] = time.time() + _MFA_LOCKOUT_SECONDS
+ if session["mfa_fail_count"] >= _MFA_MAX_ATTEMPTS:
+ session["mfa_lockout_until"] = time.time() + _MFA_LOCKOUT_SECONDS
# Drop the pending-MFA marker so the attacker has to go back through
# password auth before they get another burst of attempts. Preserve
# the lockout timestamp so a fresh /login cannot wipe it.
_clear_pre_mfa_state(preserve_lockout=True)
- session.pop('username', None)
+ session.pop("username", None)
@app.route("/auth/mfa-setup", methods=["GET", "POST"])
@@ -430,7 +450,7 @@ def mfa_setup():
return redirect(url_for("mfa_verify"))
if _mfa_is_locked_out():
- flash('Too many attempts. Please try again later.', 'danger')
+ flash("Too many attempts. Please try again later.", "danger")
return redirect(url_for("login.login"))
form = MFASetupForm()
@@ -442,9 +462,9 @@ def mfa_setup():
# The secret MUST come from the server-side session, never from the
# submitted form. Trusting form.mfa_secret let an attacker enrol a
# secret of their choosing and immediately log in.
- mfa_secret = session.get('pending_mfa_secret')
+ mfa_secret = session.get("pending_mfa_secret")
if not mfa_secret:
- flash('MFA setup expired. Please restart the login flow.', 'danger')
+ flash("MFA setup expired. Please restart the login flow.", "danger")
return redirect(url_for("login.login"))
totp = pyotp.TOTP(mfa_secret)
@@ -463,7 +483,7 @@ def mfa_setup():
has_valid_password = True
if not has_valid_password:
- _register_mfa_failure(user, 'setup (invalid password)')
+ _register_mfa_failure(user, "setup (invalid password)")
flash("Invalid password. Please try again.", "danger")
return render_template("mfa_setup.html", form=form)
@@ -472,24 +492,23 @@ def mfa_setup():
db.session.commit()
track_activity(
f"MFA setup successful for user {user.user}",
- ctx_less=True,
- display_in_ui=False,
+ ctx_less=True, display_in_ui=False,
)
+
# Setup succeeded — promote this session to MFA-verified for this
# user and clear the pre-MFA markers. Without this, wrap_login_user
# would loop straight back to mfa_verify.
- session['mfa_verified_for_user_id'] = user.id
+ session["mfa_verified_for_user_id"] = user.id
_clear_pre_mfa_state()
return wrap_login_user(user)
- else:
- _register_mfa_failure(user, 'setup (invalid token)')
- flash("Invalid token or password. Please try again.", "danger")
+ _register_mfa_failure(user, "setup (invalid token)")
+ flash("Invalid token or password. Please try again.", "danger")
# Generate a fresh secret on every GET and stash it in the session. The
# client only sees the QR code and the base32 key for manual entry; it
# never sends the secret back.
temp_otp_secret = pyotp.random_base32()
- session['pending_mfa_secret'] = temp_otp_secret
+ session["pending_mfa_secret"] = temp_otp_secret
otp_uri = pyotp.TOTP(temp_otp_secret).provisioning_uri(
user.email, issuer_name="IRIS"
)
@@ -513,13 +532,12 @@ def mfa_verify():
if not user.mfa_secrets or not user.mfa_setup_complete:
track_activity(
f"MFA setup required for user {user.user}",
- ctx_less=True,
- display_in_ui=False,
+ ctx_less=True, display_in_ui=False,
)
return redirect(url_for("mfa_setup"))
if _mfa_is_locked_out():
- flash('Too many attempts. Please try again later.', 'danger')
+ flash("Too many attempts. Please try again later.", "danger")
return redirect(url_for("login.login"))
form = MFASetupForm()
@@ -535,17 +553,15 @@ def mfa_verify():
if totp.verify(token):
track_activity(
f"MFA verification successful for user {user.user}",
- ctx_less=True,
- display_in_ui=False,
+ ctx_less=True, display_in_ui=False,
)
# Bind the MFA-verified marker to this specific user id so that a
# later login attempt for a different user on the same browser
# session cannot reuse it.
- session['mfa_verified_for_user_id'] = user.id
+ session["mfa_verified_for_user_id"] = user.id
_clear_pre_mfa_state()
return wrap_login_user(user)
- else:
- _register_mfa_failure(user, 'verification (invalid token)')
- flash("Invalid token. Please try again.", "danger")
+ _register_mfa_failure(user, "verification (invalid token)")
+ flash("Invalid token. Please try again.", "danger")
return render_template("mfa_verify.html", form=form)
diff --git a/source/app/blueprints/rest/alerts_routes.py b/source/app/blueprints/rest/alerts_routes.py
index 49dc9e793..8c454cd2e 100644
--- a/source/app/blueprints/rest/alerts_routes.py
+++ b/source/app/blueprints/rest/alerts_routes.py
@@ -66,15 +66,20 @@
alerts_rest_blueprint = Blueprint('alerts_rest', __name__)
-# Fields that must be immutable on alert update. Allowing them via the API
-# lets a user with write access to one customer re-attribute an alert to a
-# customer they cannot see — planting fake alerts or (with an XSS vector)
-# making another user move an alert into an attacker-controlled customer.
-# See SBA-ADV-20260128-05 / CWE-863.
+# Fields that must be immutable on alert update. The web UI never changes
+# these, and allowing them via the API lets a user with write access to one
+# customer re-attribute an alert to a customer they cannot see — either to
+# plant fake alerts under another customer's name, or (combined with an XSS
+# vector) to make another user move an alert into an attacker-controlled
+# customer. See GHSA-8hwq-v6vm-9grr / SBA-ADV-20260128-05 / CWE-863.
+#
+# alert_id: primary key, must not be rewritten
+# alert_customer_id: ownership, re-attribution bypasses customer-scoped ACL
+# alert_creation_time: audit integrity; set once at creation
_ALERT_UPDATE_READONLY_FIELDS = frozenset({
- 'alert_id', # primary key, must not be rewritten
- 'alert_customer_id', # ownership — re-attribution bypasses customer ACL
- 'alert_creation_time', # audit integrity; set once at creation
+ 'alert_id',
+ 'alert_customer_id',
+ 'alert_creation_time',
})
@@ -350,8 +355,10 @@ def alerts_update_route(alert_id) -> Response:
do_status_hook = False
try:
- # Load the JSON data from the request. Drop fields the caller must not
- # be allowed to change (SBA-ADV-20260128-05 / CWE-863).
+ # Drop fields the caller must not be allowed to change on update
+ # (GHSA-8hwq-v6vm-9grr / SBA-ADV-20260128-05 / CWE-863). Done before
+ # any other processing so these values never reach the activity log
+ # or the ORM.
data = _strip_readonly_update_fields(request.get_json())
activity_data = []
@@ -430,7 +437,10 @@ def alerts_batch_update_route() -> Response:
# Load the JSON data from the request
data = request.get_json()
- # Get the list of alert IDs and updates from the request data
+ # Get the list of alert IDs and updates from the request data. Strip
+ # immutable fields from the batch payload so one API call can't silently
+ # re-attribute every selected alert to a different customer
+ # (GHSA-8hwq-v6vm-9grr / SBA-ADV-20260128-05 / CWE-863).
alert_ids: List[int] = data.get('alert_ids', [])
updates = _strip_readonly_update_fields(data.get('updates', {}))
diff --git a/source/app/blueprints/rest/api_auth.py b/source/app/blueprints/rest/api_auth.py
index ac0e99b5b..b8ff21658 100644
--- a/source/app/blueprints/rest/api_auth.py
+++ b/source/app/blueprints/rest/api_auth.py
@@ -5,9 +5,24 @@
from app import app
from app.business.users import users_get_active
+from app.models.errors import ObjectNotFoundError
from app.blueprints.rest.endpoints import response_api_error
+def _safe_get_active(user_id):
+ # The identifier on a token/session may point at a user that has since
+ # been deleted or deactivated. Callers translate the None into either
+ # "invalid" (JWT — where the caller *did* present a signed identity
+ # that we're rejecting) or a plain unauthenticated fallthrough for
+ # legacy/session paths. Without this we'd propagate
+ # ObjectNotFoundError up to the Flask exception handler and log a
+ # 500 for what is really just a stale credential.
+ try:
+ return users_get_active(user_id)
+ except ObjectNotFoundError:
+ return None
+
+
def _jwt_user():
auth = request.headers.get("Authorization")
if not auth or not auth.startswith("Bearer "):
@@ -25,19 +40,29 @@ def _jwt_user():
if payload.get("type") != "access":
return "invalid"
- return users_get_active(payload["user_id"])
+ # Step-1 tokens (password validated but MFA not yet verified) must not
+ # admit the bearer to protected endpoints. Treating them as `invalid`
+ # rather than `None` short-circuits the legacy/session fallthrough — a
+ # step-1 access token shouldn't quietly promote to a session login.
+ if payload.get("mfa_required") and not payload.get("mfa_verified"):
+ return "invalid"
+
+ user = _safe_get_active(payload["user_id"])
+ # Signed token for a user that no longer exists → reject outright,
+ # don't quietly fall through to legacy/session auth.
+ return user if user is not None else "invalid"
def _legacy_token_user():
if not hasattr(g, "auth_user"):
return None
- return users_get_active(g.auth_user["user_id"])
+ return _safe_get_active(g.auth_user["user_id"])
def _session_user():
if not current_user.is_authenticated:
return None
- return users_get_active(current_user.id)
+ return _safe_get_active(current_user.id)
def api_auth(*, require_mfa: bool = False):
diff --git a/source/app/blueprints/rest/api_v2_routes.py b/source/app/blueprints/rest/api_v2_routes.py
index 467658d61..c6494c0dc 100644
--- a/source/app/blueprints/rest/api_v2_routes.py
+++ b/source/app/blueprints/rest/api_v2_routes.py
@@ -18,6 +18,7 @@
from flask import Blueprint
+from app.blueprints.rest.v2.activities import activities_blueprint
from app.blueprints.rest.v2.alerts import alerts_blueprint
from app.blueprints.rest.v2.assets import assets_blueprint
from app.blueprints.rest.v2.events import events_blueprint
@@ -25,14 +26,28 @@
from app.blueprints.rest.v2.notes import notes_blueprint
from app.blueprints.rest.v2.auth import auth_blueprint
from app.blueprints.rest.v2.cases import cases_blueprint
+from app.blueprints.rest.v2.custom_dashboards import custom_dashboards_blueprint
from app.blueprints.rest.v2.dashboard import dashboard_blueprint
+from app.blueprints.rest.v2.dim_tasks import dim_tasks_blueprint
from app.blueprints.rest.v2.global_tasks import global_tasks_blueprint
from app.blueprints.rest.v2.iocs import iocs_blueprint
from app.blueprints.rest.v2.manage import manage_v2_blueprint
from app.blueprints.rest.v2.tags import tags_blueprint
from app.blueprints.rest.v2.tasks import tasks_blueprint
from app.blueprints.rest.v2.profile import profile_blueprint
+from app.blueprints.rest.v2.search import search_blueprint
from app.blueprints.rest.v2.alerts_filters import alerts_filters_blueprint
+from app.blueprints.rest.v2.avatars import admin_avatar_blueprint
+from app.blueprints.rest.v2.avatars import me_avatar_blueprint
+from app.blueprints.rest.v2.avatars import users_public_blueprint
+from app.blueprints.rest.v2.cases_filters import cases_filters_blueprint
+from app.blueprints.rest.v2.war_rooms.root import war_rooms_blueprint
+from app.blueprints.rest.v2.notifications import notifications_blueprint
+from app.blueprints.rest.v2.notifications import admin_notifications_blueprint
+from app.blueprints.rest.v2.mail import mail_blueprint
+from app.blueprints.rest.v2.alert_clusters import alert_clusters_blueprint
+from app.blueprints.rest.v2.cluster_rules import cluster_rules_blueprint
+from app.blueprints.rest.v2.investigation_flows import investigation_flows_blueprint
# Create root /api/v2 blueprint
@@ -49,8 +64,23 @@
rest_v2_blueprint.register_blueprint(evidences_blueprint)
rest_v2_blueprint.register_blueprint(notes_blueprint)
rest_v2_blueprint.register_blueprint(alerts_blueprint)
+rest_v2_blueprint.register_blueprint(custom_dashboards_blueprint)
rest_v2_blueprint.register_blueprint(dashboard_blueprint)
rest_v2_blueprint.register_blueprint(manage_v2_blueprint)
rest_v2_blueprint.register_blueprint(tags_blueprint)
rest_v2_blueprint.register_blueprint(profile_blueprint)
+rest_v2_blueprint.register_blueprint(search_blueprint)
rest_v2_blueprint.register_blueprint(alerts_filters_blueprint)
+rest_v2_blueprint.register_blueprint(cases_filters_blueprint)
+rest_v2_blueprint.register_blueprint(users_public_blueprint)
+rest_v2_blueprint.register_blueprint(me_avatar_blueprint)
+rest_v2_blueprint.register_blueprint(admin_avatar_blueprint)
+rest_v2_blueprint.register_blueprint(activities_blueprint)
+rest_v2_blueprint.register_blueprint(dim_tasks_blueprint)
+rest_v2_blueprint.register_blueprint(war_rooms_blueprint)
+rest_v2_blueprint.register_blueprint(notifications_blueprint)
+rest_v2_blueprint.register_blueprint(admin_notifications_blueprint)
+rest_v2_blueprint.register_blueprint(mail_blueprint)
+rest_v2_blueprint.register_blueprint(alert_clusters_blueprint)
+rest_v2_blueprint.register_blueprint(cluster_rules_blueprint)
+rest_v2_blueprint.register_blueprint(investigation_flows_blueprint)
diff --git a/source/app/blueprints/rest/case/case_notes_routes.py b/source/app/blueprints/rest/case/case_notes_routes.py
index 4e008105e..a1fece6e0 100644
--- a/source/app/blueprints/rest/case/case_notes_routes.py
+++ b/source/app/blueprints/rest/case/case_notes_routes.py
@@ -43,6 +43,7 @@
from app.datamgmt.case.case_notes_db import get_case_note_comment
from app.datamgmt.case.case_notes_db import get_case_note_comments
from app.datamgmt.case.case_notes_db import get_note
+from app.datamgmt.case.case_notes_db import update_note_revision
from app.datamgmt.states import get_notes_state
from app.iris_engine.module_handler.module_handler import call_modules_hook
from app.iris_engine.utils.tracker import track_activity
@@ -152,6 +153,55 @@ def case_note_save(cur_id, caseid):
return response_error(e.get_message(), data=e.get_data())
+@case_notes_rest_blueprint.route('/case/notes/
/collab/persist', methods=['POST'])
+@ac_requires_case_identifier(CaseAccessLevel.full_access)
+@ac_api_requires()
+def case_note_collab_persist(cur_id, caseid):
+ try:
+ note = get_note(cur_id)
+ if not note or note.note_case_id != caseid:
+ return response_error('Invalid note ID for this case')
+
+ request_data = request.get_json(silent=True) or {}
+ if not isinstance(request_data.get('note_content'), str):
+ return response_error('Data error', data={'note_content': ['Missing note content']})
+
+ note.note_content = request_data.get('note_content')
+ note = notes_update(iris_current_user, note)
+
+ return response_success('ok', data={
+ 'persisted': True,
+ 'client_hash': request_data.get('client_hash')
+ })
+
+ except BusinessProcessingError as e:
+ return response_error(e.get_message(), data=e.get_data())
+
+ except Exception:
+ app.logger.exception('Unable to persist collab note %s', cur_id)
+ return response_error('Unable to persist note')
+
+
+@case_notes_rest_blueprint.route('/case/notes//collab/snapshot', methods=['POST'])
+@ac_requires_case_identifier(CaseAccessLevel.full_access)
+@ac_api_requires()
+def case_note_collab_snapshot(cur_id, caseid):
+ try:
+ note = get_note(cur_id)
+ if not note or note.note_case_id != caseid:
+ return response_error('Invalid note ID for this case')
+
+ created = update_note_revision(iris_current_user.id, note)
+
+ return response_success('ok', data={
+ 'revision_created': created
+ })
+
+ except Exception:
+ app.logger.exception('Unable to snapshot collab note %s', cur_id)
+ return response_error('Unable to snapshot note')
+
+
@case_notes_rest_blueprint.route('/case/notes//revisions/list', methods=['GET'])
@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access)
@ac_api_requires()
diff --git a/source/app/blueprints/rest/case/case_routes.py b/source/app/blueprints/rest/case/case_routes.py
index d3a141205..ca2cbd0e9 100644
--- a/source/app/blueprints/rest/case/case_routes.py
+++ b/source/app/blueprints/rest/case/case_routes.py
@@ -94,6 +94,40 @@ def desc_fetch(caseid):
return response_success('Summary updated', data=crc)
+@case_rest_blueprint.route('/case/summary/collab/persist', methods=['POST'])
+@ac_requires_case_identifier(CaseAccessLevel.full_access)
+@ac_api_requires()
+def summary_collab_persist(caseid):
+ try:
+ js_data = request.get_json(silent=True) or {}
+ if not isinstance(js_data.get('case_description'), str):
+ return response_error('Data error', data={'case_description': ['Missing case description']})
+
+ case = get_case(caseid)
+ if not case:
+ return response_error('Invalid case ID')
+
+ case.description = js_data.get('case_description')
+ crc = binascii.crc32(case.description.encode('utf-8'))
+ db.session.commit()
+ track_activity('persisted summary collaboration', caseid)
+
+ socket_io.emit('save', {
+ 'case_description': case.description,
+ 'last_saved': iris_current_user.user
+ }, to=f'case-{caseid}')
+
+ return response_success('ok', data={
+ 'persisted': True,
+ 'crc32': crc,
+ 'client_hash': js_data.get('client_hash')
+ })
+
+ except Exception:
+ log.exception('Unable to persist collab summary for case %s', caseid)
+ return response_error('Unable to persist summary')
+
+
@case_rest_blueprint.route('/case/summary/fetch', methods=['GET'])
@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access)
@ac_api_requires()
diff --git a/source/app/blueprints/rest/case/case_timeline_routes.py b/source/app/blueprints/rest/case/case_timeline_routes.py
index 2c8a83cbc..907f5aaff 100644
--- a/source/app/blueprints/rest/case/case_timeline_routes.py
+++ b/source/app/blueprints/rest/case/case_timeline_routes.py
@@ -60,6 +60,7 @@
from app.models.authorization import CaseAccessLevel
from app.models.authorization import User
from app.models.cases import CasesEvent
+from app.models.cases import CaseEventTimeline
from app.models.models import CaseEventsAssets
from app.models.models import CaseEventsIoc
from app.models.models import EventCategory
@@ -327,8 +328,8 @@ def case_gettimeline_api(asset_id, caseid):
@case_timeline_rest_blueprint.route('/case/timeline/advanced-filter', methods=['GET'])
-@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access)
@ac_api_requires()
+@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access)
def case_filter_timeline(caseid):
args = request.args.to_dict()
query_filter = args.get('q')
@@ -360,9 +361,97 @@ def case_filter_timeline(caseid):
sources = filter_d.get('source')
flag = filter_d.get('flag')
+ try:
+ page = max(1, int(args.get('page', 1)))
+ except (TypeError, ValueError):
+ page = 1
+
+ try:
+ per_page = int(args.get('per_page', 0))
+ except (TypeError, ValueError):
+ per_page = 0
+ if per_page < 0:
+ per_page = 0
+ if per_page > 500:
+ per_page = 500
+
cache, events_list, tim = _extract_timeline(assets, assets_id, caseid, categories, descriptions, end_date, event_ids,
flag, iocs, iocs_id, raws, sources, start_date, tags, titles)
+ total = len(tim)
+ if per_page > 0:
+ last_page = max(1, (total + per_page - 1) // per_page)
+ if page > last_page:
+ page = last_page
+ start = (page - 1) * per_page
+ end = start + per_page
+ tim_page = tim[start:end]
+ next_page = page + 1 if page < last_page else None
+
+ # Drag in descendants that live on later pages AND ancestors that
+ # live on earlier pages, so any event on this slice ships with its
+ # full lineage. Without this:
+ # - a child whose parent is on a later page would appear as a
+ # spurious root until pagination catches up (the frontend
+ # promotes events with unknown parent_event_id to roots).
+ # - a child whose parent is on the current page but whose own
+ # event_date falls into a later slice would be missing from
+ # the parent's children until later.
+ # Ancestors that arrived on a previous page are already cached
+ # client-side, but re-sending them is cheap and keeps each page
+ # self-consistent if loaded out of order.
+ all_by_id: dict[int, dict] = {event['event_id']: event for event in tim}
+ children_by_parent: dict[int, list[dict]] = {}
+ for event in tim:
+ parent_id = event.get('parent_event_id')
+ if parent_id is None:
+ continue
+ children_by_parent.setdefault(parent_id, []).append(event)
+
+ page_ids = {event['event_id'] for event in tim_page}
+
+ # Descendants
+ queue = list(page_ids)
+ while queue:
+ parent_id = queue.pop()
+ for child in children_by_parent.get(parent_id, ()):
+ cid = child['event_id']
+ if cid in page_ids:
+ continue
+ page_ids.add(cid)
+ tim_page.append(child)
+ queue.append(cid)
+
+ # Ancestors
+ queue = list(page_ids)
+ while queue:
+ event_id = queue.pop()
+ event = all_by_id.get(event_id)
+ if not event:
+ continue
+ parent_id = event.get('parent_event_id')
+ if parent_id is None or parent_id in page_ids:
+ continue
+ parent = all_by_id.get(parent_id)
+ if parent is None:
+ continue
+ page_ids.add(parent_id)
+ tim_page.append(parent)
+ queue.append(parent_id)
+ else:
+ last_page = 1
+ page = 1
+ tim_page = tim
+ next_page = None
+
+ pagination = {
+ "total": total,
+ "per_page": per_page if per_page > 0 else total,
+ "current_page": page,
+ "last_page": last_page,
+ "next_page": next_page
+ }
+
if request.cookies.get('session'):
iocs = Ioc.query.with_entities(
@@ -379,18 +468,20 @@ def case_filter_timeline(caseid):
events_comments_map.setdefault(k, []).append(v)
resp = {
- "tim": tim,
+ "tim": tim_page,
"comments_map": events_comments_map,
"assets": cache,
"iocs": [ioc._asdict() for ioc in iocs],
"categories": [cat.name for cat in get_events_categories()],
- "state": get_timeline_state(caseid=caseid)
+ "state": get_timeline_state(caseid=caseid),
+ "pagination": pagination
}
else:
resp = {
- "timeline": tim,
- "state": get_timeline_state(caseid=caseid)
+ "timeline": tim_page,
+ "state": get_timeline_state(caseid=caseid),
+ "pagination": pagination
}
return response_success("ok", data=resp)
@@ -562,6 +653,22 @@ def _extract_timeline(assets: str | None, assets_id: str | None, caseid, categor
if assets_map[event_id] == len_assets:
assets_filter.append(event_id)
+ # Build {event_id -> [timeline_id, ...]} for every event we're
+ # about to ship so the SPA sidebar can filter on-the-fly without a
+ # second round-trip. Pre-computing once per request keeps this O(N)
+ # instead of one query per event in the hot loop below.
+ event_ids_in_scope = [row.event_id for row in timeline]
+ timelines_by_event: dict[int, list[int]] = {}
+ if event_ids_in_scope:
+ rows = (
+ CaseEventTimeline.query
+ .with_entities(CaseEventTimeline.event_id, CaseEventTimeline.timeline_id)
+ .filter(CaseEventTimeline.event_id.in_(event_ids_in_scope))
+ .all()
+ )
+ for r in rows:
+ timelines_by_event.setdefault(r.event_id, []).append(r.timeline_id)
+
iocs_filter = []
if iocs:
for ioc in iocs_cache:
@@ -593,7 +700,10 @@ def _extract_timeline(assets: str | None, assets_id: str | None, caseid, categor
if asset.event_id == ras['event_id']:
alki.append(
{
+ "id": asset.asset_id,
"name": f"{asset.asset_name} ({asset.type})",
+ "asset_name": asset.asset_name,
+ "asset_type": asset.type,
"ip": asset.asset_ip,
"description": asset.asset_description,
"compromised": asset.asset_compromise_status_id == CompromiseStatus.compromised.value
@@ -609,12 +719,15 @@ def _extract_timeline(assets: str | None, assets_id: str | None, caseid, categor
alki.append(
{
+ "id": ioc.ioc_id,
"name": f"{ioc.ioc_value}",
+ "ioc_value": ioc.ioc_value,
"description": ioc.ioc_description
}
)
ras['iocs'] = alki
+ ras['timeline_ids'] = timelines_by_event.get(ras['event_id'], [])
tim.append(ras)
return cache, events_list, tim
diff --git a/source/app/blueprints/rest/dashboard_routes.py b/source/app/blueprints/rest/dashboard_routes.py
index 1eafa68ad..9b7e15f5b 100644
--- a/source/app/blueprints/rest/dashboard_routes.py
+++ b/source/app/blueprints/rest/dashboard_routes.py
@@ -68,8 +68,11 @@
)
-# Logout user
-@dashboard_rest_blueprint.route('/logout')
+# Logout user — POST-only to prevent CSRF. A plain
on a
+# third-party page would otherwise log the user out of IRIS without consent
+# (RFC 7231 §4.2.1: GET must be safe; CWE-650; GHSA-8hwq-v6vm-9grr;
+# SBA-ADV-20260128-03).
+@dashboard_rest_blueprint.route('/logout', methods=['POST'])
def logout():
"""
Logout function. Erase its session and redirect to index i.e login
diff --git a/source/app/blueprints/rest/datastore_routes.py b/source/app/blueprints/rest/datastore_routes.py
index 11090116c..69eb56031 100644
--- a/source/app/blueprints/rest/datastore_routes.py
+++ b/source/app/blueprints/rest/datastore_routes.py
@@ -54,33 +54,26 @@
datastore_rest_blueprint = Blueprint('datastore_rest', __name__)
-# Allowlist of form fields accepted for file add / update operations.
-# Restricting to this set prevents a caller from injecting arbitrary model
-# columns (e.g. file_local_name, file_sha256, file_size) through the
-# multipart form payload — closing a path-traversal / data-integrity vector
-# (SBA-ADV-20260128-06, CWE-22 / GHSA-qhqj-vm56-4phr).
-_DS_FILE_FORM_FIELDS = frozenset({
+
+# Allowlist of multipart form fields a caller may write through the file
+# add/update endpoints. Anything else (notably file_id, file_local_name,
+# file_case_id, file_sha256, file_size, added_by_user_id, file_date_added,
+# ...) is dropped before the schema is loaded. Closes the mass-assignment
+# vector reported as GHSA-qhqj-8qw6-wp8v / CWE-915.
+_DS_FILE_WRITABLE_FIELDS = (
'file_original_name',
'file_description',
'file_password',
'file_is_ioc',
'file_is_evidence',
'file_parent_id',
-})
-
-ALLOWED_FIELDS_DS_FILE = [
- 'file_original_name',
- 'file_description',
- 'file_is_ioc',
- 'file_is_evidence',
- 'file_password',
'file_tags',
- 'file_parent_id'
-]
+)
+
-def _filter_ds_form_fields(form):
- """Return a plain dict with only the allowed datastore form fields."""
- return {k: v for k, v in form.items() if k in _DS_FILE_FORM_FIELDS}
+def _filter_ds_file_form(form):
+ """Project a flask.request.form dict down to the writable allowlist."""
+ return {field: form.get(field) for field in _DS_FILE_WRITABLE_FIELDS if field in form}
@datastore_rest_blueprint.route('/datastore/list/tree', methods=['GET'])
@@ -126,7 +119,6 @@ def datastore_info_file(cur_id: int, caseid: int):
file_schema = DSFileSchema()
file = file_schema.dump(file)
- del file['file_local_name']
return response_success("", data=file)
@@ -142,7 +134,7 @@ def datastore_update_file(cur_id: int, caseid: int):
dsf_schema = DSFileSchema()
try:
- dsf_sc = dsf_schema.load(_filter_ds_form_fields(request.form), instance=dsf, partial=True)
+ dsf_sc = dsf_schema.load(_filter_ds_file_form(request.form), instance=dsf, partial=True)
add_obj_history_entry(dsf_sc, 'updated')
dsf.file_is_ioc = request.form.get('file_is_ioc') is not None or request.form.get('file_is_ioc') is True
@@ -247,8 +239,9 @@ def datastore_view_file(cur_id: int, caseid: int):
f'Update or delete virtual entry')
# Keep inline display only for file types the browser cannot execute as
- # script. SVG in particular can embed