diff --git a/burpui/api/settings.py b/burpui/api/settings.py index 072c2f9d..54e52d09 100644 --- a/burpui/api/settings.py +++ b/burpui/api/settings.py @@ -593,6 +593,7 @@ class ClientSettings(Resource): responses={ 200: 'Success', 403: 'Insufficient permissions', + 409: 'Conflict', 500: 'Internal failure', } ) @@ -603,6 +604,10 @@ class ClientSettings(Resource): not current_user.acl.is_client_rw(client, server): self.abort(403, 'You don\'t have rights on this server') + if bui.client.is_backup_running(client, server): + self.abort(409, 'There is currently a backup running for this client hence ' + 'we cannot delete it for now. Please try again later') + args = self.parser_delete.parse_args() delcert = args.get('delcert', False) revoke = args.get('revoke', False) @@ -625,7 +630,10 @@ class ClientSettings(Resource): force_scheduling_now() parser = bui.client.get_parser(agent=server) - bui.audit.logger.info(f'deleted client configuration {client} ({conf}), delete certificate: {delcert}, revoke certificate: {revoke}, keep a backup of the configuration: {keepconf}, delete data: {delete}', server=server) + bui.audit.logger.info( + f'deleted client configuration {client}, delete certificate: {delcert}, ' + f'revoke certificate: {revoke}, keep a backup of the configuration: ' + f'{keepconf}, delete data: {delete}, is template: {template}', server=server) return parser.remove_client(client, keepconf, delcert, revoke, template, delete), 200 diff --git a/burpui/api/tasks.py b/burpui/api/tasks.py index cf55f3dd..3cea63d6 100644 --- a/burpui/api/tasks.py +++ b/burpui/api/tasks.py @@ -20,12 +20,14 @@ from ..engines.server import BUIServer # noqa from ..ext.cache import cache from ..config import config from ..decorators import browser_cache -from ..tasks import perform_restore, load_all_tree +from ..tasks import perform_restore, load_all_tree, delete_client, force_scheduling_now from time import time from zlib import adler32 from flask import url_for, Response, current_app, after_this_request, \ - send_file, request + send_file, request, g, session +from flask_babel import gettext as _ +from flask_restplus import inputs from flask_login import current_user from datetime import timedelta from werkzeug.datastructures import Headers @@ -44,9 +46,11 @@ else: bui = current_app # type: BUIServer ns = api.namespace('tasks', 'Asynchronous tasks methods') +# tuple composed with , task_types = { 'restore': (perform_restore, '.task_get_file'), 'browse': (load_all_tree, '.task_do_browse_all'), + 'delete': (delete_client, '.task_deleted_client'), } @@ -82,7 +86,7 @@ class TaskStatus(Resource): def get(self, task_type, task_id, server=None): """Returns the state of the given task""" if task_type not in task_types: - return {'state': 'FAILURE'} + self.abort(400) task_obj, callback = task_types[task_type] task = task_obj.AsyncResult(task_id) if task.state == 'FAILURE': @@ -121,7 +125,7 @@ class TaskStatus(Resource): def delete(self, task_type, task_id, server=None): """Cancel a given task""" if task_type not in task_types: - return '', 400 + self.abort(400) task_obj, _ = task_types[task_type] task = task_obj.AsyncResult(task_id) user = task.result.get('user') @@ -332,6 +336,14 @@ class TaskRestore(Resource): help='List of files/directories to restore', nullable=False ) + parser.add_argument( + 'timeout', + type=int, + required=False, + help='Maximum task duration after you consider it stalled (in minutes)', + default=60, + nullable=True + ) @ns.expect(parser, validate=True) @ns.doc( @@ -363,6 +375,7 @@ class TaskRestore(Resource): strip = args['strip'] fmt = args['format'] or 'zip' passwd = args['pass'] + timeout = args['timeout'] args_log = args.copy() # don't leak secrets in logs del args_log['pass'] @@ -399,7 +412,7 @@ class TaskRestore(Resource): task.id, 'perform_restore', current_user.name, - timedelta(minutes=60) + timedelta(minutes=timeout) ) try: db.session.add(db_task) @@ -409,6 +422,162 @@ class TaskRestore(Resource): return {'id': task.id, 'name': 'perform_restore'}, 202 +@ns.route('/config/', + '/config//', + '//config/', + '//config//', + endpoint='task_delete_client', + methods=['DELETE']) +@ns.doc( + params={ + 'server': 'Which server to collect data from when in multi-agent mode', + 'client': 'Client name', + 'conf': 'Path of the configuration file', + }, +) +class ClientSettings(Resource): + parser_delete = ns.parser() + parser_delete.add_argument('revoke', type=inputs.boolean, help='Whether to revoke the certificate or not', default=False, nullable=True) + parser_delete.add_argument('delcert', type=inputs.boolean, help='Whether to delete the certificate or not', default=False, nullable=True) + parser_delete.add_argument('keepconf', type=inputs.boolean, help='Whether to keep the conf or not', default=False, nullable=True) + parser_delete.add_argument('template', type=inputs.boolean, help='Whether we work on a template or not', default=False, nullable=True) + parser_delete.add_argument('delete', type=inputs.boolean, help='Whether we should remove the data as well or not', default=False, nullable=True) + + @api.disabled_on_demo() + @api.acl_admin_or_moderator_required(message=_('Sorry, you don\'t have rights to access the setting panel')) + @ns.expect(parser_delete) + @ns.doc( + responses={ + 200: 'Success', + 403: 'Insufficient permissions', + 409: 'Conflict', + 500: 'Internal failure', + } + ) + def delete(self, server=None, client=None, conf=None): + """Deletes a given client""" + if not current_user.is_anonymous and \ + current_user.acl.is_moderator() and \ + not current_user.acl.is_server_rw(server): + self.abort(403, 'You don\'t have rights on this server') + + if bui.client.is_backup_running(client, server): + self.abort(409, 'There is currently a backup running for this client hence ' + 'we cannot delete it for now. Please try again later') + + args = self.parser_delete.parse_args() + delcert = args.get('delcert', False) + revoke = args.get('revoke', False) + keepconf = args.get('keepconf', False) + template = args.get('template', False) + delete = args.get('delete', False) + + task = delete_client.apply_async( + args=[ + client, + keepconf, + delcert, + revoke, + template, + delete, + server, + current_user.name + ] + ) + + if db: + db_task = Task( + task.id, + 'delete_client', + current_user.name, + timedelta(minutes=60) + ) + try: + db.session.add(db_task) + db.session.commit() + except: + db.session.rollback() + return {'id': task.id, 'name': 'delete_client'}, 202 + + +@ns.route('/completed/config/', + '/completed//config/', + endpoint='task_deleted_client') +@ns.doc( + params={ + 'task_id': 'The task ID to process', + } +) +class TaskDeletedClient(Resource): + """The :class:`burpui.api.tasks.TaskDeletedClient` resource allows you to + retrieve the result of the delete_client task. + + This resource is part of the :mod:`burpui.api.tasks` module. + """ + + @ns.doc( + responses={ + 400: 'Incomplete task', + 403: 'Insufficient permissions', + 500: 'Task failed', + }, + ) + def get(self, task_id, server=None): + """Returns the task result""" + task = delete_client.AsyncResult(task_id) + if task.state != 'SUCCESS': + if task.state == 'FAILURE': + self.abort( + 500, + 'Unsuccessful task: {}'.format(task.result.get('error')) + ) + self.abort(400, 'Task not processed yet: {}'.format(task.state)) + + tres = task.result + + user = tres.get('user') + dst_server = tres.get('server') + resp = tres.get('result') + kwargs = tres.get('kwargs') + + client = tres.get('client') + delcert = kwargs.get('delcert') + revoke = kwargs.get('revoke') + keepconf = kwargs.get('keepconf') + delete = kwargs.get('delete') + template = kwargs.get('template') + + if current_user.name != user or (dst_server and dst_server != server): + self.abort(403, 'Unauthorized access') + + task.revoke() + + if not keepconf: + # clear the cache when we remove a client + cache.clear() + # clear client-side cache through the _extra META variable + try: + _extra = session.get('_extra', g.now) + _extra = int(_extra) + except ValueError: + _extra = 0 + session['_extra'] = '{}'.format(_extra + 1) + if bui.config['WITH_CELERY']: + force_scheduling_now() + + bui.audit.logger.info( + f'deleted client configuration {client}, delete certificate: {delcert}, ' + f'revoke certificate: {revoke}, keep a backup of the configuration: ' + f'{keepconf}, delete data: {delete}, is template: {template}', server=server) + return resp + +# if not keepconf: +# parser = bui.client.get_parser(agent=server) +# +# bui.audit.logger.info(f'deleted client configuration {client} ({conf}), delete certificate: {delcert}, revoke certificate: {revoke}, keep a backup of the configuration: {keepconf}, delete data: {delete}', server=server) +# return parser.remove_client(client, keepconf, delcert, revoke, template, delete), 200 + + @ns.route('/running', '//running', '/running/', diff --git a/burpui/misc/backend/multi.py b/burpui/misc/backend/multi.py index c934f43a..d6097917 100644 --- a/burpui/misc/backend/multi.py +++ b/burpui/misc/backend/multi.py @@ -462,6 +462,8 @@ class NClient(BUIbackend): if data['func'] == 'get_tree' and data['args'].get('root') == '*': # arbitrary raise timeout timeout = max(timeout, 300) + if data['func'] == 'proxy_parser' and data['method'] == 'remove_client': + notimeout = True try: # don't need a context manager here if data['func'] == 'get_file': diff --git a/burpui/tasks.py b/burpui/tasks.py index 808a3505..e959a473 100644 --- a/burpui/tasks.py +++ b/burpui/tasks.py @@ -318,13 +318,31 @@ def perform_restore(self, client, backup, @celery.task(bind=True) -def delete_client(self, client, keepconf, delcert, revoke, template, delete, server): +def delete_client(self, client, keepconf, delcert, revoke, template, delete, server, user): parser = bui.client.get_parser(agent=server) self.update_state(state='STARTED', meta={'step': 'doing'}) - ret = parser.remove_client(client, keepconf, delcert, revoke, template, delete) - if any(x == NOTIF_ERROR for x, _ in ret): - raise Exception + res = parser.remove_client(client, keepconf, delcert, revoke, template, delete) + if any(x == NOTIF_ERROR for x, _ in res): + self.update_state(state='FAILURE', meta={'error': res}) + raise Exception(res) + + ret = { + 'result': res, + 'client': client, + 'server': server, + 'user': user, + 'kwargs': { + 'keepconf': keepconf, + 'delcert': delcert, + 'revoke': revoke, + 'template': template, + 'delete': delete, + 'template': template, + }, + } + logger.debug(ret) + return ret @celery.task(bind=True) diff --git a/burpui/templates/js/client-browse.js b/burpui/templates/js/client-browse.js index 672edec5..10e3ab97 100644 --- a/burpui/templates/js/client-browse.js +++ b/burpui/templates/js/client-browse.js @@ -249,7 +249,7 @@ $( document ).ready(function() { failCallback: function (responseHtml, url) { $preparingFileModal.modal('hide'); if (responseHtml == 'encrypted') { - msg = '{{ _("The backup seems encrypted, please provide the encryption key in the \\\'Download options\\\' form.") }}'; + msg = '{{ _("The backup seems encrypted, please provide the encryption key in the \'Download options\' form.")|escape }}'; } else { msg = responseHtml; } @@ -272,7 +272,7 @@ $( document ).ready(function() { return false; } if (resp == 'encrypted') { - msg = '{{ _("The backup seems encrypted, please provide the encryption key in the \\\'Download options\\\' form.") }}'; + msg = '{{ _("The backup seems encrypted, please provide the encryption key in the \'Download options\' form.")|escape }}'; } else { msg = resp; } @@ -297,7 +297,7 @@ $( document ).ready(function() { failCallback: function (responseHtml, url) { $preparingFileModal.modal('hide'); if (responseHtml == 'encrypted') { - msg = 'The backup seems encrypted, please provide the encryption key in the \'Download options\' form.'; + msg = '{{ _("The backup seems encrypted, please provide the encryption key in the \'Download options\' form.")|escape }}'; } else { msg = responseHtml; } diff --git a/burpui/templates/js/settings.js b/burpui/templates/js/settings.js index 40599948..3c92ac89 100644 --- a/burpui/templates/js/settings.js +++ b/burpui/templates/js/settings.js @@ -628,10 +628,23 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$timeout', '$scrollspy', 'DTOp $scope.deleteClient = function() { /* UX tweak: disable the submit button + change text */ submit = $('#btn-remove-client'); + parse_result = function(data) { + redirect = data[0][0] == NOTIF_SUCCESS; + notifAll(data, redirect); + if (redirect) { + $timeout(function() { + document.location = '{{ url_for("view.settings", server=server) }}'; + }, 1000); + } + }; sav = submit.html(); submit.html(' {{ _("Deleting...") }}'); submit.attr('disabled', true); + {% if config.WITH_CELERY -%} + api = '{{ url_for("api.task_delete_client", client=client, server=server) }}'; + {% else -%} api = '{{ url_for("api.client_settings", client=client, server=server) }}'; + {% endif -%} $.ajax({ url: api, type: 'DELETE', @@ -643,13 +656,39 @@ app.controller('ConfigCtrl', ['$scope', '$http', '$timeout', '$scrollspy', 'DTOp }) .fail(buiFail) .done(function(data) { - redirect = data[0][0] == NOTIF_SUCCESS; - notifAll(data, redirect); - if (redirect) { - $timeout(function() { - document.location = '{{ url_for("view.settings", server=server) }}'; - }, 1000); + {% if config.WITH_CELERY -%} + notif(NOTIF_SUCCESS, '{{ _("The client %(client)s is being deleted", client=client) }}'); + if ($('#deldata').is(':checked')) { + notif(NOTIF_INFO, '{{ _("The data are being deleted in the background, you can leave this page if you like though the client may still shows up in the interface until the task completes") }}'); } + var _check_task_schedule = undefined; + var check_task = function(task_id) { + $.getJSON('{{ url_for("api.task_status", task_type="delete", task_id="", server=server) }}'+task_id) + .done(function(d2) { + if (d2.state != 'SUCCESS') { + _check_task_schedule = setTimeout(function() { + check_task(task_id); + }, 2000); + } else { + $.getJSON(d2.location).done(parse_result); + /* reset the submit button state */ + submit.html(sav); + submit.attr('disabled', false); + } + }) + .fail(function(xhr, stat, err) { + if (xhr.status != 502) { + buiFail(xhr, stat, err); + } else if ('responseJSON' in xhr && 'message' in xhr.responseJSON) { + notifAll(JSON.parse(xhr.responseJSON.message)); + } + + }); + }; + check_task(data.id); + {% else -%} + parse_result(data); + {% endif -%} }) .always(function() { /* reset the submit button state */