Tendenci has Authenticated Remote Code Execution via Pickle Deserialization
Description
Tendenci is an open source content management system built for non-profits, associations and cause-based sites. Versions 15.3.11 and below include a critical deserialization vulnerability in the Helpdesk module (which is not enabled by default). This vulnerability allows Remote Code Execution (RCE) by an authenticated user with staff security level due to using Python's pickle module in helpdesk /reports/. The original CVE-2020-14942 was incompletely patched. While ticket_list() was fixed to use safe JSON deserialization, the run_report() function still uses unsafe pickle.loads(). The impact is limited to the permissions of the user running the application, typically www-data, which generally lacks write (except for upload directories) and execute permissions. This issue has been fixed in version 15.3.12.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
tendenciPyPI | < 15.3.12 | 15.3.12 |
Affected products
1Patches
32ff0a4576149replace pickle with json
1 file changed · +5 −4
tendenci/apps/imports/views.py+5 −4 modified@@ -1,5 +1,6 @@ import os -import pickle +#import pickle +import json from django.contrib.auth.decorators import login_required from django.http import HttpResponseRedirect, HttpResponse, Http404 from django.urls import reverse @@ -131,7 +132,7 @@ def user_upload_subprocess(request, sid, fd = default_storage.open(recap_path, 'r') content = fd.read() fd.close() - recap_dict = pickle.loads(content) + recap_dict = json.loads(content) recap_dict.update({'users_list': recap_dict['users_list'] + import_dict['users_list'], 'invalid_list': recap_dict['invalid_list'] + @@ -157,7 +158,7 @@ def user_upload_subprocess(request, sid, 'file_name': import_dict['file_name']} fd = default_storage.open(recap_path, 'w') - cPickle.dump(recap_dict, fd) + json.dump(recap_dict, fd) fd.close() # clear the recap_dict recap_dict = None @@ -196,7 +197,7 @@ def user_upload_recap(request, sid): content = fd.read() fd.close() - recap_dict = pickle.loads(content) + recap_dict = json.loads(content) output = BytesIO() export_wb = Workbook()
23d9fd85ab76update save_query to have post data validated
2 files changed · +39 −12
tendenci/apps/helpdesk/forms.py+26 −1 modified@@ -6,6 +6,8 @@ forms.py - Definitions of newforms-based forms for creating and maintaining tickets. """ +import simplejson +from base64 import b64decode from django import forms from django.forms import widgets from django.conf import settings @@ -21,7 +23,7 @@ from datetime import datetime as timezone from tendenci.apps.helpdesk.lib import send_templated_mail, safe_template_context -from tendenci.apps.helpdesk.models import Ticket, Queue, FollowUp, Attachment, IgnoreEmail, TicketCC, CustomField, TicketCustomFieldValue, TicketDependency +from tendenci.apps.helpdesk.models import SavedSearch, Ticket, Queue, FollowUp, Attachment, IgnoreEmail, TicketCC, CustomField, TicketCustomFieldValue, TicketDependency from tendenci.apps.helpdesk import settings as helpdesk_settings from tendenci.apps.base.forms import CustomCatpchaField @@ -110,6 +112,29 @@ def save(self, *args, **kwargs): return super(EditTicketForm, self).save(*args, **kwargs) +class SavedSearchForm(forms.ModelForm): + query_encoded = forms.CharField() + + class Meta: + model = SavedSearch + fields = ('title', 'shared',) + + def clean_query_encoded(self): + """Validate the json serialized query""" + query_encoded = self.cleaned_data['query_encoded'] + try: + query_dict = simplejson.loads(b64decode(str(query_encoded).encode())) + except simplejson.errors.JSONDecodeError: + raise forms.ValidationError(_('Invalid query_encoded.')) + # validate the content of the query + valid_keys = ['filtering', 'sorting', 'sortreverse', 'keyword', 'other_filter', 'search_string'] + for k in query_dict.keys(): + if k not in valid_keys: + raise forms.ValidationError(_(f'{k} is not a valid parameter.')) + + return query_encoded + + class EditFollowUpForm(forms.ModelForm): def __init__(self, *args, **kwargs): "Filter not openned tickets here."
tendenci/apps/helpdesk/views/staff.py+13 −11 modified@@ -28,6 +28,7 @@ from django.utils.html import escape from django import forms from django.utils.encoding import smart_str +from django.views.decorators.http import require_http_methods import simplejson try: @@ -36,7 +37,7 @@ from datetime import datetime as timezone from tendenci.apps.theme.shortcuts import themed_response as render_to_resp -from tendenci.apps.helpdesk.forms import TicketForm, UserSettingsForm, EmailIgnoreForm, EditTicketForm, TicketCCForm, EditFollowUpForm, TicketDependencyForm +from tendenci.apps.helpdesk.forms import SavedSearchForm, TicketForm, UserSettingsForm, EmailIgnoreForm, EditTicketForm, TicketCCForm, EditFollowUpForm, TicketDependencyForm from tendenci.apps.helpdesk.lib import send_templated_mail, query_to_dict, apply_query, safe_template_context from tendenci.apps.helpdesk.models import UserSettings, Ticket, Queue, FollowUp, TicketChange, PreSetReply, Attachment, SavedSearch, IgnoreEmail, TicketCC, TicketDependency, QueueMembership from tendenci.apps.helpdesk import settings as helpdesk_settings @@ -1220,18 +1221,19 @@ def month_name(m): run_report = staff_member_required(run_report) +@require_http_methods(["POST"]) def save_query(request): - title = request.POST.get('title', None) - shared = request.POST.get('shared', False) in ['on', 'True', True, 'TRUE'] - query_encoded = request.POST.get('query_encoded', None) - - if not title or not query_encoded: - return HttpResponseRedirect(reverse('helpdesk_list')) - - query = SavedSearch(title=title, shared=shared, query=query_encoded, user=request.user) - query.save() + form = SavedSearchForm(request.POST) + if form.is_valid(): + encoded_query = form.save(commit=False) + encoded_query.user =request.user + encoded_query.query = form.cleaned_data['query_encoded'] + encoded_query.save() + return HttpResponseRedirect('%s?saved_query=%s' % (reverse('helpdesk_list'), encoded_query.id)) + + # invalid form + return HttpResponseRedirect(reverse('helpdesk_list')) - return HttpResponseRedirect('%s?saved_query=%s' % (reverse('helpdesk_list'), query.id)) save_query = staff_member_required(save_query)
63e1b84a5b16replace pickle with json to deserialize
1 file changed · +4 −3
tendenci/apps/helpdesk/views/staff.py+4 −3 modified@@ -1056,10 +1056,11 @@ def run_report(request, report): return HttpResponseRedirect(reverse('helpdesk_report_index')) if not (saved_query.shared or saved_query.user == request.user): return HttpResponseRedirect(reverse('helpdesk_report_index')) - - import pickle + + #import pickle from base64 import b64decode - query_params = pickle.loads(b64decode(str(saved_query.query).encode())) + #query_params = pickle.loads(b64decode(str(saved_query.query).encode())) + query_params = simplejson.loads(b64decode(str(saved_query.query).encode())) report_queryset = apply_query(report_queryset, query_params) from collections import defaultdict
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
11- github.com/advisories/GHSA-339m-4qw5-j2g3ghsaADVISORY
- github.com/advisories/GHSA-jqmc-fxxp-r589ghsax_refsource_MISCADVISORY
- nvd.nist.gov/vuln/detail/CVE-2020-14942ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-23946ghsaADVISORY
- docs.python.org/3/library/pickle.htmlghsax_refsource_MISCWEB
- github.com/tendenci/tendenci/commit/23d9fd85ab7654e9c83cfc86cb4175c0bd7a77f1ghsax_refsource_MISCWEB
- github.com/tendenci/tendenci/commit/2ff0a457614944a1b417081c543ea4c5bb95d636ghsax_refsource_MISCWEB
- github.com/tendenci/tendenci/commit/63e1b84a5b163466d1d8d811d35e7021a7ca0d0eghsax_refsource_MISCWEB
- github.com/tendenci/tendenci/issues/867ghsax_refsource_MISCWEB
- github.com/tendenci/tendenci/releases/tag/v15.3.12ghsax_refsource_MISCWEB
- github.com/tendenci/tendenci/security/advisories/GHSA-339m-4qw5-j2g3ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.