From d07fedc45f91449787d939a5bf4cc00a0d100652 Mon Sep 17 00:00:00 2001 From: Matt Borland Date: Thu, 8 Sep 2016 14:50:23 -0600 Subject: [PATCH] Use POST not GET for keypair generation This patch fixes the Cross-Site Request Forgery (CSRF) attack against the keypair generation pages: - HORIZON_URL/project/key_pairs/PAIRNAME/generate/ - HORIZON_URL/project/key_pairs/PAIRNAME/download/ These pages exposed creating and/or overwriting a keypair with a given name via a CSRF attack. This patch closes these holes by using only POST-based keypair creation, and exposing the keypair in the contents of a modal dialog instead of a download, which ultimately requires a GET. It uses the same client-side features for both the Launch Instance keypair creation and Compute / Key Pairs panel. Closes-Bug: 1575913 Change-Id: Ie5ca28ff2bd806eb1481eba6f419b797b68856b6 --- openstack_dashboard/api/rest/nova.py | 43 ----- .../dashboards/project/key_pairs/forms.py | 23 --- .../dashboards/project/key_pairs/tables.py | 25 ++- .../templates/key_pairs/_create.html | 7 - .../key_pairs/templates/key_pairs/create.html | 8 - .../dashboards/project/key_pairs/tests.py | 104 ----------- .../dashboards/project/key_pairs/urls.py | 7 - .../dashboards/project/key_pairs/views.py | 53 ------ .../keypair/create-keypair.controller.js | 38 ++-- .../keypair/create-keypair.controller.spec.js | 24 +-- .../keypair/create-keypair.html | 25 ++- .../keypair/keypair.controller.js | 1 + .../launch-instance/keypair/keypair.html | 12 -- .../static/app/core/core.module.js | 1 + .../app/core/keypairs/keypair.controller.js | 97 ++++++++++ .../core/keypairs/keypair.controller.spec.js | 80 +++++++++ .../app/core/keypairs/keypairs.module.js | 33 ++++ .../app/core/keypairs/keypairs.module.spec.js | 25 +++ .../keypair-download.service.js | 153 ---------------- .../keypair-download.service.spec.js | 166 ------------------ .../openstack-service-api/nova.service.js | 46 +---- .../nova.service.spec.js | 49 ------ .../test/api_tests/nova_rest_tests.py | 63 ------- 23 files changed, 299 insertions(+), 784 deletions(-) delete mode 100644 openstack_dashboard/dashboards/project/key_pairs/templates/key_pairs/_create.html delete mode 100644 openstack_dashboard/dashboards/project/key_pairs/templates/key_pairs/create.html create mode 100644 openstack_dashboard/static/app/core/keypairs/keypair.controller.js create mode 100644 openstack_dashboard/static/app/core/keypairs/keypair.controller.spec.js create mode 100644 openstack_dashboard/static/app/core/keypairs/keypairs.module.js create mode 100644 openstack_dashboard/static/app/core/keypairs/keypairs.module.spec.js delete mode 100644 openstack_dashboard/static/app/core/openstack-service-api/keypair-download.service.js delete mode 100644 openstack_dashboard/static/app/core/openstack-service-api/keypair-download.service.spec.js diff --git a/openstack_dashboard/api/rest/nova.py b/openstack_dashboard/api/rest/nova.py index bf7ff4c97b..6eef062ffb 100644 --- a/openstack_dashboard/api/rest/nova.py +++ b/openstack_dashboard/api/rest/nova.py @@ -15,8 +15,6 @@ """ from collections import OrderedDict -from django.http import HttpResponse -from django.template.defaultfilters import slugify from django.utils import http as utils_http from django.utils.translation import ugettext_lazy as _ from django.views import generic @@ -87,47 +85,6 @@ class Keypairs(generic.View): ) -@urls.register -class Keypair(generic.View): - url_regex = r'nova/keypairs/(?P.+)/$' - - def get(self, request, keypair_name): - """Creates a new keypair and associates it to the current project. - - * Since the response for this endpoint creates a new keypair and - is not idempotent, it normally would be represented by a POST HTTP - request. However, this solution was adopted as it - would support automatic file download across browsers. - - :param keypair_name: the name to associate the keypair to - :param regenerate: (optional) if set to the string 'true', - replaces the existing keypair with a new keypair - - This returns the new keypair object on success. - """ - try: - regenerate = request.GET.get('regenerate') == 'true' - if regenerate: - api.nova.keypair_delete(request, keypair_name) - - keypair = api.nova.keypair_create(request, keypair_name) - - except exceptions.Conflict: - return HttpResponse(status=409) - - except Exception: - return HttpResponse(status=500) - - else: - response = HttpResponse(content_type='application/binary') - response['Content-Disposition'] = ('attachment; filename=%s.pem' - % slugify(keypair_name)) - response.write(keypair.private_key) - response['Content-Length'] = str(len(response.content)) - - return response - - @urls.register class Services(generic.View): """API for nova services. diff --git a/openstack_dashboard/dashboards/project/key_pairs/forms.py b/openstack_dashboard/dashboards/project/key_pairs/forms.py index add286d93f..f63ba0ef58 100644 --- a/openstack_dashboard/dashboards/project/key_pairs/forms.py +++ b/openstack_dashboard/dashboards/project/key_pairs/forms.py @@ -36,29 +36,6 @@ KEYPAIR_ERROR_MESSAGES = { 'and may not be white space.')} -class CreateKeypair(forms.SelfHandlingForm): - name = forms.RegexField(max_length=255, - label=_("Key Pair Name"), - regex=KEYPAIR_NAME_REGEX, - error_messages=KEYPAIR_ERROR_MESSAGES) - - def handle(self, request, data): - return True # We just redirect to the download view. - - def clean(self): - cleaned_data = super(CreateKeypair, self).clean() - name = cleaned_data.get('name') - try: - keypairs = api.nova.keypair_list(self.request) - except Exception: - exceptions.handle(self.request, ignore=True) - keypairs = [] - if name in [keypair.name for keypair in keypairs]: - error_msg = _("The name is already in use.") - self._errors['name'] = self.error_class([error_msg]) - return cleaned_data - - class ImportKeypair(forms.SelfHandlingForm): name = forms.RegexField(max_length=255, label=_("Key Pair Name"), diff --git a/openstack_dashboard/dashboards/project/key_pairs/tables.py b/openstack_dashboard/dashboards/project/key_pairs/tables.py index c6fe9366b2..249b122616 100644 --- a/openstack_dashboard/dashboards/project/key_pairs/tables.py +++ b/openstack_dashboard/dashboards/project/key_pairs/tables.py @@ -12,6 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. +from django.core import urlresolvers from django.utils.translation import string_concat from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ungettext_lazy @@ -78,16 +79,28 @@ class ImportKeyPair(QuotaKeypairMixin, tables.LinkAction): return True -class CreateKeyPair(QuotaKeypairMixin, tables.LinkAction): - name = "create" +class CreateLinkNG(QuotaKeypairMixin, tables.LinkAction): + name = "create-keypair-ng" verbose_name = _("Create Key Pair") - url = "horizon:project:key_pairs:create" - classes = ("ajax-modal",) + url = "horizon:project:key_pairs:index" + classes = ("btn-launch",) icon = "plus" policy_rules = (("compute", "os_compute_api:os-keypairs:create"),) + def get_default_attrs(self): + url = urlresolvers.reverse(self.url) + ngclick = "modal.createKeyPair({ successUrl: '%s' })" % url + self.attrs.update({ + 'ng-controller': 'KeypairController as modal', + 'ng-click': ngclick + }) + return super(CreateLinkNG, self).get_default_attrs() + + def get_link_url(self, datum=None): + return "javascript:void(0);" + def allowed(self, request, keypair=None): - if super(CreateKeyPair, self).allowed(request, keypair): + if super(CreateLinkNG, self).allowed(request, keypair): self.verbose_name = _("Create Key Pair") return True @@ -113,6 +126,6 @@ class KeyPairsTable(tables.DataTable): class Meta(object): name = "keypairs" verbose_name = _("Key Pairs") - table_actions = (CreateKeyPair, ImportKeyPair, DeleteKeyPairs, + table_actions = (CreateLinkNG, ImportKeyPair, DeleteKeyPairs, KeypairsFilterAction,) row_actions = (DeleteKeyPairs,) diff --git a/openstack_dashboard/dashboards/project/key_pairs/templates/key_pairs/_create.html b/openstack_dashboard/dashboards/project/key_pairs/templates/key_pairs/_create.html deleted file mode 100644 index 652689e168..0000000000 --- a/openstack_dashboard/dashboards/project/key_pairs/templates/key_pairs/_create.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "horizon/common/_modal_form.html" %} -{% load i18n %} - -{% block modal-body-right %} -

