Skip to main content

CPanel Email Filter API Incorrectly Documented

Comments

2 comments

  • cPRex Jurassic Moderator

    Hey there!  Could you provide me with the specific API call you're using where this works?  I tried removing the specific "account portion of the API call and it failed:

    [root@host ~]# uapi --user=username Email delete_filter filtername='test2'
    ---
    apiversion: 3
    func: delete_filter
    module: Email
    result:
      data: ~
      errors:
        - Filter "test2" not found.
      messages: ~
      metadata: {}
      status: 0
      warnings: ~

     

    but adding that back in worked well:


    [root@host ~]# uapi --user=username Email delete_filter account='cptest@domain.com' filtername='test2'
    ---
    apiversion: 3
    func: delete_filter
    module: Email
    result:
      data:
        filtername: test2
      errors: ~
      messages:
      - Filter "test2" deleted for "cptest@domain.com".
      metadata: {}
      status: 1
      warnings: ~

    If you have an example where this is working how you've explained I'd be happy to bring that up with the development team.

    0
  • SEBO IT

    Hi, I've attached a screenshot of me removing the global filter I added called 'testfilter'

    The command also omits the user flag since we are running as the CPanel user and not root, maybe that changes the behaviour somehow? But if my understanding is correct, that would be unexpected.

    We are using cPanel version 110.0.15 (though we have asked our hosting manager to update CPanel to a more recent version ASAP due to CVE-2026-41940).

    I was even able to set up a rudimentary python2 script to sync the spam filtering between our servers as a cronjob (our hosting provider uses an extremely restrictive cagefs, we can't even run curl, pip, or python3, but we can run pycurl within python). It has been working like a charm so far, though it is a bit hacky.

    import pycurl
    from StringIO import StringIO
    import json
    import subprocess
    import sys
    import re

    def request(api_call):
        buffer = StringIO()
        c = pycurl.Curl()
        
        # port 2083 is seemingly blocked by our hosting provider, but allowed through the cpanel subdomain.
        urlopt = 'https://cpanel.<domain name>/execute/%s' % (api_call)
        print("Connecting to " + urlopt)
        
        c.setopt(c.URL, urlopt)
        c.setopt(c.CONNECTTIMEOUT, 10)
        c.setopt(c.TIMEOUT, 30)
        c.setopt(c.HTTPHEADER,['Authorization: cpanel <userB>::<API KEY>'])
        
        c.setopt(c.SSL_VERIFYPEER, 0) # yolo
        c.setopt(c.SSL_VERIFYHOST, 0)
        
        c.setopt(c.WRITEFUNCTION, buffer.write)
        
        c.perform()
        c.close()
        return buffer.getvalue()

    def run_local_uapi(*args):
        """Executes a local UAPI command securely via subprocess without shell."""
        cmd = ['uapi', '--output=json'] + list(args)
        proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        out, err = proc.communicate()
        
        if proc.returncode != 0:
            print("UAPI Subprocess Error: %s" % err)
        return out

    def transform_string(val):
        """Replaces domains/users securely while preventing Python 2 UnicodeEncodeErrors."""
        if val is None:
            return ''
        
        # Safely convert to a unicode object first
        if isinstance(val, str):
            val_uni = val.decode('utf-8', 'replace')
        elif isinstance(val, unicode):
            val_uni = val
        else:
            val_uni = unicode(val)
            
        # Strip the literal cPanel variable prefix
        val_uni = val_uni.replace(u'$home/mail', u'')
        
        # Strip the physical absolute mail path prefix (just in case)
        val_uni = re.sub(ur'/home[0-9]*/<userB>/mail', u'', val_uni)
        
        # Do the regular domain and username replacements
        transformed = val_uni.replace(u'<domainA>', u'<domainB>').replace(u'<userA>', u'<userB>')
        
        # Encode back to a utf-8 byte string so subprocess/print don't crash
        return transformed.encode('utf-8')
        
    def make_uapi_arg(key, val_bytes):
        """Safely builds key=value arguments. Escapes leading '@' to prevent cPanel file lookups."""
        # If the value starts with @, prepend a backslash
        if val_bytes.startswith('@'):
            val_bytes = '\\' + val_bytes
        return "%s=%s" % (key, val_bytes)

    try:
        # Fetch remote filters and strictly verify successful retrieval BEFORE doing anything else
        print("Fetching remote filters...")
        remote_response = request('Email/list_filters')
        remote_data = json.loads(remote_response)
        
        if remote_data.get('status') != 1:
            print("Failed to fetch remote filters. Errors: %s" % remote_data.get('errors'))
            sys.exit(1)
            
        remote_filters = remote_data.get('data',[])
        
        if not remote_filters:
            print("No filters found on the remote server to migrate.")
            sys.exit(0)

        print("Successfully retrieved %d filters from remote server." % len(remote_filters))

        # Fetch local filters via CLI UAPI
        print("Fetching local filters for deletion...")
        local_response = run_local_uapi('Email', 'list_filters')
        local_data = json.loads(local_response)
        
        # Local CLI UAPI still wraps its response in a 'result' dict
        local_result = local_data.get('result', {})
        
        # Delete existing local filters (Now safe, remote filters are verified!)
        if local_result.get('status') == 1:
            local_filters = local_result.get('data',[])
            for f in local_filters:
                fname = f.get('filtername')
                if fname:
                    print("Deleting local filter: %s" % fname)
                    run_local_uapi('Email', 'delete_filter', 'filtername=%s' % fname)
        else:
            print("Failed to fetch local filters. Cannot proceed with deletion safely.")
            print("Errors: %s" % local_result.get('errors'))
            sys.exit(1)
            
        # Migrate and store the new filters
        print("Migrating filters to local server...")
        for f in remote_filters:
            fname = transform_string(f.get('filtername'))
            
            # Build the command arguments array for Python subprocess
            args =['Email', 'store_filter', make_uapi_arg('filtername', fname)]
            
            # Parse & Add Conditions
            rules = f.get('rules',[])
            for i, rule in enumerate(rules):
                idx = i + 1  # Parameters are 1-indexed (e.g. part1, match1)
                
                args.append(make_uapi_arg('part%d' % idx, transform_string(rule.get('part'))))
                args.append(make_uapi_arg('match%d' % idx, transform_string(rule.get('match'))))
                args.append(make_uapi_arg('val%d' % idx, transform_string(rule.get('val'))))
                
                if rule.get('opt'):
                    args.append(make_uapi_arg('opt%d' % idx, transform_string(rule.get('opt'))))
                    
            # Parse & Add Destinations
            actions = f.get('actions',[])
            for i, action in enumerate(actions):
                idx = i + 1  # Parameters are 1-indexed (e.g. action1, dest1)
                
                args.append(make_uapi_arg('action%d' % idx, transform_string(action.get('action'))))
                args.append(make_uapi_arg('dest%d' % idx, transform_string(action.get('dest'))))
                
            print("Storing local filter: %s" % fname)
            
            # Execute the UAPI store_filter command locally
            store_response = run_local_uapi(*args)
            
            try:
                store_data = json.loads(store_response)
                store_result = store_data.get('result', {})
                if store_result.get('status') != 1:
                    print("Failed to store filter '%s'. Errors: %s" % (fname, store_result.get('errors')))
            except ValueError:
                print("Failed to parse JSON response when storing filter '%s'. Response: %s" % (fname, store_response))
                
        print("Filter migration complete!")

    except Exception as err:
        print("Error: %s" % err)
    0

Please sign in to leave a comment.