From b585c20d06dd3ec2c1f42e10feeb61cc9abce211 Mon Sep 17 00:00:00 2001 From: Matthew Irish Date: Fri, 1 Mar 2019 10:08:30 -0600 Subject: [PATCH] UI - fix encoding for user-entered paths (#6294) * directly depend on route-recognizer * add path encode helper using route-recognizer normalizer methods * encode user-entered paths/ids for places we're not using the built-in ember data buildUrl method * encode secret link params * decode params from the url, and encode for linked-block and navigate-input components * add escape-string-regexp * use list-controller mixin and escape the string when contructing new Regex objects * encode paths in the console service * add acceptance tests for kv secrets * make encoding in linked-block an attribute, and use it on secret lists * egp endpoints are enterprise-only, so include 'enterprise' text in the test * fix routing test and exclude single quote from encoding tests * encode cli string before tokenizing * encode auth_path for use with urlFor * add test for single quote via UI input instead of web cli --- ui/app/adapters/auth-method.js | 5 +- ui/app/adapters/lease.js | 7 ++- ui/app/adapters/role-aws.js | 5 +- ui/app/adapters/role-jwt.js | 2 + ui/app/adapters/role-pki.js | 5 +- ui/app/adapters/role-ssh.js | 7 ++- ui/app/adapters/secret-engine.js | 17 ++--- ui/app/adapters/secret-v2-version.js | 5 +- ui/app/adapters/secret-v2.js | 5 +- ui/app/adapters/secret.js | 5 +- ui/app/adapters/transit-key.js | 13 ++-- ui/app/components/key-value-header.js | 3 +- ui/app/components/linked-block.js | 13 +++- ui/app/components/navigate-input.js | 12 +++- ui/app/components/secret-link.js | 3 +- .../vault/cluster/access/leases/list.js | 37 +---------- .../vault/cluster/secrets/backend/list.js | 35 +---------- ui/app/lib/console-helpers.js | 5 +- ui/app/mixins/list-controller.js | 4 +- .../vault/cluster/secrets/backend/list.js | 31 +++++++--- .../cluster/secrets/backend/secret-edit.js | 32 ++++++---- ui/app/services/console.js | 3 +- .../partials/secret-list/aws-role-item.hbs | 1 + .../templates/partials/secret-list/item.hbs | 1 + .../partials/secret-list/pki-cert-item.hbs | 1 + .../partials/secret-list/pki-role-item.hbs | 1 + .../partials/secret-list/ssh-role-item.hbs | 1 + ui/app/utils/args-tokenizer.js | 38 ++++++++++++ ui/app/utils/path-encoding-helpers.js | 16 +++++ ui/package.json | 2 + ui/tests/acceptance/cluster-test.js | 2 +- .../secrets/backend/kv/secret-test.js | 62 ++++++++++++++++++- ui/yarn.lock | 2 +- 33 files changed, 248 insertions(+), 133 deletions(-) create mode 100644 ui/app/utils/args-tokenizer.js create mode 100644 ui/app/utils/path-encoding-helpers.js diff --git a/ui/app/adapters/auth-method.js b/ui/app/adapters/auth-method.js index 3691cc35831f..38f79aa11db2 100644 --- a/ui/app/adapters/auth-method.js +++ b/ui/app/adapters/auth-method.js @@ -2,11 +2,12 @@ import { assign } from '@ember/polyfills'; import { get, set } from '@ember/object'; import ApplicationAdapter from './application'; import DS from 'ember-data'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default ApplicationAdapter.extend({ url(path) { const url = `${this.buildURL()}/auth`; - return path ? url + '/' + path : url; + return path ? url + '/' + encodePath(path) : url; }, // used in updateRecord on the model#tune action @@ -58,6 +59,6 @@ export default ApplicationAdapter.extend({ }, exchangeOIDC(path, state, code) { - return this.ajax(`/v1/auth/${path}/oidc/callback`, 'GET', { data: { state, code } }); + return this.ajax(`/v1/auth/${encodePath(path)}/oidc/callback`, 'GET', { data: { state, code } }); }, }); diff --git a/ui/app/adapters/lease.js b/ui/app/adapters/lease.js index 4b2044159cc4..67fff5e2da4a 100644 --- a/ui/app/adapters/lease.js +++ b/ui/app/adapters/lease.js @@ -1,14 +1,15 @@ import ApplicationAdapter from './application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default ApplicationAdapter.extend({ revokePrefix(prefix) { - let url = this.buildURL() + '/leases/revoke-prefix/' + prefix; + let url = this.buildURL() + '/leases/revoke-prefix/' + encodePath(prefix); url = url.replace(/\/$/, ''); return this.ajax(url, 'PUT'); }, forceRevokePrefix(prefix) { - let url = this.buildURL() + '/leases/revoke-prefix/' + prefix; + let url = this.buildURL() + '/leases/revoke-prefix/' + encodePath(prefix); url = url.replace(/\/$/, ''); return this.ajax(url, 'PUT'); }, @@ -43,7 +44,7 @@ export default ApplicationAdapter.extend({ query(store, type, query) { const prefix = query.prefix || ''; - return this.ajax(this.buildURL() + '/leases/lookup/' + prefix, 'GET', { + return this.ajax(this.buildURL() + '/leases/lookup/' + encodePath(prefix), 'GET', { data: { list: true, }, diff --git a/ui/app/adapters/role-aws.js b/ui/app/adapters/role-aws.js index cd3b330bf729..da9b818e1732 100644 --- a/ui/app/adapters/role-aws.js +++ b/ui/app/adapters/role-aws.js @@ -1,5 +1,6 @@ import { assign } from '@ember/polyfills'; import ApplicationAdapter from './application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default ApplicationAdapter.extend({ namespace: 'v1', @@ -31,9 +32,9 @@ export default ApplicationAdapter.extend({ }, urlForRole(backend, id) { - let url = `${this.buildURL()}/${backend}/roles`; + let url = `${this.buildURL()}/${encodePath(backend)}/roles`; if (id) { - url = url + '/' + id; + url = url + '/' + encodePath(id); } return url; }, diff --git a/ui/app/adapters/role-jwt.js b/ui/app/adapters/role-jwt.js index 029b6c5925b0..b492a376994e 100644 --- a/ui/app/adapters/role-jwt.js +++ b/ui/app/adapters/role-jwt.js @@ -1,12 +1,14 @@ import ApplicationAdapter from './application'; import { inject as service } from '@ember/service'; import { get } from '@ember/object'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default ApplicationAdapter.extend({ router: service(), findRecord(store, type, id, snapshot) { let [path, role] = JSON.parse(id); + path = encodePath(path); let namespace = get(snapshot, 'adapterOptions.namespace'); let url = `/v1/auth/${path}/oidc/auth_url`; diff --git a/ui/app/adapters/role-pki.js b/ui/app/adapters/role-pki.js index cd3b330bf729..da9b818e1732 100644 --- a/ui/app/adapters/role-pki.js +++ b/ui/app/adapters/role-pki.js @@ -1,5 +1,6 @@ import { assign } from '@ember/polyfills'; import ApplicationAdapter from './application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default ApplicationAdapter.extend({ namespace: 'v1', @@ -31,9 +32,9 @@ export default ApplicationAdapter.extend({ }, urlForRole(backend, id) { - let url = `${this.buildURL()}/${backend}/roles`; + let url = `${this.buildURL()}/${encodePath(backend)}/roles`; if (id) { - url = url + '/' + id; + url = url + '/' + encodePath(id); } return url; }, diff --git a/ui/app/adapters/role-ssh.js b/ui/app/adapters/role-ssh.js index c41c3f40b49f..b0390be528e9 100644 --- a/ui/app/adapters/role-ssh.js +++ b/ui/app/adapters/role-ssh.js @@ -1,6 +1,7 @@ import { assign } from '@ember/polyfills'; import { resolve, allSettled } from 'rsvp'; import ApplicationAdapter from './application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default ApplicationAdapter.extend({ namespace: 'v1', @@ -34,9 +35,9 @@ export default ApplicationAdapter.extend({ }, urlForRole(backend, id) { - let url = `${this.buildURL()}/${backend}/roles`; + let url = `${this.buildURL()}/${encodePath(backend)}/roles`; if (id) { - url = url + '/' + id; + url = url + '/' + encodePath(id); } return url; }, @@ -84,7 +85,7 @@ export default ApplicationAdapter.extend({ findAllZeroAddress(store, query) { const { backend } = query; - const url = `/v1/${backend}/config/zeroaddress`; + const url = `/v1/${encodePath(backend)}/config/zeroaddress`; return this.ajax(url, 'GET'); }, diff --git a/ui/app/adapters/secret-engine.js b/ui/app/adapters/secret-engine.js index 42b998f26905..21f926a4587d 100644 --- a/ui/app/adapters/secret-engine.js +++ b/ui/app/adapters/secret-engine.js @@ -1,16 +1,17 @@ import { assign } from '@ember/polyfills'; import ApplicationAdapter from './application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default ApplicationAdapter.extend({ url(path) { const url = `${this.buildURL()}/mounts`; - return path ? url + '/' + path : url; + return path ? url + '/' + encodePath(path) : url; }, internalURL(path) { let url = `/${this.urlPrefix()}/internal/ui/mounts`; if (path) { - url = `${url}/${path}`; + url = `${url}/${encodePath(path)}`; } return url; }, @@ -38,14 +39,14 @@ export default ApplicationAdapter.extend({ findRecord(store, type, path, snapshot) { if (snapshot.attr('type') === 'ssh') { - return this.ajax(`/v1/${path}/config/ca`, 'GET'); + return this.ajax(`/v1/${encodePath(path)}/config/ca`, 'GET'); } return; }, queryRecord(store, type, query) { if (query.type === 'aws') { - return this.ajax(`/v1/${query.backend}/config/lease`, 'GET').then(resp => { + return this.ajax(`/v1/${encodePath(query.backend)}/config/lease`, 'GET').then(resp => { resp.path = query.backend + '/'; return resp; }); @@ -61,25 +62,25 @@ export default ApplicationAdapter.extend({ if (apiPath) { const serializer = store.serializerFor(type.modelName); const data = serializer.serialize(snapshot); - const path = snapshot.id; + const path = encodePath(snapshot.id); return this.ajax(`/v1/${path}/${apiPath}`, options.isDelete ? 'DELETE' : 'POST', { data }); } }, saveAWSRoot(store, type, snapshot) { let { data } = snapshot.adapterOptions; - const path = snapshot.id; + const path = encodePath(snapshot.id); return this.ajax(`/v1/${path}/config/root`, 'POST', { data }); }, saveAWSLease(store, type, snapshot) { let { data } = snapshot.adapterOptions; - const path = snapshot.id; + const path = encodePath(snapshot.id); return this.ajax(`/v1/${path}/config/lease`, 'POST', { data }); }, saveZeroAddressConfig(store, type, snapshot) { - const path = snapshot.id; + const path = encodePath(snapshot.id); const roles = store .peekAll('role-ssh') .filterBy('zeroAddress') diff --git a/ui/app/adapters/secret-v2-version.js b/ui/app/adapters/secret-v2-version.js index 1c9236470a9c..1e2177dc05cf 100644 --- a/ui/app/adapters/secret-v2-version.js +++ b/ui/app/adapters/secret-v2-version.js @@ -3,13 +3,14 @@ import { isEmpty } from '@ember/utils'; import { get } from '@ember/object'; import ApplicationAdapter from './application'; import DS from 'ember-data'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default ApplicationAdapter.extend({ namespace: 'v1', _url(backend, id, infix = 'data') { - let url = `${this.buildURL()}/${backend}/${infix}/`; + let url = `${this.buildURL()}/${encodePath(backend)}/${infix}/`; if (!isEmpty(id)) { - url = url + id; + url = url + encodePath(id); } return url; }, diff --git a/ui/app/adapters/secret-v2.js b/ui/app/adapters/secret-v2.js index 14310c070d38..517f83d77d36 100644 --- a/ui/app/adapters/secret-v2.js +++ b/ui/app/adapters/secret-v2.js @@ -1,13 +1,14 @@ /* eslint-disable */ import { isEmpty } from '@ember/utils'; import ApplicationAdapter from './application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default ApplicationAdapter.extend({ namespace: 'v1', _url(backend, id) { - let url = `${this.buildURL()}/${backend}/metadata/`; + let url = `${this.buildURL()}/${encodePath(backend)}/metadata/`; if (!isEmpty(id)) { - url = url + id; + url = url + encodePath(id); } return url; }, diff --git a/ui/app/adapters/secret.js b/ui/app/adapters/secret.js index 59c39a0387ea..3d5fc059cf13 100644 --- a/ui/app/adapters/secret.js +++ b/ui/app/adapters/secret.js @@ -1,5 +1,6 @@ import { isEmpty } from '@ember/utils'; import ApplicationAdapter from './application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default ApplicationAdapter.extend({ namespace: 'v1', @@ -26,9 +27,9 @@ export default ApplicationAdapter.extend({ }, urlForSecret(backend, id) { - let url = `${this.buildURL()}/${backend}/`; + let url = `${this.buildURL()}/${encodePath(backend)}/`; if (!isEmpty(id)) { - url = url + id; + url = url + encodePath(id); } return url; diff --git a/ui/app/adapters/transit-key.js b/ui/app/adapters/transit-key.js index 876c0096a87d..9e23280be00b 100644 --- a/ui/app/adapters/transit-key.js +++ b/ui/app/adapters/transit-key.js @@ -1,5 +1,6 @@ import ApplicationAdapter from './application'; import { pluralize } from 'ember-inflector'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default ApplicationAdapter.extend({ namespace: 'v1', @@ -47,29 +48,29 @@ export default ApplicationAdapter.extend({ }, urlForSecret(backend, id) { - let url = `${this.buildURL()}/${backend}/keys/`; + let url = `${this.buildURL()}/${encodePath(backend)}/keys/`; if (id) { - url += id; + url += encodePath(id); } return url; }, urlForAction(action, backend, id, param) { - let urlBase = `${this.buildURL()}/${backend}/${action}`; + let urlBase = `${this.buildURL()}/${encodePath(backend)}/${action}`; // these aren't key-specific if (action === 'hash' || action === 'random') { return urlBase; } if (action === 'datakey' && param) { // datakey action has `wrapped` or `plaintext` as part of the url - return `${urlBase}/${param}/${id}`; + return `${urlBase}/${param}/${encodePath(id)}`; } if (action === 'export' && param) { let [type, version] = param; - const exportBase = `${urlBase}/${type}-key/${id}`; + const exportBase = `${urlBase}/${type}-key/${encodePath(id)}`; return version ? `${exportBase}/${version}` : exportBase; } - return `${urlBase}/${id}`; + return `${urlBase}/${encodePath(id)}`; }, optionsForQuery(id) { diff --git a/ui/app/components/key-value-header.js b/ui/app/components/key-value-header.js index 24498e99a935..7e9b59b4cc1f 100644 --- a/ui/app/components/key-value-header.js +++ b/ui/app/components/key-value-header.js @@ -1,6 +1,7 @@ import { computed } from '@ember/object'; import Component from '@ember/component'; import utils from 'vault/lib/key-utils'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default Component.extend({ tagName: 'nav', @@ -31,7 +32,7 @@ export default Component.extend({ let crumbs = []; const root = this.get('root'); const baseKey = this.get('baseKey.display') || this.get('baseKey.id'); - const baseKeyModel = this.get('baseKey.id'); + const baseKeyModel = encodePath(this.get('baseKey.id')); if (root) { crumbs.push(root); diff --git a/ui/app/components/linked-block.js b/ui/app/components/linked-block.js index d9b459ba8ee4..b829b2754805 100644 --- a/ui/app/components/linked-block.js +++ b/ui/app/components/linked-block.js @@ -1,6 +1,7 @@ import { inject as service } from '@ember/service'; import Component from '@ember/component'; import hbs from 'htmlbars-inline-precompile'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; let LinkedBlockComponent = Component.extend({ router: service(), @@ -11,6 +12,8 @@ let LinkedBlockComponent = Component.extend({ queryParams: null, + encode: false, + click(event) { const $target = this.$(event.target); const isAnchorOrButton = @@ -19,7 +22,15 @@ let LinkedBlockComponent = Component.extend({ $target.closest('button', event.currentTarget).length > 0 || $target.closest('a', event.currentTarget).length > 0; if (!isAnchorOrButton) { - const params = this.get('params'); + let params = this.get('params'); + if (this.encode) { + params = params.map((param, index) => { + if (index === 0 || typeof param !== 'string') { + return param; + } + return encodePath(param); + }); + } const queryParams = this.get('queryParams'); if (queryParams) { params.push({ queryParams }); diff --git a/ui/app/components/navigate-input.js b/ui/app/components/navigate-input.js index b32ad6b4c2ef..3e3a5ad0efc9 100644 --- a/ui/app/components/navigate-input.js +++ b/ui/app/components/navigate-input.js @@ -5,6 +5,7 @@ import Component from '@ember/component'; import utils from 'vault/lib/key-utils'; import keys from 'vault/lib/keycodes'; import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; const routeFor = function(type, mode) { const MODES = { @@ -43,8 +44,15 @@ export default Component.extend(FocusOnInsertMixin, { filterMatchesKey: null, firstPartialMatch: null, - transitionToRoute: function() { - this.get('router').transitionTo(...arguments); + transitionToRoute(...args) { + let params = args.map((param, index) => { + if (index === 0 || typeof param !== 'string') { + return param; + } + return encodePath(param); + }); + + this.get('router').transitionTo(...params); }, shouldFocus: false, diff --git a/ui/app/components/secret-link.js b/ui/app/components/secret-link.js index e563a5900390..0b2b12d0aaa5 100644 --- a/ui/app/components/secret-link.js +++ b/ui/app/components/secret-link.js @@ -1,5 +1,6 @@ import { computed } from '@ember/object'; import Component from '@ember/component'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export function linkParams({ mode, secret, queryParams }) { let params; @@ -8,7 +9,7 @@ export function linkParams({ mode, secret, queryParams }) { if (!secret || secret === ' ') { params = [route + '-root']; } else { - params = [route, secret]; + params = [route, encodePath(secret)]; } if (queryParams) { diff --git a/ui/app/controllers/vault/cluster/access/leases/list.js b/ui/app/controllers/vault/cluster/access/leases/list.js index a80e513836c4..985502d25a88 100644 --- a/ui/app/controllers/vault/cluster/access/leases/list.js +++ b/ui/app/controllers/vault/cluster/access/leases/list.js @@ -2,19 +2,12 @@ import { inject as service } from '@ember/service'; import { computed } from '@ember/object'; import Controller, { inject as controller } from '@ember/controller'; import utils from 'vault/lib/key-utils'; +import ListController from 'vault/mixins/list-controller'; -export default Controller.extend({ +export default Controller.extend(ListController, { flashMessages: service(), store: service(), clusterController: controller('vault.cluster'), - queryParams: { - page: 'page', - pageFilter: 'pageFilter', - }, - - page: 1, - pageFilter: null, - filter: null, backendCrumb: computed(function() { return { @@ -27,24 +20,6 @@ export default Controller.extend({ isLoading: false, - filterMatchesKey: computed('filter', 'model', 'model.[]', function() { - var filter = this.get('filter'); - var content = this.get('model'); - return !!(content.length && content.findBy('id', filter)); - }), - - firstPartialMatch: computed('filter', 'model', 'model.[]', 'filterMatchesKey', function() { - var filter = this.get('filter'); - var content = this.get('model'); - var filterMatchesKey = this.get('filterMatchesKey'); - var re = new RegExp('^' + filter); - return filterMatchesKey - ? null - : content.find(function(key) { - return re.test(key.get('id')); - }); - }), - filterIsFolder: computed('filter', function() { return !!utils.keyIsFolder(this.get('filter')); }), @@ -65,14 +40,6 @@ export default Controller.extend({ }), actions: { - setFilter(val) { - this.set('filter', val); - }, - - setFilterFocus(bool) { - this.set('filterFocused', bool); - }, - revokePrefix(prefix, isForce) { const adapter = this.get('store').adapterFor('lease'); const method = isForce ? 'forceRevokePrefix' : 'revokePrefix'; diff --git a/ui/app/controllers/vault/cluster/secrets/backend/list.js b/ui/app/controllers/vault/cluster/secrets/backend/list.js index 4faf8b1075ef..cfda28671fcd 100644 --- a/ui/app/controllers/vault/cluster/secrets/backend/list.js +++ b/ui/app/controllers/vault/cluster/secrets/backend/list.js @@ -4,50 +4,19 @@ import Controller from '@ember/controller'; import utils from 'vault/lib/key-utils'; import BackendCrumbMixin from 'vault/mixins/backend-crumb'; import WithNavToNearestAncestor from 'vault/mixins/with-nav-to-nearest-ancestor'; +import ListController from 'vault/mixins/list-controller'; -export default Controller.extend(BackendCrumbMixin, WithNavToNearestAncestor, { +export default Controller.extend(ListController, BackendCrumbMixin, WithNavToNearestAncestor, { flashMessages: service(), queryParams: ['page', 'pageFilter', 'tab'], tab: '', - page: 1, - pageFilter: null, - filterFocused: false, - - // set via the route `loading` action - isLoading: false, - - filterMatchesKey: computed('filter', 'model', 'model.[]', function() { - var filter = this.get('filter'); - var content = this.get('model'); - return !!(content.length && content.findBy('id', filter)); - }), - - firstPartialMatch: computed('filter', 'model', 'model.[]', 'filterMatchesKey', function() { - var filter = this.get('filter'); - var content = this.get('model'); - var filterMatchesKey = this.get('filterMatchesKey'); - var re = new RegExp('^' + filter); - return filterMatchesKey - ? null - : content.find(function(key) { - return re.test(key.get('id')); - }); - }), filterIsFolder: computed('filter', function() { return !!utils.keyIsFolder(this.get('filter')); }), actions: { - setFilter(val) { - this.set('filter', val); - }, - - setFilterFocus(bool) { - this.set('filterFocused', bool); - }, - chooseAction(action) { this.set('selectedAction', action); }, diff --git a/ui/app/lib/console-helpers.js b/ui/app/lib/console-helpers.js index 211a65c5e898..c0cb651f7496 100644 --- a/ui/app/lib/console-helpers.js +++ b/ui/app/lib/console-helpers.js @@ -56,11 +56,14 @@ export function executeUICommand(command, logAndOutput, clearLog, toggleFullscre } export function parseCommand(command, shouldThrow) { - let args = argTokenizer(command); + // encode everything but spaces + let cmd = encodeURIComponent(command).replace(/%20/g, decodeURIComponent); + let args = argTokenizer(cmd); if (args[0] === 'vault') { args.shift(); } + args = args.map(decodeURIComponent); let [method, ...rest] = args; let path; let flags = []; diff --git a/ui/app/mixins/list-controller.js b/ui/app/mixins/list-controller.js index 2c6e4ad5ae4b..423b42a12e21 100644 --- a/ui/app/mixins/list-controller.js +++ b/ui/app/mixins/list-controller.js @@ -1,5 +1,6 @@ import { computed } from '@ember/object'; import Mixin from '@ember/object/mixin'; +import escapeStringRegexp from 'escape-string-regexp'; export default Mixin.create({ queryParams: { @@ -10,6 +11,7 @@ export default Mixin.create({ page: 1, pageFilter: null, filter: null, + filterFocused: false, isLoading: false, @@ -23,7 +25,7 @@ export default Mixin.create({ var filter = this.get('filter'); var content = this.get('model'); var filterMatchesKey = this.get('filterMatchesKey'); - var re = new RegExp('^' + filter); + var re = new RegExp('^' + escapeStringRegexp(filter)); return filterMatchesKey ? null : content.find(function(key) { diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index cdf5e6ac1f86..239192d21de3 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -4,10 +4,13 @@ import Route from '@ember/routing/route'; import { getOwner } from '@ember/application'; import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; import { inject as service } from '@ember/service'; +import { normalizePath } from 'vault/utils/path-encoding-helpers'; const SUPPORTED_BACKENDS = supportedSecretBackends(); export default Route.extend({ + templateName: 'vault/cluster/secrets/backend/list', + pathHelp: service('path-help'), queryParams: { page: { refreshModel: true, @@ -20,13 +23,21 @@ export default Route.extend({ }, }, - templateName: 'vault/cluster/secrets/backend/list', - pathHelp: service('path-help'), + secretParam() { + let { secret } = this.paramsFor(this.routeName); + return secret ? normalizePath(secret) : ''; + }, + + enginePathParam() { + let { backend } = this.paramsFor('vault.cluster.secrets.backend'); + return backend; + }, beforeModel() { let owner = getOwner(this); - let { secret } = this.paramsFor(this.routeName); - let { backend, tab } = this.paramsFor('vault.cluster.secrets.backend'); + let secret = this.secretParam(); + let backend = this.enginePathParam(); + let { tab } = this.paramsFor('vault.cluster.secrets.backend'); let secretEngine = this.store.peekRecord('secret-engine', backend); let type = secretEngine && secretEngine.get('engineType'); if (!type || !SUPPORTED_BACKENDS.includes(type)) { @@ -58,8 +69,8 @@ export default Route.extend({ }, model(params) { - const secret = params.secret ? params.secret : ''; - const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const secret = this.secretParam() || ''; + const backend = this.enginePathParam(); const backendModel = this.modelFor('vault.cluster.secrets.backend'); return hash({ secret, @@ -89,7 +100,7 @@ export default Route.extend({ afterModel(model) { const { tab } = this.paramsFor(this.routeName); - const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const backend = this.enginePathParam(); if (!tab || tab !== 'certs') { return; } @@ -114,7 +125,7 @@ export default Route.extend({ let secretParams = this.paramsFor(this.routeName); let secret = resolvedModel.secret; let model = resolvedModel.secrets; - let { backend } = this.paramsFor('vault.cluster.secrets.backend'); + let backend = this.enginePathParam(); let backendModel = this.store.peekRecord('secret-engine', backend); let has404 = this.get('has404'); // only clear store cache if this is a new model @@ -155,8 +166,8 @@ export default Route.extend({ actions: { error(error, transition) { - let { secret } = this.paramsFor(this.routeName); - let { backend } = this.paramsFor('vault.cluster.secrets.backend'); + let secret = this.secretParam(); + let backend = this.enginePathParam(); let is404 = error.httpStatus === 404; let hasModel = this.controllerFor(this.routeName).get('hasModel'); diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index f87573a5dee1..f8c84e34d2be 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -6,11 +6,20 @@ import Route from '@ember/routing/route'; import utils from 'vault/lib/key-utils'; import { getOwner } from '@ember/application'; import UnloadModelRoute from 'vault/mixins/unload-model-route'; +import { encodePath, normalizePath } from 'vault/utils/path-encoding-helpers'; export default Route.extend(UnloadModelRoute, { pathHelp: service('path-help'), + secretParam() { + let { secret } = this.paramsFor(this.routeName); + return secret ? normalizePath(secret) : ''; + }, + enginePathParam() { + let { backend } = this.paramsFor('vault.cluster.secrets.backend'); + return backend; + }, capabilities(secret) { - const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const backend = this.enginePathParam(); let backendModel = this.modelFor('vault.cluster.secrets.backend'); let backendType = backendModel.get('engineType'); if (backendType === 'kv' || backendType === 'cubbyhole' || backendType === 'generic') { @@ -37,13 +46,13 @@ export default Route.extend(UnloadModelRoute, { // currently there is no recursive delete for folders in vault, so there's no need to 'edit folders' // perhaps in the future we could recurse _for_ users, but for now, just kick them // back to the list - const { secret } = this.paramsFor(this.routeName); + let secret = this.secretParam(); return this.buildModel(secret).then(() => { const parentKey = utils.parentKeyForKey(secret); const mode = this.routeName.split('.').pop(); if (mode === 'edit' && utils.keyIsFolder(secret)) { if (parentKey) { - return this.transitionTo('vault.cluster.secrets.backend.list', parentKey); + return this.transitionTo('vault.cluster.secrets.backend.list', encodePath(parentKey)); } else { return this.transitionTo('vault.cluster.secrets.backend.list-root'); } @@ -52,7 +61,8 @@ export default Route.extend(UnloadModelRoute, { }, buildModel(secret) { - const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const backend = this.enginePathParam(); + let modelType = this.modelType(backend, secret); if (['secret', 'secret-v2'].includes(modelType)) { return resolve(); @@ -77,10 +87,10 @@ export default Route.extend(UnloadModelRoute, { }, model(params) { - let { secret } = params; - const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + let secret = this.secretParam(); + let backend = this.enginePathParam(); let backendModel = this.modelFor('vault.cluster.secrets.backend', backend); - const modelType = this.modelType(backend, secret); + let modelType = this.modelType(backend, secret); if (!secret) { secret = '\u0020'; @@ -139,8 +149,8 @@ export default Route.extend(UnloadModelRoute, { setupController(controller, model) { this._super(...arguments); - const { secret } = this.paramsFor(this.routeName); - const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + let secret = this.secretParam(); + let backend = this.enginePathParam(); const preferAdvancedEdit = this.controllerFor('vault.cluster.secrets.backend').get('preferAdvancedEdit') || false; const backendType = this.backendType(); @@ -168,8 +178,8 @@ export default Route.extend(UnloadModelRoute, { actions: { error(error) { - const { secret } = this.paramsFor(this.routeName); - const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + let secret = this.secretParam(); + let backend = this.enginePathParam(); set(error, 'keyId', backend + '/' + secret); set(error, 'backend', backend); return true; diff --git a/ui/app/services/console.js b/ui/app/services/console.js index f9f5afd2b4c2..babac4e7b9de 100644 --- a/ui/app/services/console.js +++ b/ui/app/services/console.js @@ -5,6 +5,7 @@ import Service from '@ember/service'; import { getOwner } from '@ember/application'; import { computed } from '@ember/object'; import { shiftCommandIndex } from 'vault/lib/console-helpers'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export function sanitizePath(path) { //remove whitespace + remove trailing and leading slashes @@ -74,7 +75,7 @@ export default Service.extend({ ajax(operation, path, options = {}) { let verb = VERBS[operation]; let adapter = this.adapter(); - let url = adapter.buildURL(path); + let url = adapter.buildURL(encodePath(path)); let { data, wrapTTL } = options; return adapter.ajax(url, verb, { data, diff --git a/ui/app/templates/partials/secret-list/aws-role-item.hbs b/ui/app/templates/partials/secret-list/aws-role-item.hbs index 47af1119631a..54eb34357c99 100644 --- a/ui/app/templates/partials/secret-list/aws-role-item.hbs +++ b/ui/app/templates/partials/secret-list/aws-role-item.hbs @@ -7,6 +7,7 @@ item.id class="list-item-row" data-test-secret-link=item.id + encode=true }}
diff --git a/ui/app/templates/partials/secret-list/item.hbs b/ui/app/templates/partials/secret-list/item.hbs index b16b5f2d6ae5..7530e6ea882c 100644 --- a/ui/app/templates/partials/secret-list/item.hbs +++ b/ui/app/templates/partials/secret-list/item.hbs @@ -7,6 +7,7 @@ item.id class="list-item-row" data-test-secret-link=item.id + encode=true }}
diff --git a/ui/app/templates/partials/secret-list/pki-cert-item.hbs b/ui/app/templates/partials/secret-list/pki-cert-item.hbs index 76f5b5afc0e0..096705279d20 100644 --- a/ui/app/templates/partials/secret-list/pki-cert-item.hbs +++ b/ui/app/templates/partials/secret-list/pki-cert-item.hbs @@ -8,6 +8,7 @@ class="list-item-row" data-test-secret-link=item.id tagName="div" + encode=true }}
diff --git a/ui/app/templates/partials/secret-list/pki-role-item.hbs b/ui/app/templates/partials/secret-list/pki-role-item.hbs index e272074c678a..37113a740493 100644 --- a/ui/app/templates/partials/secret-list/pki-role-item.hbs +++ b/ui/app/templates/partials/secret-list/pki-role-item.hbs @@ -10,6 +10,7 @@ class="list-item-row" data-test-secret-link=item.id tagName="div" + encode=true }}
diff --git a/ui/app/templates/partials/secret-list/ssh-role-item.hbs b/ui/app/templates/partials/secret-list/ssh-role-item.hbs index caed3090d7ca..5c582ef7bb99 100644 --- a/ui/app/templates/partials/secret-list/ssh-role-item.hbs +++ b/ui/app/templates/partials/secret-list/ssh-role-item.hbs @@ -7,6 +7,7 @@ item.id class="list-item-row" data-test-secret-link=item.id + encode=true }}
diff --git a/ui/app/utils/args-tokenizer.js b/ui/app/utils/args-tokenizer.js new file mode 100644 index 000000000000..c77b0b531bac --- /dev/null +++ b/ui/app/utils/args-tokenizer.js @@ -0,0 +1,38 @@ +export default function(argString) { + if (Array.isArray(argString)) return argString; + + argString = argString.trim(); + + var i = 0; + var prevC = null; + var c = null; + var opening = null; + var args = []; + + for (var ii = 0; ii < argString.length; ii++) { + prevC = c; + c = argString.charAt(ii); + + // split on spaces unless we're in quotes. + if (c === ' ' && !opening) { + if (!(prevC === ' ')) { + i++; + } + continue; + } + + // don't split the string if we're in matching + // opening or closing single and double quotes. + if (c === opening) { + if (!args[i]) args[i] = ''; + opening = null; + } else if ((c === "'" || c === '"') && argString.indexOf(c, ii + 1) > 0 && !opening) { + opening = c; + } + + if (!args[i]) args[i] = ''; + args[i] += c; + } + + return args; +} diff --git a/ui/app/utils/path-encoding-helpers.js b/ui/app/utils/path-encoding-helpers.js new file mode 100644 index 000000000000..ce5f082a7214 --- /dev/null +++ b/ui/app/utils/path-encoding-helpers.js @@ -0,0 +1,16 @@ +import RouteRecognizer from 'route-recognizer'; + +const { + Normalizer: { normalizePath, encodePathSegment }, +} = RouteRecognizer; + +export function encodePath(path) { + return path + ? path + .split('/') + .map(encodePathSegment) + .join('/') + : path; +} + +export { normalizePath, encodePathSegment }; diff --git a/ui/package.json b/ui/package.json index 7cfa5c23d75e..0253593f005a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -94,6 +94,7 @@ "ember-source": "~3.4.0", "ember-test-selectors": "^1.0.0", "ember-truth-helpers": "^2.1.0", + "escape-string-regexp": "^1.0.5", "eslint-config-prettier": "^3.1.0", "eslint-plugin-ember": "^5.2.0", "eslint-plugin-prettier": "^3.0.0", @@ -106,6 +107,7 @@ "prettier": "^1.14.3", "prettier-eslint-cli": "^4.7.1", "qunit-dom": "^0.7.1", + "route-recognizer": "^0.3.4", "sass-svg-uri": "^1.0.0", "string.prototype.endswith": "^0.2.0", "string.prototype.startswith": "^0.2.0", diff --git a/ui/tests/acceptance/cluster-test.js b/ui/tests/acceptance/cluster-test.js index f1c633de441f..9463efef24f2 100644 --- a/ui/tests/acceptance/cluster-test.js +++ b/ui/tests/acceptance/cluster-test.js @@ -54,7 +54,7 @@ module('Acceptance | cluster', function(hooks) { await logout.visit(); }); - test('nav item links to first route that user has access to', async function(assert) { + test('enterprise nav item links to first route that user has access to', async function(assert) { const read_rgp_policy = `' path "sys/policies/rgp" { capabilities = ["read"] diff --git a/ui/tests/acceptance/secrets/backend/kv/secret-test.js b/ui/tests/acceptance/secrets/backend/kv/secret-test.js index 89bd54273c28..355025567baa 100644 --- a/ui/tests/acceptance/secrets/backend/kv/secret-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/secret-test.js @@ -1,4 +1,4 @@ -import { settled, currentURL, currentRouteName } from '@ember/test-helpers'; +import { visit, settled, currentURL, currentRouteName } from '@ember/test-helpers'; import { create } from 'ember-cli-page-object'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; @@ -177,7 +177,8 @@ module('Acceptance | secrets/secret/create', function(hooks) { await listPage.visitRoot({ backend: 'secret' }); await listPage.create(); await editPage.createSecret(path, 'foo', 'bar'); - await listPage.visit({ backend: 'secret', id: 'foo/bar' }); + // use visit helper here because ids with / in them get encoded + await visit('/vault/secrets/secret/list/foo/bar'); assert.equal(currentRouteName(), 'vault.cluster.secrets.backend.list'); assert.ok(currentURL().endsWith('/'), 'redirects to the path ending in a slash'); }); @@ -259,4 +260,61 @@ module('Acceptance | secrets/secret/create', function(hooks) { assert.ok(showPage.editIsPresent, 'shows the edit button'); await logout.visit(); }); + + test('paths are properly encoded', async function(assert) { + let backend = 'kv'; + let paths = [ + '(', + ')', + '"', + //"'", + '!', + '#', + '$', + '&', + '*', + '+', + '@', + '{', + '|', + '}', + '~', + '[', + '\\', + ']', + '^', + '_', + ].map(char => `${char}some`); + assert.expect(paths.length * 2); + let secretName = '2'; + let commands = paths.map(path => `write ${backend}/${path}/${secretName} 3=4`); + await consoleComponent.runCommands(['write sys/mounts/kv type=kv', ...commands]); + for (let path of paths) { + await listPage.visit({ backend, id: path }); + assert.ok(listPage.secrets.filterBy('text', '2')[0], `${path}: secret is displayed properly`); + await listPage.secrets.filterBy('text', '2')[0].click(); + assert.equal( + currentRouteName(), + 'vault.cluster.secrets.backend.show', + `${path}: show page renders correctly` + ); + } + }); + + // the web cli does not handle a single quote in a path, so we test it here via the UI + test('creating a secret with a single quote works properly', async function(assert) { + await consoleComponent.runCommands('write sys/mounts/kv type=kv'); + let path = "'some"; + await listPage.visitRoot({ backend: 'kv' }); + await listPage.create(); + await editPage.createSecret(`${path}/2`, 'foo', 'bar'); + await listPage.visit({ backend: 'kv', id: path }); + assert.ok(listPage.secrets.filterBy('text', '2')[0], `${path}: secret is displayed properly`); + await listPage.secrets.filterBy('text', '2')[0].click(); + assert.equal( + currentRouteName(), + 'vault.cluster.secrets.backend.show', + `${path}: show page renders correctly` + ); + }); }); diff --git a/ui/yarn.lock b/ui/yarn.lock index 3839ea2cc29b..28bcd8bda8ed 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -11452,7 +11452,7 @@ rollup@^0.57.1: signal-exit "^3.0.2" sourcemap-codec "^1.4.1" -route-recognizer@^0.3.3: +route-recognizer@^0.3.3, route-recognizer@^0.3.4: version "0.3.4" resolved "https://registry.yarnpkg.com/route-recognizer/-/route-recognizer-0.3.4.tgz#39ab1ffbce1c59e6d2bdca416f0932611e4f3ca3" integrity sha512-2+MhsfPhvauN1O8KaXpXAOfR/fwe8dnUXVM+xw7yt40lJRfPVQxV6yryZm0cgRvAj5fMF/mdRZbL2ptwbs5i2g==