Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker/nginx/nginx-newui.conf
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ events {

http {
map $request_uri $csp_header {
default "default-src 'self' https://analytics.dfir-iris.org https://127.0.0.1; script-src 'self' 'unsafe-inline' https://analytics.dfir-iris.org; style-src 'self' 'unsafe-inline'; img-src 'self' data:;";
default "default-src 'self' https://analytics.dfir-iris.org https://127.0.0.1 http://app:8000; script-src 'self' 'unsafe-inline' https://analytics.dfir-iris.org; style-src 'self' 'unsafe-inline'; img-src 'self' data:;";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Updated Content Security Policy to allow connections from the app server.

Adding http://app:8000 to the CSP default-src directive ensures that resources from this origin can be loaded, which is necessary for the token-based authentication functionality.


🌐 Web query:

What are the security implications of adding an HTTP (non-HTTPS) origin to a Content Security Policy?

💡 Result:

Security Implications of Adding an HTTP (Non-HTTPS) Origin to a Content Security Policy

Adding an HTTP (non-HTTPS) origin to a Content Security Policy (CSP) significantly weakens the security protections CSP is meant to provide. Below are the main security implications:

1. Exposure to Man-in-the-Middle (MitM) Attacks

  • HTTP traffic is not encrypted. If your CSP allows resources (like scripts, images, or stylesheets) to load from non-HTTPS origins, attackers can intercept or modify these resources in transit. This enables attackers to inject malicious code or manipulate site behavior, potentially bypassing the very protections CSP is designed to enforce[10].
  • Any data or script loaded over HTTP can be tampered with by anyone able to intercept the network traffic (such as a compromised Wi-Fi network, malicious ISP, or anyone with access to the network path).

2. Mixed Content Weakens HTTPS Guarantees

  • If your main site is served over HTTPS but includes resources from HTTP sources (mixed content), browsers categorize these resources as either "blockable" (e.g., scripts, styles) or "upgradable." Blockable insecure content is typically blocked by modern browsers, but if not, it creates serious vulnerabilities, undermining the integrity and confidentiality guarantees of HTTPS[10].
  • Mixed content can also lead to inconsistent user experiences, as some resources may load (or fail to load) depending on browser security settings.

3. Increased XSS (Cross-Site Scripting) Risk

  • One of CSP’s main goals is to mitigate XSS attacks by controlling script sources[2][3]. Allowing HTTP origins opens a vector for attackers to inject compromised scripts if they are able to tamper with the HTTP traffic. This undermines CSP's effectiveness in protecting against XSS and related attacks.

4. Invalidation of Defense-in-Depth

  • CSP works best when you strictly whitelist secure, trusted origins, preferably over HTTPS[3][5][6]. By adding HTTP origins, you lose the defense-in-depth benefit because the trust boundary is no longer secure.
  • Attackers can exploit the weak link (the HTTP resource) to execute script injections or data exfiltration.

5. Modern Browser Behavior and Best Practices

  • Modern browsers and security guidelines strongly recommend (and often require) that all resources be loaded over HTTPS. Some browsers will block HTTP resources on an HTTPS page, but relying on this behavior rather than strict HTTPS sourcing in your CSP is risky and can lead to unexpected breakage or gaps in protection[10].

Summary Table

Security Implication Description
Susceptible to MitM Attacks HTTP resources can be intercepted and modified by attackers
Mixed Content Weakens HTTPS Compromises confidentiality and integrity of HTTPS pages
Increased XSS Risk Attackers can inject malicious scripts if HTTP resource is hijacked
Loss of Defense-in-Depth Weakens CSP’s effectiveness as a security boundary
Potential Broken Functionality Browsers may block or fail to upgrade HTTP content, causing issues

Best Practice:
Always use HTTPS origins in your CSP directives. If you have legacy content over HTTP, use the upgrade-insecure-requests directive to instruct browsers to upgrade HTTP requests to HTTPS where possible[10].

“Web developers are strongly encouraged to serve all their content over HTTPS... The ultimate solution to mixed content is for developers to load all resources over HTTPS.”[10]

Allowing HTTP origins in a CSP should be avoided, as it defeats the purpose of using CSP for robust web security.

Citations:


Avoid HTTP in CSP: serve the app backend over HTTPS or proxy through Nginx

File: docker/nginx/nginx-newui.conf
Lines: 29-29

Snippet showing the final state at these lines:

        default "default-src 'self' https://analytics.dfir-iris.org https://127.0.0.1 http://app:8000; script-src 'self' 'unsafe-inline' https://analytics.dfir-iris.org; style-src 'self' 'unsafe-inline'; img-src 'self' data:;";

Comment
Adding a non‑HTTPS origin (http://app:8000) to default-src weakens your CSP by introducing mixed‑content, Man‑in‑the‑Middle, and XSS risks[10]. To maintain strong security:

  • Secure your backend with TLS (e.g. https://app:8000) and whitelist that in your CSP instead.
  • Or proxy all API calls through the same HTTPS domain (e.g., under /api), then drop the HTTP origin from your CSP.
  • Optionally, use the upgrade-insecure-requests directive to enforce HTTPS requests.

Please update the CSP directive accordingly.

}
include /etc/nginx/mime.types;

Expand Down
20 changes: 11 additions & 9 deletions source/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,17 @@ def ac_current_user_has_manage_perms():

set_celery_flask_context(celery, app)

if app.config.get('DEVELOPMENT_ENABLED'):
CORS(app,
supports_credentials=True,
resources={r"/api/*": {"origins": [
"https://127.0.0.1:5137",
"https://localhost:5173",
"https://localhost",
"https://127.0.0.1"
]}})
#if app.config.get('DEVELOPMENT_ENABLED'):
CORS(app,
supports_credentials=True,
resources={r"/api/*": {"origins": [
"https://127.0.0.1:5137",
"https://localhost:5173",
"https://localhost",
"https://127.0.0.1",
"http://app:8000",
"http://frontend:5173",
]}})


app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
Expand Down
107 changes: 90 additions & 17 deletions source/app/blueprints/access_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from flask import request
from flask import render_template
from flask import session
from flask import g
from flask_login import current_user
from flask_login import login_user
from flask_wtf import FlaskForm
Expand All @@ -42,9 +43,11 @@
from app import app
from app import db
from app.blueprints.responses import response_error
from app.business.auth import validate_auth_token
from app.datamgmt.case.case_db import get_case
from app.datamgmt.manage.manage_access_control_db import user_has_client_access
from app.datamgmt.manage.manage_users_db import get_user
from app.iris_engine.access_control.iris_user import iris_current_user
from app.iris_engine.access_control.utils import ac_fast_check_user_has_case_access
from app.iris_engine.access_control.utils import ac_get_effective_permissions_of_user
from app.iris_engine.utils.tracker import track_activity
Expand All @@ -55,12 +58,36 @@

def _user_has_at_least_a_required_permission(permissions: list[Permissions]):
"""
Returns true as soon as the user has at least one permission in the list of permissions
Returns true if the list of required permissions is empty
Returns true if the user has at least one of the required permissions
Works with both session-based and token-based authentication
"""
if not permissions:
return True

# For token-based authentication
if hasattr(g, 'auth_token_user_id'):
# Use cached permissions from token if available
if hasattr(g, 'auth_user_permissions'):
user_permissions = g.auth_user_permissions
else:
# Lazy load permissions only once per request
from app.datamgmt.manage.manage_users_db import get_user
user = get_user(g.auth_token_user_id)
if not user:
return False

user_permissions = ac_get_effective_permissions_of_user(user)
g.auth_user_permissions = user_permissions # Cache for this request

for permission in permissions:
if user_permissions & permission.value:
return True
return False

# For session-based authentication
if 'permissions' not in session:
session['permissions'] = ac_get_effective_permissions_of_user(current_user)

Comment on lines +88 to +90
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use iris_current_user consistently in the session path

The token path already switched to iris_current_user.
Retaining current_user here introduces two code‑paths and increases the risk of diverging behaviour.

-        session['permissions'] = ac_get_effective_permissions_of_user(current_user)
+        session['permissions'] = ac_get_effective_permissions_of_user(iris_current_user)

Keeps the logic unified and future‑proof.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if 'permissions' not in session:
session['permissions'] = ac_get_effective_permissions_of_user(current_user)
if 'permissions' not in session:
session['permissions'] = ac_get_effective_permissions_of_user(iris_current_user)

for permission in permissions:
if session['permissions'] & permission.value:
return True
Expand Down Expand Up @@ -182,7 +209,7 @@ def _get_case_access(request_data, access_level, no_cid_required=False):
if ctmp is not None:
return redir, ctmp, has_access

eaccess_level = ac_fast_check_user_has_case_access(current_user.id, caseid, access_level)
eaccess_level = ac_fast_check_user_has_case_access(iris_current_user.id, caseid, access_level)
if eaccess_level is None and access_level:
_update_denied_case(caseid)
return redir, caseid, False
Expand Down Expand Up @@ -218,8 +245,8 @@ def _is_csrf_token_valid():

def _ac_return_access_denied(caseid: int = None):
error_uuid = uuid.uuid4()
log.warning(f"Access denied to case #{caseid} for user ID {current_user.id}. Error {error_uuid}")
return render_template('pages/error-403.html', user=current_user, caseid=caseid, error_uuid=error_uuid,
log.warning(f"Access denied to case #{caseid} for user ID {iris_current_user.id}. Error {error_uuid}")
return render_template('pages/error-403.html', user=iris_current_user, caseid=caseid, error_uuid=error_uuid,
template_folder=TEMPLATE_PATH), 403


Expand Down Expand Up @@ -251,11 +278,11 @@ def get_case_access_from_api(request_data, access_level):
redir, caseid, has_access = _get_caseid_from_request_data(request_data, False)
redir = False

if not hasattr(current_user, 'id'):
if not hasattr(iris_current_user, 'id'):
# Anonymous request, deny access
return False, 1, False

eaccess_level = ac_fast_check_user_has_case_access(current_user.id, caseid, access_level)
eaccess_level = ac_fast_check_user_has_case_access(iris_current_user.id, caseid, access_level)
if eaccess_level is None and access_level:
return redir, caseid, False

Expand Down Expand Up @@ -321,19 +348,37 @@ def inner_wrap(f):
@wraps(f)
def wrap(*args, **kwargs):
if not _is_csrf_token_valid():
return response_error('Invalid CSRF token')
if 'auth_token_user_id' not in g:
return response_error('Invalid CSRF token')

if not is_user_authenticated(request):
return response_error('Authentication required', status=401)

if 'permissions' not in session:
session['permissions'] = ac_get_effective_permissions_of_user(current_user)
# Set the user for token-based authentication
if hasattr(g, 'auth_token_user_id'):
from app.datamgmt.manage.manage_users_db import get_user
user = get_user(g.auth_token_user_id)
if not user:
return response_error('User not found', status=404)

if not _user_has_at_least_a_required_permission(permissions):
return response_error('Permission denied', status=403)
# Create a compatibility layer for token auth
g.token_user = user

# Check permissions
if not _user_has_at_least_a_required_permission(permissions):
return response_error('Permission denied', status=403)
else:
# Session-based auth - use the normal approach
if 'permissions' not in session:
session['permissions'] = ac_get_effective_permissions_of_user(current_user)

if not _user_has_at_least_a_required_permission(permissions):
return response_error('Permission denied', status=403)

return f(*args, **kwargs)

return wrap

return inner_wrap


Expand All @@ -342,7 +387,7 @@ def inner_wrap(f):
@wraps(f)
def wrap(*args, **kwargs):
client_id = kwargs.get('client_id')
if not user_has_client_access(current_user.id, client_id):
if not user_has_client_access(iris_current_user.id, client_id):
return _ac_return_access_denied()

return f(*args, **kwargs)
Expand All @@ -364,7 +409,7 @@ def wrap(*args, **kwargs):
else:
return _ac_return_access_denied(caseid=0)

access = ac_fast_check_user_has_case_access(current_user.id, case_id, access_level)
access = ac_fast_check_user_has_case_access(iris_current_user.id, case_id, access_level)
if not access:
return _ac_return_access_denied(caseid=case_id)

Expand All @@ -375,7 +420,7 @@ def wrap(*args, **kwargs):


def ac_api_return_access_denied(caseid: int = None):
user_id = current_user.id if hasattr(current_user, 'id') else 'Anonymous'
user_id = iris_current_user.id if hasattr(iris_current_user, 'id') else 'Anonymous'
error_uuid = uuid.uuid4()
log.warning(f"EID {error_uuid} - Access denied with case #{caseid} for user ID {user_id} "
f"accessing URI {request.full_path}")
Expand All @@ -392,7 +437,7 @@ def inner_wrap(f):
@wraps(f)
def wrap(*args, **kwargs):
client_id = kwargs.get('client_id')
if not user_has_client_access(current_user.id, client_id):
if not user_has_client_access(iris_current_user.id, client_id):
return response_error("Permission denied", status=403)

return f(*args, **kwargs)
Expand Down Expand Up @@ -497,7 +542,32 @@ def _oidc_proxy_authentication_process(incoming_request: Request):


def _local_authentication_process(incoming_request: Request):
return current_user.is_authenticated
return iris_current_user.is_authenticated


def _token_authentication_process(incoming_request: Request):
"""
Process authentication using an Authorization header with Bearer token
"""
auth_header = incoming_request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return False

parts = auth_header.split(' ')
if len(parts) < 2:
return False

token = parts[1]
user_data = validate_auth_token(token)

if not user_data:
return False

# Store user data for later use
g.auth_user = user_data
g.auth_token_user_id = user_data['user_id']

Comment on lines +548 to +569
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Refresh tokens accepted as access tokens – add type check

validate_auth_token() is invoked blindly; a valid refresh token will happily authenticate an API request because its payload structure matches.
Validate the type claim (or use separate decoding functions):

-    user_data = validate_auth_token(token)
+    user_data = validate_auth_token(token)
+    if user_data and user_data.get('type') == 'refresh':
+        # Prevent privilege escalation with long‑lived refresh tokens
+        return False

Also consider propagating the token’s exp claim into g.auth_token_expires_at for rate‑limiting / analytics.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _token_authentication_process(incoming_request: Request):
"""
Process authentication using an Authorization header with Bearer token
"""
auth_header = incoming_request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return False
parts = auth_header.split(' ')
if len(parts) < 2:
return False
token = parts[1]
user_data = validate_auth_token(token)
if not user_data:
return False
# Store user data for later use
g.auth_user = user_data
g.auth_token_user_id = user_data['user_id']
def _token_authentication_process(incoming_request: Request):
"""
Process authentication using an Authorization header with Bearer token
"""
auth_header = incoming_request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return False
parts = auth_header.split(' ')
if len(parts) < 2:
return False
token = parts[1]
user_data = validate_auth_token(token)
if user_data and user_data.get('type') == 'refresh':
# Prevent privilege escalation with long‑lived refresh tokens
return False
if not user_data:
return False
# Store user data for later use
g.auth_user = user_data
g.auth_token_user_id = user_data['user_id']

return True


def is_user_authenticated(incoming_request: Request):
Expand All @@ -508,6 +578,9 @@ def is_user_authenticated(incoming_request: Request):
"oidc": _local_authentication_process,
}

if _token_authentication_process(incoming_request):
return True

return authentication_mapper.get(app.config.get("AUTHENTICATION_TYPE"))(incoming_request)


Expand Down
4 changes: 2 additions & 2 deletions source/app/blueprints/graphql/graphql_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
from flask import request
from flask_wtf import FlaskForm
from flask import Blueprint
from flask_login import current_user

from graphql_server.flask import GraphQLView
from graphene import ObjectType
Expand Down Expand Up @@ -51,6 +50,7 @@

from app.business.cases import cases_get_by_identifier
from app.business.iocs import iocs_get
from app.iris_engine.access_control.iris_user import iris_current_user
from app.blueprints.graphql.permissions import permissions_check_current_user_has_some_case_access
import warnings

Expand All @@ -70,7 +70,7 @@ class Query(ObjectType):
@staticmethod
def resolve_cases(root, info, classification_id=None, client_id=None, state_id=None, owner_id=None, open_date=None, name=None, soc_id=None,
severity_id=None, tags=None, open_since=None, **kwargs):
return build_filter_case_query(current_user.id, start_open_date=open_date, end_open_date=None, case_customer_id=client_id, case_ids=None,
return build_filter_case_query(iris_current_user.id, start_open_date=open_date, end_open_date=None, case_customer_id=client_id, case_ids=None,
case_name=name, case_description=None, case_classification_id=classification_id, case_owner_id=owner_id,
case_opening_user_id=None, case_severity_id=severity_id, case_state_id=state_id, case_soc_id=soc_id,
case_tags=tags, case_open_since=open_since)
Expand Down
4 changes: 2 additions & 2 deletions source/app/blueprints/graphql/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@
from uuid import uuid4

from flask import session
from flask_login import current_user
from flask import request

from app.blueprints.access_controls import get_case_access_from_api
from app.iris_engine.access_control.iris_user import iris_current_user
from app.iris_engine.access_control.utils import ac_get_effective_permissions_of_user
from app.iris_engine.access_control.utils import ac_fast_check_current_user_has_case_access

Expand Down Expand Up @@ -66,7 +66,7 @@ def permissions_check_current_user_has_some_case_access_stricter(access_levels):
# this method is used to replace annotation ac_api_requires
def permissions_check_current_user_has_some_permission(permissions):
if 'permissions' not in session:
session['permissions'] = ac_get_effective_permissions_of_user(current_user)
session['permissions'] = ac_get_effective_permissions_of_user(iris_current_user)

for permission in permissions:
if session['permissions'] & permission.value:
Expand Down
4 changes: 2 additions & 2 deletions source/app/blueprints/pages/alerts/alerts_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
from flask import render_template
from flask import redirect
from flask import url_for
from flask_login import current_user
from flask_wtf import FlaskForm
from typing import Union
from werkzeug import Response
Expand All @@ -30,6 +29,7 @@
from app.models.authorization import Permissions
from app.blueprints.responses import response_error
from app.blueprints.access_controls import ac_requires
from app.iris_engine.access_control.iris_user import iris_current_user

alerts_blueprint = Blueprint(
'alerts',
Expand Down Expand Up @@ -78,7 +78,7 @@ def alert_comment_modal(cur_id, caseid, url_redir):
if not alert:
return response_error('Invalid alert ID')

if not user_has_client_access(current_user.id, alert.alert_customer_id):
if not user_has_client_access(iris_current_user.id, alert.alert_customer_id):
return response_error('User not entitled to update alerts for the client', status=403)

return render_template("modal_conversation.html", element_id=cur_id, element_type='alerts',
Expand Down
4 changes: 2 additions & 2 deletions source/app/blueprints/pages/case/case_tasks_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
from flask import redirect
from flask import render_template
from flask import url_for
from flask_login import current_user
from flask_wtf import FlaskForm

from app.iris_engine.access_control.iris_user import iris_current_user
from app.datamgmt.case.case_db import get_case
from app.datamgmt.case.case_tasks_db import get_case_tasks_comments_count
from app.datamgmt.case.case_tasks_db import get_task
Expand Down Expand Up @@ -65,7 +65,7 @@ def case_add_task_modal(caseid, url_redir):
form.task_status_id.choices = [(a.id, a.status_name) for a in get_tasks_status()]
form.task_assignees_id.choices = []

return render_template("modal_add_case_task.html", form=form, task=task, uid=current_user.id, user_name=None,
return render_template("modal_add_case_task.html", form=form, task=task, uid=iris_current_user.id, user_name=None,
attributes=task.custom_attributes)


Expand Down
6 changes: 3 additions & 3 deletions source/app/blueprints/pages/dashboard/dashboard_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@
from flask import redirect
from flask import render_template
from flask import url_for
from flask_login import current_user
from flask_wtf import FlaskForm

from app import app
from app.iris_engine.access_control.iris_user import iris_current_user
from app.datamgmt.dashboard.dashboard_db import get_tasks_status
from app.forms import CaseGlobalTaskForm
from app.iris_engine.access_control.utils import ac_get_user_case_counts
Expand Down Expand Up @@ -60,7 +60,7 @@ def index(caseid, url_redir):

msg = None

acgucc = ac_get_user_case_counts(current_user.id)
acgucc = ac_get_user_case_counts(iris_current_user.id)

data = {
"user_open_count": acgucc[2],
Expand All @@ -87,7 +87,7 @@ def add_gtask_modal(caseid, url_redir):
form.task_assignee_id.choices = [(user.id, user.name) for user in User.query.filter(User.active == True).order_by(User.name).all()]
form.task_status_id.choices = [(a.id, a.status_name) for a in get_tasks_status()]

return render_template("modal_add_global_task.html", form=form, task=task, uid=current_user.id, user_name=None)
return render_template("modal_add_global_task.html", form=form, task=task, uid=iris_current_user.id, user_name=None)


@dashboard_blueprint.route('/global/tasks/update/<int:cur_id>/modal', methods=['GET'])
Expand Down
Loading