VYPR
Critical severityOSV Advisory· Published Jul 29, 2020· Updated Sep 17, 2024

Command Injection

CVE-2020-7698

Description

Gerapy before 0.9.3 has an OS command injection in the project_configure endpoint because user input is passed unsanitized to Popen.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Gerapy before 0.9.3 has an OS command injection in the project_configure endpoint because user input is passed unsanitized to Popen.

Vulnerability

CVE-2020-7698 is an OS command injection vulnerability in the Gerapy distributed crawler management framework (versions prior to 0.9.3). The root cause is that the project_configure endpoint passes user-controlled input directly to Popen via a shell command without sanitization [1]. The unsanitized parameter is project_name, which is concatenated into a gerapy generate command and executed with shell=True [2].

Exploitation

An attacker can exploit this vulnerability by sending a crafted request to the project_configure endpoint with a project_name containing shell metacharacters. The input is not filtered before being used in the command string. No authentication is required if the endpoint is exposed, and the attack can be performed remotely over the network. The GitHub commit fix adds a regex filter to remove dangerous characters like !, @, #, ;, &, *, ~, ", ', {, }, [, ], -, +, %, and ^ from the project_name before executing the command [2].

Impact

Successful exploitation allows an attacker to execute arbitrary OS commands on the server with the privileges of the Gerapy process. This could lead to full compromise of the server, including data exfiltration, installation of backdoors, or lateral movement within the network. The CVSS v3.1 base score is 9.8 (Critical) with an Attack Vector of Network and no privileges required [3].

Mitigation

Users should upgrade to Gerapy version 0.9.3 or later, which includes the fix that sanitizes the project_name input. For users unable to upgrade immediately, restricting network access to the project_configure endpoint and applying virtual patching or web application firewall rules can reduce risk. No public exploitation of this CVE has been reported in the KEV list.

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
gerapyPyPI
< 0.9.30.9.3

Affected products

2

Patches

1
e8446605eb24

to b1

