CVE-2024-36110 (published 2024-05-28T19:15Z)

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.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/ansibleguy/webui
commit_details: repo_url: https://github.com/ansibleguy/webui
author: AnsibleGuy
author_date: 2024-05-20 11:10:34+02:00
msg: added validation against XSS (#44)
repository: repo_url: https://github.com/ansibleguy/webui
repo_name: ansibleguy/webui
description: Basic WebUI for using Ansible
repo_language: Python
owner: ansibleguy
file_changes: filename: CHANGELOG.md
change_type: ModificationType.MODIFY
diff: @@ -4,7 +4,7 @@
 
 ### 0.0.21
 
-* ...
+* Added [validation against XSS](https://github.com/ansibleguy/webui/issues/44)
 
 ----
 

programming_language: Markdown
filename: alert.py
change_type: ModificationType.MODIFY
diff: @@ -7,7 +7,7 @@ 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
 
@@ -197,6 +197,13 @@ class AlertUserWriteRequest(BaseAlertWriteRequest):
         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']
@@ -352,6 +359,13 @@ class AlertGlobalWriteRequest(BaseAlertWriteRequest):
         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']
@@ -513,6 +527,13 @@ class AlertGroupWriteRequest(BaseAlertWriteRequest):
         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']

programming_language: Python
filename: base.py
change_type: ModificationType.MODIFY
diff: @@ -2,13 +2,16 @@ 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):
@@ -88,3 +91,8 @@ 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}'")

programming_language: Python
filename: credentials.py
change_type: ModificationType.MODIFY
diff: @@ -9,7 +9,7 @@ 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
@@ -50,12 +50,26 @@ class JobGlobalCredentialsWriteRequest(serializers.ModelSerializer):
     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':

programming_language: Python
filename: job.py
change_type: ModificationType.MODIFY
diff: @@ -11,7 +11,7 @@ from aw.model.permission import CHOICE_PERMISSION_READ, CHOICE_PERMISSION_EXECUT
     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
@@ -28,6 +28,13 @@ class JobWriteRequest(serializers.ModelSerializer):
 
     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:

programming_language: Python
filename: permission.py
change_type: ModificationType.MODIFY
diff: @@ -10,7 +10,7 @@ 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
@@ -60,6 +60,13 @@ class PermissionWriteRequest(serializers.ModelSerializer):
         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

programming_language: Python
filename: repository.py
change_type: ModificationType.MODIFY
diff: @@ -10,7 +10,7 @@ from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParamet
 
 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
@@ -27,6 +27,13 @@ class RepositoryWriteRequest(serializers.ModelSerializer):
 
     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:

programming_language: Python
filename: aw.js
change_type: ModificationType.MODIFY
diff: @@ -206,13 +206,32 @@ function sortTable($sortButton, order = 'desc') {
     }
 }
 
+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';
     }
@@ -234,7 +253,7 @@ function apiActionError(result, exception) {
     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>';
     }

programming_language: TypeScript
filename: logs.js
change_type: ModificationType.MODIFY
diff: @@ -1,11 +1,3 @@
-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");

programming_language: JavaScript
filename: manage.js
change_type: ModificationType.MODIFY
diff: @@ -39,6 +39,8 @@ function updateApiTableDataJob(row, row2, entry) {
     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;
 

programming_language: TypeScript