diff --git a/source/app/blueprints/case/case_assets_routes.py b/source/app/blueprints/case/case_assets_routes.py index e7409e113..3f83244f3 100644 --- a/source/app/blueprints/case/case_assets_routes.py +++ b/source/app/blueprints/case/case_assets_routes.py @@ -68,6 +68,7 @@ from app.util import ac_case_requires from app.util import response_error from app.util import response_success +from app.util import generate_page_uid case_assets_blueprint = Blueprint('case_assets', __name__, @@ -203,7 +204,8 @@ def add_asset_modal(caseid): ioc = get_iocs(caseid) attributes = get_default_custom_attributes('asset') - return render_template("modal_add_case_multi_asset.html", form=form, asset=None, ioc=ioc, attributes=attributes) + return render_template("modal_add_case_multi_asset.html", form=form, asset=None, ioc=ioc, attributes=attributes, + page_uid=generate_page_uid()) @case_assets_blueprint.route('/case/assets/add', methods=['POST']) @@ -390,7 +392,8 @@ def asset_view_modal(cur_id, caseid, url_redir): comments_map = get_case_assets_comments_count([cur_id]) return render_template("modal_add_case_asset.html", form=form, asset=asset, map={}, ioc=case_iocs, - ioc_prefill=ioc_prefill, attributes=asset.custom_attributes, comments_map=comments_map) + ioc_prefill=ioc_prefill, attributes=asset.custom_attributes, comments_map=comments_map, + page_uid=generate_page_uid()) @case_assets_blueprint.route('/case/assets/update/', methods=['POST']) diff --git a/source/app/blueprints/case/case_ioc_routes.py b/source/app/blueprints/case/case_ioc_routes.py index 51834f464..9816ade3b 100644 --- a/source/app/blueprints/case/case_ioc_routes.py +++ b/source/app/blueprints/case/case_ioc_routes.py @@ -61,6 +61,7 @@ from app.util import ac_case_requires from app.util import response_error from app.util import response_success +from app.util import generate_page_uid from app.business.iocs import create from app.business.iocs import update from app.business.iocs import delete @@ -241,7 +242,8 @@ def case_add_ioc_modal(caseid): attributes = get_default_custom_attributes('ioc') - return render_template("modal_add_case_ioc.html", form=form, ioc=Ioc(), attributes=attributes) + return render_template("modal_add_case_ioc.html", form=form, ioc=Ioc(), attributes=attributes, + page_uid=generate_page_uid()) @case_ioc_blueprint.route('/case/ioc/delete/', methods=['POST']) @@ -277,7 +279,7 @@ def case_view_ioc_modal(cur_id, caseid, url_redir): comments_map = get_case_iocs_comments_count([cur_id]) return render_template("modal_add_case_ioc.html", form=form, ioc=ioc, attributes=ioc.custom_attributes, - comments_map=comments_map) + comments_map=comments_map, page_uid=generate_page_uid()) @case_ioc_blueprint.route('/case/ioc/', methods=['GET']) diff --git a/source/app/blueprints/case/case_rfiles_routes.py b/source/app/blueprints/case/case_rfiles_routes.py index 09308de45..16f301f28 100644 --- a/source/app/blueprints/case/case_rfiles_routes.py +++ b/source/app/blueprints/case/case_rfiles_routes.py @@ -52,6 +52,7 @@ from app.util import ac_case_requires from app.util import response_error from app.util import response_success +from app.util import generate_page_uid case_rfiles_blueprint = Blueprint( 'case_rfiles', @@ -149,14 +150,15 @@ def case_edit_rfile_modal(cur_id, caseid, url_redir): comments_map = get_case_evidence_comments_count([cur_id]) return render_template("modal_add_case_rfile.html", rfile=crf, attributes=crf.custom_attributes, - comments_map=comments_map) + comments_map=comments_map, page_uid=generate_page_uid()) @case_rfiles_blueprint.route('/case/evidences/add/modal', methods=['GET']) @ac_api_case_requires(CaseAccessLevel.full_access) def case_add_rfile_modal(caseid): - return render_template("modal_add_case_rfile.html", rfile=None, attributes=get_default_custom_attributes('evidence')) + return render_template("modal_add_case_rfile.html", rfile=None, attributes=get_default_custom_attributes('evidence'), + page_uid=generate_page_uid()) @case_rfiles_blueprint.route('/case/evidences/update/', methods=['POST']) diff --git a/source/app/blueprints/case/case_tasks_routes.py b/source/app/blueprints/case/case_tasks_routes.py index 999500ce3..b64772b70 100644 --- a/source/app/blueprints/case/case_tasks_routes.py +++ b/source/app/blueprints/case/case_tasks_routes.py @@ -59,6 +59,7 @@ from app.util import ac_case_requires from app.util import response_error from app.util import response_success +from app.util import generate_page_uid case_tasks_blueprint = Blueprint('case_tasks', __name__, @@ -138,7 +139,7 @@ def case_add_task_modal(caseid): form.task_assignees_id.choices = [] return render_template("modal_add_case_task.html", form=form, task=task, uid=current_user.id, user_name=None, - attributes=task.custom_attributes) + attributes=task.custom_attributes, page_uid=generate_page_uid()) @case_tasks_blueprint.route('/case/tasks/add', methods=['POST']) @@ -207,7 +208,7 @@ def case_task_view_modal(cur_id, caseid, url_redir): comments_map = get_case_tasks_comments_count([task.id]) return render_template("modal_add_case_task.html", form=form, task=task, user_name=user_name, - comments_map=comments_map, attributes=task.custom_attributes) + comments_map=comments_map, attributes=task.custom_attributes, page_uid=generate_page_uid()) @case_tasks_blueprint.route('/case/tasks/update/', methods=['POST']) diff --git a/source/app/blueprints/case/case_timeline_routes.py b/source/app/blueprints/case/case_timeline_routes.py index 9816bc38b..9c1ee0a67 100644 --- a/source/app/blueprints/case/case_timeline_routes.py +++ b/source/app/blueprints/case/case_timeline_routes.py @@ -81,6 +81,7 @@ from app.util import add_obj_history_entry from app.util import response_error from app.util import response_success +from app.util import generate_page_uid event_tags = ["Network", "Server", "ActiveDirectory", "Computer", "Malware", "User Interaction"] @@ -759,7 +760,8 @@ def event_view_modal(cur_id, caseid, url_redir): return render_template("modal_add_case_event.html", form=form, event=event, user_name=usr_name, tags=event_tags, assets=assets, iocs=iocs, comments_map=comments_map, assets_prefill=assets_prefill, iocs_prefill=iocs_prefill, - category=event.category, attributes=event.custom_attributes) + category=event.category, attributes=event.custom_attributes, + page_uid=generate_page_uid()) @case_timeline_blueprint.route('/case/timeline/events/update/', methods=["POST"]) @@ -837,7 +839,7 @@ def case_add_event_modal(caseid): return render_template("modal_add_case_event.html", form=form, event=event, tags=event_tags, assets=assets, iocs=iocs, assets_prefill=None, category=def_cat, - attributes=event.custom_attributes) + attributes=event.custom_attributes, page_uid=generate_page_uid()) @case_timeline_blueprint.route('/case/timeline/filter-help/modal', methods=['GET']) diff --git a/source/app/blueprints/manage/manage_attributes_routes.py b/source/app/blueprints/manage/manage_attributes_routes.py index bf8fcfd16..270da1cb3 100644 --- a/source/app/blueprints/manage/manage_attributes_routes.py +++ b/source/app/blueprints/manage/manage_attributes_routes.py @@ -34,6 +34,7 @@ from app.util import ac_requires from app.util import response_error from app.util import response_success +from app.util import generate_page_uid manage_attributes_blueprint = Blueprint('manage_attributes', __name__, template_folder='templates') @@ -104,7 +105,7 @@ def attributes_preview(caseid, url_redir): except Exception as e: return response_error("Invalid JSON", data=str(e)) - templated = render_template("modal_preview_attribute.html", attributes=attribute) + templated = render_template("modal_preview_attribute.html", attributes=attribute, page_uid=generate_page_uid()) return response_success(data=templated) diff --git a/source/app/blueprints/manage/manage_cases_routes.py b/source/app/blueprints/manage/manage_cases_routes.py index b0d6580ad..8ba750945 100644 --- a/source/app/blueprints/manage/manage_cases_routes.py +++ b/source/app/blueprints/manage/manage_cases_routes.py @@ -67,6 +67,7 @@ from app.util import ac_requires from app.util import response_error from app.util import response_success +from app.util import generate_page_uid from app.business.cases import delete from app.business.cases import update from app.business.cases import create @@ -124,7 +125,7 @@ def details_case(cur_id: int, caseid: int, url_redir: bool) -> Union[str, Respon print(res.get('status_name')) return render_template("modal_case_info_from_case.html", data=res, form=form, protagonists=protagonists, case_classifications=case_classifications, case_states=case_states, customers=customers, - severities=severities) + severities=severities, page_uid=generate_page_uid()) else: return response_error("Unknown case") @@ -340,7 +341,7 @@ def add_case_modal(): attributes = get_default_custom_attributes('case') - return render_template('modal_add_case.html', form=form, attributes=attributes) + return render_template('modal_add_case.html', form=form, attributes=attributes, page_uid=generate_page_uid()) @manage_cases_blueprint.route('/manage/cases/add', methods=['POST']) diff --git a/source/app/blueprints/manage/manage_customers_routes.py b/source/app/blueprints/manage/manage_customers_routes.py index aff4c6d49..f996408a0 100644 --- a/source/app/blueprints/manage/manage_customers_routes.py +++ b/source/app/blueprints/manage/manage_customers_routes.py @@ -58,6 +58,7 @@ from app.util import page_not_found from app.util import response_error from app.util import response_success +from app.util import generate_page_uid manage_customers_blueprint = Blueprint( 'manage_customers', @@ -312,7 +313,7 @@ def view_customer_modal(client_id, caseid, url_redir): form.customer_sla.data = customer.sla return render_template("modal_add_customer.html", form=form, customer=customer, - attributes=customer.custom_attributes) + attributes=customer.custom_attributes, page_uid=generate_page_uid()) @manage_customers_blueprint.route('/manage/customers/update/', methods=['POST']) @@ -346,7 +347,8 @@ def add_customers_modal(caseid, url_redir): return redirect(url_for('manage_customers.manage_customers', cid=caseid)) form = AddCustomerForm() attributes = get_default_custom_attributes('client') - return render_template("modal_add_customer.html", form=form, customer=None, attributes=attributes) + return render_template("modal_add_customer.html", form=form, customer=None, attributes=attributes, + page_uid=generate_page_uid()) @manage_customers_blueprint.route('/manage/customers/add', methods=['POST']) diff --git a/source/app/util.py b/source/app/util.py index 4bdff48fe..41d85b72d 100644 --- a/source/app/util.py +++ b/source/app/util.py @@ -504,6 +504,19 @@ def regenerate_session(): session.modified = True +def generate_page_uid() -> str: + """Generate a short, DOM-safe, per-render-unique id prefix. + + Namespaces the ids `modal_attributes_tabs.html` / `modal_attributes_nav.html` generate for + custom-attribute tabs and fields (see dfir-iris/iris-web#1111). Bootstrap 4's `data-toggle="pill"` + resolves its target pane via a document-global CSS id lookup on the link's `href`, not scoped to + the surrounding `.tab-content` — so every render must get its own unique prefix or two + simultaneously-rendered attribute-tab blocks collide and tab-switching breaks. + """ + return f'pu{uuid.uuid4().hex[:8]}_' + + + def api_login_required(f): @wraps(f) def wrap(*args, **kwargs): diff --git a/tests/iris.py b/tests/iris.py index 5f377b40d..bef41d0cc 100644 --- a/tests/iris.py +++ b/tests/iris.py @@ -17,16 +17,24 @@ # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. from pathlib import Path +import re import shutil import time +import requests from docker_compose import DockerCompose from rest_api import RestApi from server_timeout_error import ServerTimeoutError API_URL = 'http://127.0.0.1:8000' +# Cookie-session routes need this, not API_URL: SESSION_COOKIE_SECURE=True (source/app/configuration.py) +# means Flask's session cookie is never sent back over plain HTTP, only through nginx/HTTPS. +HTTPS_URL = 'https://127.0.0.1' _API_KEY = 'B8BA5D730210B50F41C06941582D7965D57319D5685440587F98DFDC45A01594' _IRIS_PATH = Path('..') _TEST_DATA_PATH = Path('./data') +# Matches IRIS_ADM_USERNAME (default) / IRIS_ADM_PASSWORD in tests/data/basic.env. +_ADMIN_USERNAME = 'administrator' +_ADMIN_PASSWORD = 'MySuperAdminPassword!' class Iris: @@ -102,3 +110,26 @@ def get_cases(self): def get_cases_filter(self): return self._api.get('/manage/cases/filter') + + def get_authenticated_session(self): + """Log in with a real cookie session (not the API key) for routes gated by @ac_requires(), + which read session['permissions'] directly and 500 if authenticated via API key only. + Must go through nginx/HTTPS (HTTPS_URL) -- SESSION_COOKIE_SECURE=True means the session + cookie is dropped by the client on a plain-HTTP round trip against API_URL.""" + session = requests.Session() + session.verify = False + login_page = session.get(f'{HTTPS_URL}/login').text + csrf_match = re.search(r'name="csrf_token"[^>]*value="([^"]+)"', login_page) + if not csrf_match: + raise ServerTimeoutError('Could not find CSRF token on login page') + session.post(f'{HTTPS_URL}/login', data={ + 'csrf_token': csrf_match.group(1), + 'username': _ADMIN_USERNAME, + 'password': _ADMIN_PASSWORD, + }) + return session + + def get_html(self, session, path, case_id=None): + params = {'cid': case_id} if case_id is not None else None + response = session.get(f'{HTTPS_URL}{path}', params=params) + return response.status_code, response.text diff --git a/tests/rest_api.py b/tests/rest_api.py index 12e35a4c4..cc10c9188 100644 --- a/tests/rest_api.py +++ b/tests/rest_api.py @@ -37,6 +37,12 @@ def get(self, path, query_parameters=None): print(f'GET {url} => {response.status_code} {body}') return body + def get_raw(self, path, query_parameters=None): + url = self._build_url(path) + response = requests.get(url, headers=self._headers, params=query_parameters) + print(f'GET {url} => {response.status_code} ({len(response.text)} bytes, non-JSON)') + return response.status_code, response.text + def post(self, path, payload, query_parameters=None): url = self._build_url(path) response = requests.post(url, headers=self._headers, params=query_parameters, json=payload) diff --git a/tests/tests.py b/tests/tests.py index 7c9f83659..90b6816f8 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -16,6 +16,8 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. from unittest import TestCase +import json +import re from iris import Iris @@ -67,4 +69,51 @@ def test_update_case_should_not_require_case_name_issue_358(self): case = self._subject.create_case() case_identifier = case['case_id'] response = self._subject.update_case(case_identifier, {'case_tags': 'test,example'}) - self.assertEqual('success', response['status']) \ No newline at end of file + self.assertEqual('success', response['status']) + + def test_custom_attribute_tabs_have_unique_ids_across_cases(self): + # Look up the 'case' attribute definition dynamically -- do not hardcode its id, + # the seeded attribute_id ordering is an implementation detail, not a contract. + attributes = self._subject._api.get('/manage/attributes/list')['data'] + case_attribute = next(a for a in attributes if a['attribute_for'] == 'case') + + schema = { + 'Investigation Metadata': { + 'MITRE ATT&CK Tactic': {'type': 'input_string', 'mandatory': False, 'value': ''}, + } + } + + # Create the cases BEFORE (re-)applying the attribute definition. New cases are not seeded + # with custom_attributes at creation time (that only happens via the "Add case" modal echoing + # its pre-fetched defaults back in the payload) -- but updating the attribute definition runs + # update_all_attributes(), which retroactively backfills every case whose custom_attributes is + # still None. Creating first and updating second reliably exercises that backfill path. + case_a = self._subject.create_case() + case_b = self._subject.create_case() + + response = self._subject._api.post( + f'/manage/attributes/update/{case_attribute["attribute_id"]}', + {'attribute_content': json.dumps(schema), 'partial_overwrite': True, 'complete_overwrite': False} + ) + self.assertEqual('success', response['status']) + + session = self._subject.get_authenticated_session() + status_a, html_a = self._subject.get_html(session, f'/case/details/{case_a["case_id"]}', case_id=case_a['case_id']) + status_b, html_b = self._subject.get_html(session, f'/case/details/{case_b["case_id"]}', case_id=case_b['case_id']) + + self.assertEqual(200, status_a) + self.assertEqual(200, status_b) + + # "Info" must still be the default active tab in both renders. + self.assertIn('class="nav-link active show" id="pills-home-tab-nobd"', html_a) + self.assertIn('class="nav-link active show" id="pills-home-tab-nobd"', html_b) + + # The generated tab id for the custom attribute pane must differ between two + # independently-rendered cases -- this is false on the unfixed code (both render + # "1_investigation_metadata" verbatim) and true once page_uid is a real per-render value. + id_pattern = re.compile(r'id="(\w*1_investigation_metadata)"') + id_a = id_pattern.search(html_a) + id_b = id_pattern.search(html_b) + self.assertIsNotNone(id_a, 'Investigation Metadata tab pane not found in case A details HTML') + self.assertIsNotNone(id_b, 'Investigation Metadata tab pane not found in case B details HTML') + self.assertNotEqual(id_a.group(1), id_b.group(1)) \ No newline at end of file