diff --git a/spring-boot-admin-server-ui/src/main/frontend/components/sba-alert.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/components/sba-alert.spec.ts new file mode 100644 index 00000000000..d75e878e61a --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/components/sba-alert.spec.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2014-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { screen } from '@testing-library/vue'; +import { describe, expect, it } from 'vitest'; + +import SbaAlert from '@/components/sba-alert.vue'; + +import { render } from '@/test-utils'; + +describe('SbaAlert', () => { + it('should render string error message', async () => { + render(SbaAlert, { + props: { + error: 'Something went wrong', + }, + }); + + expect(await screen.findByText('Something went wrong')).toBeInTheDocument(); + }); + + it('should preserve safe markup in sanitized message', async () => { + render(SbaAlert, { + props: { + error: 'Request failed: timeout', + }, + }); + + const strong = await screen.findByText('timeout'); + expect(strong.tagName).toBe('STRONG'); + }); + + it('should remove XSS vectors from string error', async () => { + render(SbaAlert, { + props: { + error: + '', + }, + }); + + await screen.findByRole('alert'); + expect(document.querySelectorAll('img').length).toBe(0); + expect(document.querySelectorAll('script').length).toBe(0); + expect((window as any).__xss).toBeUndefined(); + }); + + it('should remove XSS vectors from Error message', async () => { + render(SbaAlert, { + props: { + error: new Error('boom'), + }, + }); + + expect(await screen.findByText('boom')).toBeInTheDocument(); + expect(document.querySelectorAll('img').length).toBe(0); + expect((window as any).__xss).toBeUndefined(); + }); +}); diff --git a/spring-boot-admin-server-ui/src/main/frontend/components/sba-alert.vue b/spring-boot-admin-server-ui/src/main/frontend/components/sba-alert.vue index ca6a95e448c..5aa2bd8195e 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/components/sba-alert.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/components/sba-alert.vue @@ -42,6 +42,8 @@ import { defineComponent } from 'vue'; import FontAwesomeIcon from '@/components/font-awesome-icon'; +import { sanitizeHtml } from '@/utils/sanitizeHtml'; + export const Severity = { ERROR: 'ERROR', WARN: 'WARN', @@ -90,10 +92,10 @@ export default defineComponent({ computed: { message() { if (this.error instanceof Error) { - return this.error.message; + return sanitizeHtml(this.error.message); } if (typeof this.error === 'string') { - return this.error; + return sanitizeHtml(this.error); } return null; diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/applications/ActionHandler.ts b/spring-boot-admin-server-ui/src/main/frontend/views/applications/ActionHandler.ts index c0f280d6827..d3915135101 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/applications/ActionHandler.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/views/applications/ActionHandler.ts @@ -1,5 +1,6 @@ import Application from '@/services/application'; import Instance from '@/services/instance'; +import { sanitizeHtml } from '@/utils/sanitizeHtml'; export interface ActionHandler { restart(item: any): Promise; @@ -19,7 +20,7 @@ export class InstanceActionHandler implements ActionHandler { async unregister(item: Instance) { const isConfirmed = await this.$sbaModal.confirm( this.t('applications.actions.unregister'), - this.t('instances.unregister', { name: item.id }), + sanitizeHtml(this.t('instances.unregister', { name: item.id })), ); if (!isConfirmed) { return; @@ -28,14 +29,18 @@ export class InstanceActionHandler implements ActionHandler { try { await item.unregister(); this.notificationCenter.success( - this.t('instances.unregister_successful', { name: item.id }), + sanitizeHtml( + this.t('instances.unregister_successful', { name: item.id }), + ), ); } catch (error) { this.notificationCenter.error( - this.t('instances.unregister_failed', { - name: item.id || item.name, - error: error.response.status, - }), + sanitizeHtml( + this.t('instances.unregister_failed', { + name: item.id || item.name, + error: error.response.status, + }), + ), ); } } @@ -43,7 +48,7 @@ export class InstanceActionHandler implements ActionHandler { async shutdown(item: Instance) { const isConfirmed = await this.$sbaModal.confirm( this.t('applications.actions.shutdown'), - this.t('instances.shutdown', { name: item.id }), + sanitizeHtml(this.t('instances.shutdown', { name: item.id })), ); if (!isConfirmed) { return; @@ -52,14 +57,18 @@ export class InstanceActionHandler implements ActionHandler { try { await item.shutdown(); this.notificationCenter.success( - this.t('instances.shutdown_successful', { name: item.id }), + sanitizeHtml( + this.t('instances.shutdown_successful', { name: item.id }), + ), ); } catch (error) { this.notificationCenter.error( - this.t('instances.shutdown_failed', { - name: item.id || item.name, - error: error.response.status, - }), + sanitizeHtml( + this.t('instances.shutdown_failed', { + name: item.id || item.name, + error: error.response.status, + }), + ), ); } } @@ -67,7 +76,7 @@ export class InstanceActionHandler implements ActionHandler { async restart(item: Instance) { const isConfirmed = await this.$sbaModal.confirm( this.t('applications.actions.restart'), - this.t('instances.restart', { name: item.id }), + sanitizeHtml(this.t('instances.restart', { name: item.id })), ); if (!isConfirmed) { return; @@ -76,14 +85,16 @@ export class InstanceActionHandler implements ActionHandler { try { await item.restart(); this.notificationCenter.success( - this.t('instances.restarted', { name: item.id }), + sanitizeHtml(this.t('instances.restarted', { name: item.id })), ); } catch (error) { this.notificationCenter.error( - this.t('instances.restart_failed', { - name: item.id || item.name, - error: error.response.status, - }), + sanitizeHtml( + this.t('instances.restart_failed', { + name: item.id || item.name, + error: error.response.status, + }), + ), ); } } @@ -99,7 +110,7 @@ export class ApplicationActionHandler implements ActionHandler { async restart(application: Application) { const isConfirmed = await this.$sbaModal.confirm( this.t('applications.actions.restart'), - this.t('applications.restart', { name: application.name }), + sanitizeHtml(this.t('applications.restart', { name: application.name })), ); if (!isConfirmed) { return; @@ -108,14 +119,18 @@ export class ApplicationActionHandler implements ActionHandler { try { await application.restart(); this.notificationCenter.success( - this.t('applications.restarted', { name: application.name }), + sanitizeHtml( + this.t('applications.restarted', { name: application.name }), + ), ); } catch (error) { this.notificationCenter.error( - this.t('applications.restart_failed', { - name: application.name, - error: error.response.status, - }), + sanitizeHtml( + this.t('applications.restart_failed', { + name: application.name, + error: error.response.status, + }), + ), ); } } @@ -123,7 +138,7 @@ export class ApplicationActionHandler implements ActionHandler { async shutdown(application: Application) { const isConfirmed = await this.$sbaModal.confirm( this.t('applications.actions.shutdown'), - this.t('applications.shutdown', { name: application.name }), + sanitizeHtml(this.t('applications.shutdown', { name: application.name })), ); if (!isConfirmed) { return; @@ -132,14 +147,20 @@ export class ApplicationActionHandler implements ActionHandler { try { await application.shutdown(); this.notificationCenter.success( - this.t('applications.shutdown_successful', { name: application.name }), + sanitizeHtml( + this.t('applications.shutdown_successful', { + name: application.name, + }), + ), ); } catch (error) { this.notificationCenter.error( - this.t('applications.shutdown_failed', { - name: application.name, - error: error.response.status, - }), + sanitizeHtml( + this.t('applications.shutdown_failed', { + name: application.name, + error: error.response.status, + }), + ), ); } } @@ -147,7 +168,9 @@ export class ApplicationActionHandler implements ActionHandler { async unregister(application: Application) { const isConfirmed = await this.$sbaModal.confirm( this.t('applications.actions.unregister'), - this.t('applications.unregister', { name: application.name }), + sanitizeHtml( + this.t('applications.unregister', { name: application.name }), + ), ); if (!isConfirmed) { return; @@ -156,16 +179,20 @@ export class ApplicationActionHandler implements ActionHandler { try { await application.unregister(); this.notificationCenter.success( - this.t('applications.unregister_successful', { - name: application.name, - }), + sanitizeHtml( + this.t('applications.unregister_successful', { + name: application.name, + }), + ), ); } catch (error) { this.notificationCenter.error( - this.t('applications.unregister_failed', { - name: application.name, - error: error.response.status, - }), + sanitizeHtml( + this.t('applications.unregister_failed', { + name: application.name, + error: error.response.status, + }), + ), ); } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/applications/NotificationFilterSettings.vue b/spring-boot-admin-server-ui/src/main/frontend/views/applications/NotificationFilterSettings.vue index 745599cd8a7..948194eaa49 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/applications/NotificationFilterSettings.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/applications/NotificationFilterSettings.vue @@ -21,9 +21,11 @@

    import { useI18n } from 'vue-i18n'; +import { sanitizeHtml } from '@/utils/sanitizeHtml'; + export default { props: { object: { @@ -102,6 +108,7 @@ export default { return { t: i18n.t, currentLocale: i18n.locale, + sanitizeHtml, }; }, data() { diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/applications/index.vue b/spring-boot-admin-server-ui/src/main/frontend/views/applications/index.vue index 0be53a52258..e2a70f796fa 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/applications/index.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/applications/index.vue @@ -203,6 +203,7 @@ import NotificationFilter from '@/services/notification-filter'; import axios from '@/utils/axios'; import { anyValueMatches } from '@/utils/collections'; import { Subject, concatMap, mergeWith, timer } from '@/utils/rxjs'; +import { sanitizeHtml } from '@/utils/sanitizeHtml'; import { useRouterState } from '@/utils/useRouterState'; import { useSubscription } from '@/utils/useSubscription'; import ApplicationListItemAction from '@/views/applications/ApplicationListItemAction.vue'; @@ -426,10 +427,12 @@ async function addFilter({ object, ttl }) { let notificationFilter = response.data; notificationFilterSubject.next(notificationFilter); notificationCenter.success( - `${t('applications.notifications_suppressed_for', { - name: - notificationFilter.applicationName || notificationFilter.instanceId, - })} ${notificationFilter.expiry.fromNow(true)}.`, + sanitizeHtml( + `${t('applications.notifications_suppressed_for', { + name: + notificationFilter.applicationName || notificationFilter.instanceId, + })} ${notificationFilter.expiry.fromNow(true)}.`, + ), ); } catch (error) { console.warn('Adding notification filter failed:', error); diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.spec.ts index 9b37ddd4b61..43177b6cb2b 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.spec.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.spec.ts @@ -184,4 +184,30 @@ describe('HealthDetails', () => { }, ); }); + + describe('XSS in string-valued details', () => { + it('should not render HTML/script markup contained in a string detail value', async () => { + render(HealthDetails, { + props: { + name: 'db', + health: { + status: 'UP', + details: { + canary: + '', + }, + }, + }, + }); + + const canaryDetail = await screen.findByRole('definition', { + name: 'canary', + }); + + expect(canaryDetail.innerHTML).not.toContain(' @@ -79,6 +79,7 @@ import { computed, useId } from 'vue'; import SbaFormattedObj from '@/components/sba-formatted-obj.vue'; import autolink from '@/utils/autolink'; +import { sanitizeHtml } from '@/utils/sanitizeHtml'; const id = useId(); diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/env/refresh.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/env/refresh.vue index 5e5f82d32a1..0b8920308ec 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/env/refresh.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/env/refresh.vue @@ -21,8 +21,29 @@