https://github.com/Gerapy/GerapyGermeyJul 6, 2020via ghsa
4 files changed · +50 69
  • gerapy/server/core/views.py+47 63 modified
    @@ -1,3 +1,4 @@
    +import re
     from pathlib import Path
     from urllib.parse import unquote
     import base64
    @@ -251,6 +252,8 @@ def project_configure(request, project_name):
             configuration = json.dumps(data.get('configuration'), ensure_ascii=False)
             project.update(**{'configuration': configuration})
             
    +        # for safe protection
    +        project_name = re.sub('[\!\@\#\$\;\&\*\~\"\'\{\}\]\[\-\+\%\^]+', '', project_name)
             # execute generate cmd
             cmd = ' '.join(['gerapy', 'generate', project_name])
             p = Popen(cmd, shell=True, stdin=PIPE, stdout=PIPE, stderr=PIPE)
    @@ -634,17 +637,15 @@ def job_list(request, client_id, project_name):
         if request.method == 'GET':
             client = Client.objects.get(id=client_id)
             scrapyd = get_scrapyd(client)
    -        try:
    -            result = scrapyd.list_jobs(project_name)
    -            jobs = []
    -            statuses = ['pending', 'running', 'finished']
    -            for status in statuses:
    -                for job in result.get(status):
    -                    job['status'] = status
    -                    jobs.append(job)
    -            return JsonResponse(jobs)
    -        except ConnectionError:
    -            return JsonResponse({'message': 'Connect Error'}, status=500)
    +        result = scrapyd.list_jobs(project_name)
    +        jobs = []
    +        statuses = ['pending', 'running', 'finished']
    +        for status in statuses:
    +            for job in result.get(status):
    +                job['status'] = status
    +                jobs.append(job)
    +        return JsonResponse(jobs)
    +    
     
     
     @api_view(['GET'])
    @@ -663,21 +664,18 @@ def job_log(request, client_id, project_name, spider_name, job_id):
             client = Client.objects.get(id=client_id)
             # get log url
             url = log_url(client.ip, client.port, project_name, spider_name, job_id)
    -        try:
    -            # get last 1000 bytes of log
    -            response = requests.get(url, timeout=5, headers={
    -                'Range': 'bytes=-1000'
    -            }, auth=(client.username, client.password) if client.auth else None)
    -            # Get encoding
    -            encoding = response.apparent_encoding
    -            # log not found
    -            if response.status_code == 404:
    -                return JsonResponse({'message': 'Log Not Found'}, status=404)
    -            # bytes to string
    -            text = response.content.decode(encoding, errors='replace')
    -            return HttpResponse(text)
    -        except requests.ConnectionError:
    -            return JsonResponse({'message': 'Load Log Error'}, status=500)
    +        # get last 1000 bytes of log
    +        response = requests.get(url, timeout=5, headers={
    +            'Range': 'bytes=-1000'
    +        }, auth=(client.username, client.password) if client.auth else None)
    +        # Get encoding
    +        encoding = response.apparent_encoding
    +        # log not found
    +        if response.status_code == 404:
    +            return JsonResponse({'message': 'Log Not Found'}, status=404)
    +        # bytes to string
    +        text = response.content.decode(encoding, errors='replace')
    +        return HttpResponse(text)
     
     
     @api_view(['GET'])
    @@ -693,38 +691,29 @@ def job_cancel(request, client_id, project_name, job_id):
         """
         if request.method == 'GET':
             client = Client.objects.get(id=client_id)
    -        try:
    -            scrapyd = get_scrapyd(client)
    -            result = scrapyd.cancel(project_name, job_id)
    -            return JsonResponse(result)
    -        except ConnectionError:
    -            return JsonResponse({'message': 'Connect Error'})
    +        scrapyd = get_scrapyd(client)
    +        result = scrapyd.cancel(project_name, job_id)
    +        return JsonResponse(result)
     
     
     @api_view(['GET'])
     @permission_classes([IsAuthenticated])
     def del_version(request, client_id, project, version):
         if request.method == 'GET':
             client = Client.objects.get(id=client_id)
    -        try:
    -            scrapyd = get_scrapyd(client)
    -            result = scrapyd.delete_version(project=project, version=version)
    -            return JsonResponse(result)
    -        except ConnectionError:
    -            return JsonResponse({'message': 'Connect Error'})
    +        scrapyd = get_scrapyd(client)
    +        result = scrapyd.delete_version(project=project, version=version)
    +        return JsonResponse(result)
     
     
     @api_view(['GET'])
     @permission_classes([IsAuthenticated])
     def del_project(request, client_id, project):
         if request.method == 'GET':
             client = Client.objects.get(id=client_id)
    -        try:
    -            scrapyd = get_scrapyd(client)
    -            result = scrapyd.delete_project(project=project)
    -            return JsonResponse(result)
    -        except ConnectionError:
    -            return JsonResponse({'message': 'Connect Error'})
    +        scrapyd = get_scrapyd(client)
    +        result = scrapyd.delete_project(project=project)
    +        return JsonResponse(result)
     
     
     @api_view(['POST'])
    @@ -829,18 +818,16 @@ def task_remove(request, task_id):
         :return:
         """
         if request.method == 'POST':
    -        try:
    -            # delete job from DjangoJob
    -            task = Task.objects.get(id=task_id)
    -            clients = clients_of_task(task)
    -            for client in clients:
    -                job_id = get_job_id(client, task)
    -                DjangoJob.objects.filter(name=job_id).delete()
    -            # delete task
    -            Task.objects.filter(id=task_id).delete()
    -            return JsonResponse({'result': '1'})
    -        except:
    -            return JsonResponse({'result': '0'})
    +        # delete job from DjangoJob
    +        task = Task.objects.get(id=task_id)
    +        clients = clients_of_task(task)
    +        for client in clients:
    +            job_id = get_job_id(client, task)
    +            DjangoJob.objects.filter(name=job_id).delete()
    +        # delete task
    +        Task.objects.filter(id=task_id).delete()
    +        return JsonResponse({'result': '1'})
    +    
     
     
     @api_view(['GET'])
    @@ -915,10 +902,7 @@ def render_html(request):
             url = unquote(base64.b64decode(url).decode('utf-8'))
             js = request.GET.get('js', 0)
             script = request.GET.get('script')
    -        try:
    -            response = requests.get(url, timeout=5)
    -            response.encoding = response.apparent_encoding
    -            html = process_html(response.text)
    -            return HttpResponse(html)
    -        except Exception as e:
    -            return JsonResponse({'message': e.args}, status=500)
    +        response = requests.get(url, timeout=5)
    +        response.encoding = response.apparent_encoding
    +        html = process_html(response.text)
    +        return HttpResponse(html)
    
  • gerapy/__version__.py+1 1 modified
    @@ -1,4 +1,4 @@
    -VERSION = (0, 9, '3a3')
    +VERSION = (0, 9, '3b1')
     
     __version__ = '.'.join(map(str, VERSION))
     
    
  • README.md+0 3 modified
    @@ -8,9 +8,6 @@
     ![Docker Pulls](https://img.shields.io/docker/pulls/germey/gerapy)
     ![PyPI - License](https://img.shields.io/pypi/l/gerapy)
     
    -
    -> 注:从 Gerapy 2.x 开始,其定位发生改变,不再支持 Scrapyd,转而支持 Docker、Kubernetes 的部署,另外开发还会迁移到 Scrapy 可视化配置和智能解析方面,敬请期待。
    -
     Distributed Crawler Management Framework Based on Scrapy, Scrapyd, Scrapyd-Client, Scrapyd-API, Django and Vue.js.
     
     ## Documentation
    
  • requirements.txt+2 2 modified
    @@ -5,12 +5,12 @@ django-cors-headers==3.2.0
     django-apscheduler==0.3.0
     furl==2.1.0
     jinja2==2.10.1
    -scrapy>=1.4.0
    +scrapy==1.5.0
     scrapy-redis==0.6.8
     scrapy-splash==0.7.2
     python-scrapyd-api==2.1.2
     redis==2.10.5
    -requests>=2.20.0
    +requests==2.20.0
     pymongo==3.9.0
     pymysql==0.7.10
     pyquery==1.2.17
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.