CVE-2024-36110: Insufficient Information (NVD-CWE-noinfo)
value: ansibleguy-webui is an open source WebUI for using Ansible. Multiple forms in versions < 0.0.21 allowed injection of HTML elements. These are returned to the user after executing job actions and thus evaluated by the browser. These issues have been addressed in version 0.0.21 (0.0.21.post2 on pypi). Users are advised to upgrade. There are no known workarounds for these issues.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-05-28T19:15Z
Repository: https://github.com/ansibleguy/webui
Language: Python
Files changed: CHANGELOG.md, src/ansibleguy-webui/aw/api_endpoints/alert.py, src/ansibleguy-webui/aw/api_endpoints/base.py, src/ansibleguy-webui/aw/api_endpoints/credentials.py, src/ansibleguy-webui/aw/api_endpoints/job.py, src/ansibleguy-webui/aw/api_endpoints/permission.py, src/ansibleguy-webui/aw/api_endpoints/repository.py, src/ansibleguy-webui/aw/static/js/aw.js, src/ansibleguy-webui/aw/static/js/jobs/logs.js, src/ansibleguy-webui/aw/static/js/jobs/manage.js
Commit date: 2024-05-20 11:10:34 +0200
Commit message: added validation against XSS (#44)

Diff: diff --git a/CHANGELOG.md b/CHANGELOG.md
index d45f465..be025e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,11 +2,11 @@
 
 ## Version 0
 
 ### 0.0.21
 
-* ...
+* Added [validation against XSS](https://github.com/ansibleguy/webui/issues/44)
 
 ----
 
 ### 0.0.20
 
diff --git a/src/ansibleguy-webui/aw/api_endpoints/alert.py b/src/ansibleguy-webui/aw/api_endpoints/alert.py
index 046160b..1a1f93d 100644
--- a/src/ansibleguy-webui/aw/api_endpoints/alert.py
+++ b/src/ansibleguy-webui/aw/api_endpoints/alert.py
@@ -5,11 +5,11 @@ from rest_framework import serializers
 from rest_framework.response import Response
 from drf_spectacular.utils import extend_schema, OpenApiResponse
 
 from aw.model.job import Job
 from aw.api_endpoints.base import API_PERMISSION, GenericResponse, get_api_user, api_docs_put, api_docs_delete, \
-    api_docs_post
+    api_docs_post, validate_no_xss
 from aw.utils.permission import has_manager_privileges
 from aw.model.alert import BaseAlert, AlertPlugin, AlertGlobal, AlertGroup, AlertUser
 
 
 def update_jobs(alert: BaseAlert, job_ids: list):
@@ -195,10 +195,17 @@ class AlertUserReadResponse(serializers.ModelSerializer):
 class AlertUserWriteRequest(BaseAlertWriteRequest):
     class Meta:
         model = AlertUser
         fields = AlertUser.api_fields_write
 
+    def validate(self, attrs: dict):
+        for field in AlertUser.api_fields_write:
+            if field in attrs:
+                validate_no_xss(value=attrs[field], field=field)
+
+        return attrs
+
 
 class APIAlertUser(GenericAPIView):
     http_method_names = ['get', 'post']
     serializer_class = AlertUserReadResponse
     permission_classes = API_PERMISSION
@@ -350,10 +357,17 @@ class AlertGlobalReadResponse(serializers.ModelSerializer):
 class AlertGlobalWriteRequest(BaseAlertWriteRequest):
     class Meta:
         model = AlertGlobal
         fields = AlertGlobal.api_fields_write
 
+    def validate(self, attrs: dict):
+        for field in AlertGlobal.api_fields_write:
+            if field in attrs:
+                validate_no_xss(value=attrs[field], field=field)
+
+        return attrs
+
 
 class APIAlertGlobal(GenericAPIView):
     http_method_names = ['get', 'post']
     serializer_class = AlertGlobalReadResponse
     permission_classes = API_PERMISSION
@@ -511,10 +525,17 @@ class AlertGroupReadResponse(serializers.ModelSerializer):
 class AlertGroupWriteRequest(BaseAlertWriteRequest):
     class Meta:
         model = AlertGroup
         fields = AlertGroup.api_fields_write
 
+    def validate(self, attrs: dict):
+        for field in AlertGroup.api_fields_write:
+            if field in attrs:
+                validate_no_xss(value=attrs[field], field=field)
+
+        return attrs
+
 
 class APIAlertGroup(GenericAPIView):
     http_method_names = ['get', 'post']
     serializer_class = AlertGroupReadResponse
     permission_classes = API_PERMISSION
diff --git a/src/ansibleguy-webui/aw/api_endpoints/base.py b/src/ansibleguy-webui/aw/api_endpoints/base.py
index 663ba08..d2b2f15 100644
--- a/src/ansibleguy-webui/aw/api_endpoints/base.py
+++ b/src/ansibleguy-webui/aw/api_endpoints/base.py
@@ -1,16 +1,19 @@
 from django.conf import settings
 from django.contrib.auth.models import AnonymousUser
 from django.core.exceptions import ObjectDoesNotExist
 from django.http import JsonResponse
+from django.utils.html import escape as escape_html
 from rest_framework import serializers
+from rest_framework.exceptions import ValidationError
 from rest_framework.permissions import IsAuthenticated
 from rest_framework_api_key.permissions import BaseHasAPIKey
 from drf_spectacular.utils import OpenApiResponse
 
 from aw.model.api import AwAPIKey
 from aw.base import USERS, GROUPS
+from aw.utils.util import is_set
 
 
 class HasAwAPIKey(BaseHasAPIKey):
     model = AwAPIKey
 
@@ -86,5 +89,10 @@ def api_docs_post(item: str) -> dict:
 
 
 def not_implemented(*args, **kwargs):
     del args, kwargs
     return JsonResponse({'error': 'Not yet implemented'}, status=404)
+
+
+def validate_no_xss(value: str, field: str):
+    if is_set(value) and isinstance(value, str) and value != escape_html(value):
+        raise ValidationError(f"Found illegal characters in field '{field}'")
diff --git a/src/ansibleguy-webui/aw/api_endpoints/credentials.py b/src/ansibleguy-webui/aw/api_endpoints/credentials.py
index 68c23a6..3f0dfc8 100644
--- a/src/ansibleguy-webui/aw/api_endpoints/credentials.py
+++ b/src/ansibleguy-webui/aw/api_endpoints/credentials.py
@@ -7,11 +7,11 @@ from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParamet
 
 from aw.model.job import Job, JobExecution
 from aw.model.job_credential import BaseJobCredentials, JobUserCredentials, JobGlobalCredentials
 from aw.model.permission import CHOICE_PERMISSION_READ, CHOICE_PERMISSION_WRITE, CHOICE_PERMISSION_DELETE
 from aw.api_endpoints.base import API_PERMISSION, get_api_user, GenericResponse, BaseResponse, api_docs_delete, \
-    api_docs_put, api_docs_post
+    api_docs_put, api_docs_post, validate_no_xss
 from aw.utils.permission import has_credentials_permission, has_manager_privileges
 from aw.config.hardcoded import SECRET_HIDDEN
 from aw.utils.util import is_null
 from aw.base import USERS
 
@@ -48,16 +48,30 @@ class JobGlobalCredentialsWriteRequest(serializers.ModelSerializer):
     vault_pass = serializers.CharField(max_length=100, required=False, default=None, allow_blank=True)
     become_pass = serializers.CharField(max_length=100, required=False, default=None, allow_blank=True)
     connect_pass = serializers.CharField(max_length=100, required=False, default=None, allow_blank=True)
     ssh_key = serializers.CharField(max_length=5000, required=False, default=None, allow_blank=True)
 
+    def validate(self, attrs: dict):
+        for field in JobGlobalCredentials.api_fields_write:
+            if field in attrs and field not in BaseJobCredentials.SECRET_ATTRS:
+                validate_no_xss(value=attrs[field], field=field)
+
+        return attrs
+
 
 class JobUserCredentialsWriteRequest(JobGlobalCredentialsWriteRequest):
     class Meta:
         model = JobUserCredentials
         fields = JobUserCredentials.api_fields_write
 
+    def validate(self, attrs: dict):
+        for field in JobUserCredentials.api_fields_write:
+            if field in attrs and field not in BaseJobCredentials.SECRET_ATTRS:
+                validate_no_xss(value=attrs[field], field=field)
+
+        return attrs
+
 
 def are_global_credentials(request) -> bool:
     if 'global' in request.GET and request.GET['global'] != 'true':
         return False
 
diff --git a/src/ansibleguy-webui/aw/api_endpoints/job.py b/src/ansibleguy-webui/aw/api_endpoints/job.py
index 1791e48..60ad61b 100644
--- a/src/ansibleguy-webui/aw/api_endpoints/job.py
+++ b/src/ansibleguy-webui/aw/api_endpoints/job.py
@@ -9,11 +9,11 @@ from aw.config.hardcoded import JOB_EXECUTION_LIMIT
 from aw.model.job import Job, JobExecution
 from aw.model.permission import CHOICE_PERMISSION_READ, CHOICE_PERMISSION_EXECUTE, \
     CHOICE_PERMISSION_WRITE, CHOICE_PERMISSION_DELETE
 from aw.model.job_credential import JobGlobalCredentials
 from aw.api_endpoints.base import API_PERMISSION, get_api_user, BaseResponse, GenericResponse, \
-    LogDownloadResponse, api_docs_put, api_docs_delete, api_docs_post
+    LogDownloadResponse, api_docs_put, api_docs_delete, api_docs_post, validate_no_xss
 from aw.api_endpoints.job_util import get_viewable_jobs_serialized, JobReadResponse, get_job_executions_serialized, \
     JobExecutionReadResponse, get_viewable_jobs, get_job_execution_serialized, get_log_file_content
 from aw.utils.permission import has_job_permission, has_credentials_permission, has_manager_privileges
 from aw.execute.queue import queue_add
 from aw.execute.util import update_status, is_execution_status
@@ -26,10 +26,17 @@ class JobWriteRequest(serializers.ModelSerializer):
         model = Job
         fields = Job.api_fields_write
 
     name = serializers.CharField(validators=[])  # uc on update
 
+    def validate(self, attrs: dict):
+        for field in Job.api_fields_write:
+            if field in attrs:
+                validate_no_xss(value=attrs[field], field=field)
+
+        return attrs
+
 
 def _find_job(job_id: int) -> (Job, None):
     try:
         return Job.objects.get(id=job_id)
 
diff --git a/src/ansibleguy-webui/aw/api_endpoints/permission.py b/src/ansibleguy-webui/aw/api_endpoints/permission.py
index 33d52a4..9952bef 100644
--- a/src/ansibleguy-webui/aw/api_endpoints/permission.py
+++ b/src/ansibleguy-webui/aw/api_endpoints/permission.py
@@ -8,11 +8,11 @@ from drf_spectacular.utils import extend_schema
 from aw.model.job import Job
 from aw.model.job_credential import JobGlobalCredentials
 from aw.model.permission import JobPermission, JobPermissionMapping, JobPermissionMemberUser, \
     JobPermissionMemberGroup, JobCredentialsPermissionMapping, JobRepositoryPermissionMapping
 from aw.api_endpoints.base import API_PERMISSION, GenericResponse, get_api_user, api_docs_put, api_docs_delete, \
-    api_docs_post
+    api_docs_post, validate_no_xss
 from aw.utils.permission import has_manager_privileges
 from aw.utils.util import is_set
 from aw.base import USERS, GROUPS
 from aw.model.repository import Repository
 
@@ -58,10 +58,17 @@ class PermissionWriteRequest(serializers.ModelSerializer):
             choices=[repo.id for repo in Repository.objects.all()]
         )
         self.fields['users'] = serializers.MultipleChoiceField(choices=[user.id for user in USERS.objects.all()])
         self.fields['groups'] = serializers.MultipleChoiceField(choices=[group.id for group in GROUPS.objects.all()])
 
+    def validate(self, attrs: dict):
+        for field in JobPermission.api_fields_write:
+            if field in attrs:
+                validate_no_xss(value=attrs[field], field=field)
+
+        return attrs
+
     @staticmethod
     def create_or_update(validated_data: dict, perm: JobPermission = None):
         # pylint: disable=R0912,R0915
         if 'permission' in validated_data:
             permission = validated_data['permission']
diff --git a/src/ansibleguy-webui/aw/api_endpoints/repository.py b/src/ansibleguy-webui/aw/api_endpoints/repository.py
index aa57e2a..25734c2 100644
--- a/src/ansibleguy-webui/aw/api_endpoints/repository.py
+++ b/src/ansibleguy-webui/aw/api_endpoints/repository.py
@@ -8,11 +8,11 @@ from rest_framework import serializers
 from rest_framework.response import Response
 from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParameter
 
 from aw.model.repository import Repository
 from aw.api_endpoints.base import API_PERMISSION, GenericResponse, get_api_user, LogDownloadResponse, api_docs_put, \
-    api_docs_delete, api_docs_post
+    api_docs_delete, api_docs_post, validate_no_xss
 from aw.utils.permission import has_manager_privileges, has_repository_permission, get_viewable_repositories
 from aw.model.job import Job
 from aw.utils.util import unset_or_null, is_set
 from aw.model.permission import CHOICE_PERMISSION_READ, CHOICE_PERMISSION_WRITE, CHOICE_PERMISSION_DELETE, \
     CHOICE_PERMISSION_EXECUTE
@@ -25,10 +25,17 @@ class RepositoryWriteRequest(serializers.ModelSerializer):
         model = Repository
         fields = Repository.api_fields_write
 
     name = serializers.CharField(validators=[])  # uc on update
 
+    def validate(self, attrs: dict):
+        for field in Repository.api_fields_write:
+            if field in attrs:
+                validate_no_xss(value=attrs[field], field=field)
+
+        return attrs
+
 
 class RepositoryReadResponse(RepositoryWriteRequest):
     class Meta:
         model = Repository
         fields = Repository.api_fields_read
diff --git a/src/ansibleguy-webui/aw/static/js/aw.js b/src/ansibleguy-webui/aw/static/js/aw.js
index 35e3e15..6bad72d 100644
--- a/src/ansibleguy-webui/aw/static/js/aw.js
+++ b/src/ansibleguy-webui/aw/static/js/aw.js
@@ -204,17 +204,36 @@ function sortTable($sortButton, order = 'desc') {
             switching = true;
         }
     }
 }
 