{% trans "Key pairs are SSH credentials which are injected into images when they are launched. Creating a new key pair registers the public key and downloads the private key (a .pem file)." %}

-

{% trans "Protect and use the key as you would any normal SSH private key." %}

-{% endblock %} diff --git a/openstack_dashboard/dashboards/project/key_pairs/templates/key_pairs/create.html b/openstack_dashboard/dashboards/project/key_pairs/templates/key_pairs/create.html deleted file mode 100644 index 26914557b6..0000000000 --- a/openstack_dashboard/dashboards/project/key_pairs/templates/key_pairs/create.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'base.html' %} -{% load i18n %} -{% block title %}{{ page_title }}{% endblock %} - -{% block main %} - {% include 'project/key_pairs/_create.html' %} -{% endblock %} - diff --git a/openstack_dashboard/dashboards/project/key_pairs/tests.py b/openstack_dashboard/dashboards/project/key_pairs/tests.py index c7f9027886..7fd5c9bdeb 100644 --- a/openstack_dashboard/dashboards/project/key_pairs/tests.py +++ b/openstack_dashboard/dashboards/project/key_pairs/tests.py @@ -22,8 +22,6 @@ from mox3.mox import IsA import six from openstack_dashboard import api -from openstack_dashboard.dashboards.project.key_pairs.forms \ - import CreateKeypair from openstack_dashboard.dashboards.project.key_pairs.forms \ import KEYPAIR_ERROR_MESSAGES from openstack_dashboard.test import helpers as test @@ -80,37 +78,6 @@ class KeyPairTests(test.TestCase): res = self.client.post(INDEX_URL, formData) self.assertRedirectsNoFollow(res, INDEX_URL) - def test_create_keypair_get(self): - res = self.client.get( - reverse('horizon:project:key_pairs:create')) - self.assertTemplateUsed( - res, 'project/key_pairs/create.html') - - def test_download_keypair_get(self): - keypair_name = "keypair" - context = {'keypair_name': keypair_name} - url = reverse('horizon:project:key_pairs:download', - kwargs={'keypair_name': keypair_name}) - res = self.client.get(url, context) - self.assertTemplateUsed( - res, 'project/key_pairs/download.html') - - @test.create_stubs({api.nova: ('keypair_create',)}) - def test_generate_keypair_get(self): - keypair = self.keypairs.first() - keypair.private_key = "secret" - - api.nova.keypair_create(IsA(http.HttpRequest), - keypair.name).AndReturn(keypair) - self.mox.ReplayAll() - - context = {'keypair_name': keypair.name} - url = reverse('horizon:project:key_pairs:generate', - kwargs={'keypair_name': keypair.name}) - res = self.client.get(url, context) - - self.assertTrue(res.has_header('content-disposition')) - @test.create_stubs({api.nova: ('keypair_get',)}) def test_keypair_detail_get(self): keypair = self.keypairs.first() @@ -126,22 +93,6 @@ class KeyPairTests(test.TestCase): res = self.client.get(url, context) self.assertContains(res, "
%s
" % keypair.name, 1, 200) - @test.create_stubs({api.nova: ("keypair_create", "keypair_delete",)}) - def test_regenerate_keypair_get(self): - keypair = self.keypairs.first() - keypair.private_key = "secret" - optional_param = "regenerate" - api.nova.keypair_delete(IsA(http.HttpRequest), keypair.name) - api.nova.keypair_create(IsA(http.HttpRequest), - keypair.name).AndReturn(keypair) - self.mox.ReplayAll() - url = reverse('horizon:project:key_pairs:generate', - kwargs={'keypair_name': keypair.name, - 'optional': optional_param}) - res = self.client.get(url) - - self.assertTrue(res.has_header('content-disposition')) - @test.create_stubs({api.nova: ("keypair_import",)}) def test_import_keypair(self): key1_name = "new_key_pair" @@ -203,22 +154,6 @@ class KeyPairTests(test.TestCase): msg = six.text_type(KEYPAIR_ERROR_MESSAGES['invalid']) self.assertFormErrors(res, count=1, message=msg) - @test.create_stubs({api.nova: ("keypair_create",)}) - def test_generate_keypair_exception(self): - keypair = self.keypairs.first() - - api.nova.keypair_create(IsA(http.HttpRequest), keypair.name) \ - .AndRaise(self.exceptions.nova) - self.mox.ReplayAll() - - context = {'keypair_name': keypair.name} - url = reverse('horizon:project:key_pairs:generate', - kwargs={'keypair_name': keypair.name}) - res = self.client.get(url, context) - - self.assertRedirectsNoFollow( - res, reverse('horizon:project:key_pairs:index')) - @test.create_stubs({api.nova: ("keypair_import",)}) def test_import_keypair_with_regex_defined_name(self): key1_name = "new-key-pair with_regex" @@ -235,42 +170,3 @@ class KeyPairTests(test.TestCase): url = reverse('horizon:project:key_pairs:import') res = self.client.post(url, formData) self.assertMessageCount(res, success=1) - - @test.create_stubs({api.nova: ("keypair_create",)}) - def test_create_keypair_with_regex_name_get(self): - keypair = self.keypairs.first() - keypair.name = "key-space pair-regex_name-0123456789" - keypair.private_key = "secret" - - api.nova.keypair_create(IsA(http.HttpRequest), - keypair.name).AndReturn(keypair) - self.mox.ReplayAll() - - context = {'keypair_name': keypair.name} - url = reverse('horizon:project:key_pairs:generate', - kwargs={'keypair_name': keypair.name}) - res = self.client.get(url, context) - - self.assertTrue(res.has_header('content-disposition')) - - def test_download_with_regex_name_get(self): - keypair_name = "key pair-regex_name-0123456789" - context = {'keypair_name': keypair_name} - url = reverse('horizon:project:key_pairs:download', - kwargs={'keypair_name': keypair_name}) - res = self.client.get(url, context) - self.assertTemplateUsed( - res, 'project/key_pairs/download.html') - - @test.create_stubs({api.nova: ('keypair_list',)}) - def test_create_duplicate_keypair(self): - keypair_name = self.keypairs.first().name - - api.nova.keypair_list(IsA(http.HttpRequest)) \ - .AndReturn(self.keypairs.list()) - self.mox.ReplayAll() - - form = CreateKeypair(self.request, data={'name': keypair_name}) - self.assertFalse(form.is_valid()) - self.assertIn('The name is already in use.', - form.errors['name'][0]) diff --git a/openstack_dashboard/dashboards/project/key_pairs/urls.py b/openstack_dashboard/dashboards/project/key_pairs/urls.py index afe68a2977..9c5b2015f1 100644 --- a/openstack_dashboard/dashboards/project/key_pairs/urls.py +++ b/openstack_dashboard/dashboards/project/key_pairs/urls.py @@ -21,14 +21,7 @@ from openstack_dashboard.dashboards.project.key_pairs import views urlpatterns = [ url(r'^$', views.IndexView.as_view(), name='index'), - url(r'^create/$', views.CreateView.as_view(), name='create'), url(r'^import/$', views.ImportView.as_view(), name='import'), - url(r'^(?P[^/]+)/download/$', views.DownloadView.as_view(), - name='download'), - url(r'^(?P[^/]+)/generate/$', views.GenerateView.as_view(), - name='generate'), - url(r'^(?P[^/]+)/(?P[^/]+)/generate/$', - views.GenerateView.as_view(), name='generate'), url(r'^(?P[^/]+)/$', views.DetailView.as_view(), name='detail'), ] diff --git a/openstack_dashboard/dashboards/project/key_pairs/views.py b/openstack_dashboard/dashboards/project/key_pairs/views.py index 81bf525b2c..fb4a0598ae 100644 --- a/openstack_dashboard/dashboards/project/key_pairs/views.py +++ b/openstack_dashboard/dashboards/project/key_pairs/views.py @@ -14,12 +14,7 @@ from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse_lazy -from django import http -from django.template.defaultfilters import slugify -from django.utils.decorators import method_decorator from django.utils.translation import ugettext_lazy as _ -from django.views.decorators.cache import cache_control -from django.views.decorators.cache import never_cache from horizon import exceptions from horizon import forms @@ -55,20 +50,6 @@ class IndexView(tables.DataTableView): return keypairs -class CreateView(forms.ModalFormView): - form_class = key_pairs_forms.CreateKeypair - template_name = 'project/key_pairs/create.html' - submit_url = reverse_lazy( - "horizon:project:key_pairs:create") - success_url = 'horizon:project:key_pairs:download' - submit_label = page_title = _("Create Key Pair") - cancel_url = reverse_lazy("horizon:project:key_pairs:index") - - def get_success_url(self): - return reverse(self.success_url, - kwargs={"keypair_name": self.request.POST['name']}) - - class ImportView(forms.ModalFormView): form_class = key_pairs_forms.ImportKeypair template_name = 'project/key_pairs/import.html' @@ -103,37 +84,3 @@ class DetailView(views.HorizonTemplateView): context = super(DetailView, self).get_context_data(**kwargs) context['keypair'] = self._get_data() return context - - -class DownloadView(views.HorizonTemplateView): - template_name = 'project/key_pairs/download.html' - page_title = _("Download Key Pair") - - def get_context_data(self, keypair_name=None): - return {'keypair_name': keypair_name} - - -class GenerateView(views.HorizonTemplateView): - # TODO(Itxaka): Remove cache_control in django >= 1.9 - # https://code.djangoproject.com/ticket/13008 - @method_decorator(cache_control(max_age=0, no_cache=True, - no_store=True, must_revalidate=True)) - @method_decorator(never_cache) - def get(self, request, keypair_name=None, optional=None): - try: - if optional == "regenerate": - nova.keypair_delete(request, keypair_name) - - keypair = nova.keypair_create(request, keypair_name) - except Exception: - redirect = reverse('horizon:project:key_pairs:index') - exceptions.handle(self.request, - _('Unable to create key pair: %(exc)s'), - redirect=redirect) - - response = http.HttpResponse(content_type='application/binary') - response['Content-Disposition'] = ('attachment; filename=%s.pem' - % slugify(keypair.name)) - response.write(keypair.private_key) - response['Content-Length'] = str(len(response.content)) - return response diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/create-keypair.controller.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/create-keypair.controller.js index 5cffddafc1..b1a1bec8f5 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/create-keypair.controller.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/create-keypair.controller.js @@ -24,9 +24,7 @@ LaunchInstanceCreateKeyPairController.$inject = [ '$uibModalInstance', 'existingKeypairs', - 'horizon.app.core.openstack-service-api.nova', - 'horizon.framework.widgets.toast.service', - 'horizon.app.core.openstack-service-api.keypair-download-service' + 'horizon.app.core.openstack-service-api.nova' ]; /** @@ -35,22 +33,21 @@ * @param {Object} $uibModalInstance * @param {Object} existingKeypairs * @param {Object} nova - * @param {Object} toastService - * @param {Object} keypairDownloadService * @description * Provide a dialog for creation of a new key pair. * @returns {undefined} Returns nothing */ - function LaunchInstanceCreateKeyPairController($uibModalInstance, existingKeypairs, nova, - toastService, keypairDownloadService) { + function LaunchInstanceCreateKeyPairController($uibModalInstance, existingKeypairs, nova) { var ctrl = this; ctrl.submit = submit; ctrl.cancel = cancel; ctrl.doesKeypairExist = doesKeypairExist; + ctrl.generate = generate; ctrl.keypair = ''; ctrl.keypairExistsError = gettext('Keypair already exists or name contains bad characters.'); + ctrl.copyPrivateKey = copyPrivateKey; /* * @ngdoc function @@ -62,6 +59,21 @@ return exists(ctrl.keypair); } + function generate() { + nova.createKeypair({name: ctrl.keypair}).then(onKeypairCreated); + + function onKeypairCreated(data) { + ctrl.createdKeypair = data.data; + ctrl.privateKey = ctrl.createdKeypair.private_key; + ctrl.publicKey = ctrl.createdKeypair.public_key; + } + } + + function copyPrivateKey() { + angular.element('textarea').select(); + document.execCommand('copy'); + } + /* * @ngdoc function * @name exists @@ -84,17 +96,7 @@ * notified of the problem and given the opportunity to try again. */ function submit() { - keypairDownloadService.createAndDownloadKeypair(ctrl.keypair).then( - function success(createdKeypair) { - createdKeypair.regenerateUrl = nova.getRegenerateKeypairUrl(createdKeypair.name); - $uibModalInstance.close(createdKeypair); - }, - function error() { - var errorMessage = interpolate(gettext('Unable to generate "%s". Please try again.'), - [ctrl.keypair]); - toastService.add('error', errorMessage); - } - ); + $uibModalInstance.close(ctrl.createdKeypair); } /* diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/create-keypair.controller.spec.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/create-keypair.controller.spec.js index e30e14d229..57b81a05fd 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/create-keypair.controller.spec.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/create-keypair.controller.spec.js @@ -77,36 +77,16 @@ }); it('should close the modal and return the created keypair', function() { - mockCreationSuccess = true; - mockKeypair = { - name: "newKeypair" - }; - spyOn(createKeypairServiceMock, 'getRegenerateKeypairUrl').and.returnValue( - "a url" - ); spyOn(modalInstanceMock, 'close'); + ctrl.createdKeypair = {name: 'newKeypair'}; ctrl.submit(); expect(modalInstanceMock.close).toHaveBeenCalledWith({ - name: "newKeypair", - regenerateUrl: "a url" + name: "newKeypair" }); }); - it('should raise a toast error message when create is unsuccessful', function() { - mockCreationSuccess = false; - spyOn(toastServiceMock, 'add'); - - ctrl.keypair = "aKeypair"; - ctrl.submit(); - - expect(toastServiceMock.add).toHaveBeenCalledWith( - 'error', - 'Unable to generate "aKeypair". Please try again.' - ); - }); - it('defines a submit function', function() { expect(ctrl.submit).toBeDefined(); }); diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/create-keypair.html b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/create-keypair.html index 7b19be6d7a..148323f068 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/create-keypair.html +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/create-keypair.html @@ -29,17 +29,30 @@ {$ ctrl.keypairExistsError $} +
+ + + +
diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/keypair.controller.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/keypair.controller.js index 83809b3598..58ac7d1a1c 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/keypair.controller.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/keypair.controller.js @@ -36,6 +36,7 @@ * @param {Object} launchInstanceModel * @param {Object} $uibModal * @param {Object} toastService + * @param {Object} settingsService * @description * Allows selection of key pairs. * @returns {undefined} No return value diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/keypair.html b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/keypair.html index 5cae897c49..73f4154bae 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/keypair.html +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/keypair/keypair.html @@ -4,18 +4,6 @@ You may select an existing key pair, import a key pair, or generate a new key pair.

- -