+function escapeRegExp(string) {
+    return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
+}
+
+function replaceAll(str, search, replace) {
+    return str.replace(new RegExp(escapeRegExp(search), 'g'), replace);
+}
+
+// see: https://docs.djangoproject.com/en/5.0/ref/templates/language/#automatic-html-escaping
+function escapeHTML(data) {
+    if (!is_set(data)) {
+        return data;
+    }
+    data = data.replaceAll('<', '&lt;');
+    data = data.replaceAll('>', '&gt;');
+    data = data.replaceAll('&', '&amp;');
+    return data;
+}
+
 // API CALLS
 const CSRF_TOKEN = getCookie('csrftoken');
 
 function apiActionSuccess(result) {
     resultDiv = document.getElementById('aw-api-result');
     if (result.msg) {
-        resultDiv.innerHTML = 'Success: ' + result.msg;
+        resultDiv.innerHTML = 'Success: ' + escapeHTML(result.msg);
     } else {
         resultDiv.innerHTML = 'Success';
     }
     apiActionErrorClear();
     $('html, body').animate({ scrollTop: 0 }, 'fast');
@@ -232,11 +251,11 @@ function apiActionError(result, exception) {
 
     // inform user about the error and allow to toggle/show the full/verbose error message
     errorDiv =  document.getElementById('aw-api-error');
     let errorHTML = "Got error: " + result.statusText + ' (' + result.status + ')';
     if (is_set(result.responseJSON.msg)) {
-        errorHTML += ' - ' + result.responseJSON.msg;
+        errorHTML += ' - ' + escapeHTML(result.responseJSON.msg);
     } else {
         errorHTML += '<br><button class="btn btn-warning aw-btn-action" title="Full error" onclick="apiActionFullError()">Show full error</button><br>';
     }
     errorDiv.innerHTML = errorHTML;
 
diff --git a/src/ansibleguy-webui/aw/static/js/jobs/logs.js b/src/ansibleguy-webui/aw/static/js/jobs/logs.js
index 66e444b..6a92a5a 100644
--- a/src/ansibleguy-webui/aw/static/js/jobs/logs.js
+++ b/src/ansibleguy-webui/aw/static/js/jobs/logs.js
@@ -1,13 +1,5 @@
-function escapeRegExp(string) {
-    return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
-}
-
-function replaceAll(str, search, replace) {
-    return str.replace(new RegExp(escapeRegExp(search), 'g'), replace);
-}
-
 function addLogLines($this) {
     let logParentElement = $this.attr("aw-expand");
     let logElement = $this.attr("aw-log");
     let logElementEnd = document.getElementById($this.attr("aw-log-end"));
     let job_id = $this.attr("aw-job");
diff --git a/src/ansibleguy-webui/aw/static/js/jobs/manage.js b/src/ansibleguy-webui/aw/static/js/jobs/manage.js
index e140854..5af5fe5 100644
--- a/src/ansibleguy-webui/aw/static/js/jobs/manage.js
+++ b/src/ansibleguy-webui/aw/static/js/jobs/manage.js
@@ -37,10 +37,12 @@ function updateApiTableDataJob(row, row2, entry) {
 
     let actionsTemplate = document.getElementById(ELEM_ID_TMPL_ACTIONS).innerHTML;
     actionsTemplate = actionsTemplate.replaceAll('${ID}', entry.id);
     if (lastExecution != null) {
         actionsTemplate = actionsTemplate.replaceAll('${EXEC_ID_1}', lastExecution.id);
+    } else {
+        actionsTemplate = actionsTemplate.replaceAll('${EXEC_ID_1}', 0);
     }
     row.cells[7].innerHTML = actionsTemplate;
 
     // execution stati
     row2.innerHTML = document.getElementById(ELEM_ID_TMPL_ROW).innerHTML;

Vulnerable code: * ...
    api_docs_post
    api_docs_put, api_docs_post
    LogDownloadResponse, api_docs_put, api_docs_delete, api_docs_post
    api_docs_post
    api_docs_delete, api_docs_post
        resultDiv.innerHTML = 'Success: ' + result.msg;
        errorHTML += ' - ' + result.responseJSON.msg;
function escapeRegExp(string) {
    return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}

function replaceAll(str, search, replace) {
    return str.replace(new RegExp(escapeRegExp(search), 'g'), replace);
}

Fixed code: * Added [validation against XSS](https://github.com/ansibleguy/webui/issues/44)
    api_docs_post, validate_no_xss
    def validate(self, attrs: dict):
        for field in AlertUser.api_fields_write:
            if field in attrs:
                validate_no_xss(value=attrs[field], field=field)

        return attrs

    def validate(self, attrs: dict):
        for field in AlertGlobal.api_fields_write:
            if field in attrs:
                validate_no_xss(value=attrs[field], field=field)

        return attrs

    def validate(self, attrs: dict):
        for field in AlertGroup.api_fields_write:
            if field in attrs:
                validate_no_xss(value=attrs[field], field=field)

        return attrs

from django.utils.html import escape as escape_html
from rest_framework.exceptions import ValidationError
from aw.utils.util import is_set


def validate_no_xss(value: str, field: str):
    if is_set(value) and isinstance(value, str) and value != escape_html(value):
        raise ValidationError(f"Found illegal characters in field '{field}'")
    api_docs_put, api_docs_post, validate_no_xss
    def validate(self, attrs: dict):
        for field in JobGlobalCredentials.api_fields_write:
            if field in attrs and field not in BaseJobCredentials.SECRET_ATTRS:
                validate_no_xss(value=attrs[field], field=field)

        return attrs

    def validate(self, attrs: dict):
        for field in JobUserCredentials.api_fields_write:
            if field in attrs and field not in BaseJobCredentials.SECRET_ATTRS:
                validate_no_xss(value=attrs[field], field=field)

        return attrs

    LogDownloadResponse, api_docs_put, api_docs_delete, api_docs_post, validate_no_xss
    def validate(self, attrs: dict):
        for field in Job.api_fields_write:
            if field in attrs:
                validate_no_xss(value=attrs[field], field=field)

        return attrs

    api_docs_post, validate_no_xss
    def validate(self, attrs: dict):
        for field in JobPermission.api_fields_write:
            if field in attrs:
                validate_no_xss(value=attrs[field], field=field)

        return attrs

    api_docs_delete, api_docs_post, validate_no_xss
    def validate(self, attrs: dict):
        for field in Repository.api_fields_write:
            if field in attrs:
                validate_no_xss(value=attrs[field], field=field)

        return attrs

function escapeRegExp(string) {
    return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}

function replaceAll(str, search, replace) {
    return str.replace(new RegExp(escapeRegExp(search), 'g'), replace);
}

// see: https://docs.djangoproject.com/en/5.0/ref/templates/language/#automatic-html-escaping
function escapeHTML(data) {
    if (!is_set(data)) {
        return data;
    }
    data = data.replaceAll('<', '&lt;');
    data = data.replaceAll('>', '&gt;');
    data = data.replaceAll('&', '&amp;');
    return data;
}

        resultDiv.innerHTML = 'Success: ' + escapeHTML(result.msg);
        errorHTML += ' - ' + escapeHTML(result.responseJSON.msg);
    } else {
        actionsTemplate = actionsTemplate.replaceAll('${EXEC_ID_1}', 0);