Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from '@testing-library/vue';
import { HttpResponse, http } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Ref, ref } from 'vue';

import { useApplicationStore } from '@/composables/useApplicationStore';
import { server } from '@/mocks/server';
import Application from '@/services/application';
import Instance, { Registration } from '@/services/instance';
import NotificationFilter from '@/services/notification-filter';
import { render } from '@/test-utils';
import Applications from '@/views/applications/index.vue';

Expand Down Expand Up @@ -284,3 +285,125 @@ describe('Applications', () => {
});
});
});

describe('Applications add notification filter', () => {
const renderWithApplication = (application: Application) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
useApplicationStore.mockReturnValue({
applicationStore: {
findApplicationByInstanceId: (id: string) =>
application.instances.find((i) => i.id === id) ? application : null,
},
applicationsInitialized: ref(true),
applications: ref([application]),
error: ref(null),
});

render(Applications);
};

const createApplication = (name: string, instanceId: string) =>
new Application({
id: 'app-id',
name,
status: 'UP',
instances: [
new Instance({
id: instanceId,
statusInfo: { status: 'UP' },
registration: {
name,
serviceUrl: 'serviceUrl',
metadata: {},
} as Registration,
}),
],
});

const clearToasts = () =>
// The toasts are rendered outside of the component tree, hence they are
// not removed by the testing-library cleanup.
document
.querySelectorAll('.v-toast-container')
.forEach((container) => container.remove());

const getToast = async () =>
await waitFor(() => {
const toast = document.querySelector('.v-toast__message');
expect(toast).not.toBeNull();
return toast as HTMLElement;
});

beforeEach(() => {
clearToasts();
vi.spyOn(NotificationFilter, 'isSupported').mockReturnValue(true);
vi.spyOn(NotificationFilter, 'getFilters').mockResolvedValue({
data: [],
} as any);
});

afterEach(() => {
clearToasts();
delete (window as any).__xss;
});

it('strips dangerous markup contained in the application name from the success toast', async () => {
const maliciousName = '<img src=x onerror="window.__xss=1">rogue-app';
vi.spyOn(NotificationFilter, 'addFilter').mockResolvedValue({
data: new NotificationFilter({
id: 'filter-id',
applicationName: maliciousName,
expiry: Date.now() + 5 * 60 * 1000,
}),
} as any);

renderWithApplication(createApplication(maliciousName, 'instance-id'));

await userEvent.click(await screen.findByTitle('Notification filters'));
await userEvent.click(await screen.findByText('Suppress'));

const toast = await getToast();

// The application name still shows up as text ...
expect(toast.textContent).toContain('rogue-app');
// ... and so does the expiry, which is intentionally rendered as markup ...
expect(toast.querySelector('strong')).toHaveTextContent('5 minutes');
// ... but the <img onerror=...> must have been stripped by sanitizeHtml,
// it must never be parsed into a real element / fire its handler.
expect(toast.querySelector('img')).toBeNull();
expect((window as any).__xss).toBeUndefined();
});

it('strips script tags contained in the instance id from the success toast', async () => {
const maliciousId = '<script>window.__xss=1</script>evil-instance';
vi.spyOn(NotificationFilter, 'addFilter').mockResolvedValue({
data: new NotificationFilter({
id: 'filter-id',
instanceId: maliciousId,
expiry: Date.now() + 5 * 60 * 1000,
}),
} as any);

renderWithApplication(createApplication('an-application', maliciousId));

// Expand the application to get hold of the instance level actions.
await userEvent.click(
await screen.findByRole('button', { name: /an-application/i }),
);
await userEvent.click(
await waitFor(() => {
const button = document.getElementById(`nf-settings-${maliciousId}`);
expect(button).not.toBeNull();
return button as HTMLElement;
}),
);
await userEvent.click(await screen.findByText('Suppress'));

const toast = await getToast();

expect(toast.textContent).toContain('evil-instance');
expect(toast.querySelector('script')).toBeNull();
expect((window as any).__xss).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ import Instance from '@/services/instance';
import NotificationFilter from '@/services/notification-filter';
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';
Expand Down Expand Up @@ -463,10 +464,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,
})} <strong>${notificationFilter.expiry.fromNow(true)}</strong>.`,
sanitizeHtml(
`${t('applications.notifications_suppressed_for', {
name:
notificationFilter.applicationName || notificationFilter.instanceId,
})} <strong>${notificationFilter.expiry.fromNow(true)}</strong>.`,
),
);
} catch (error) {
console.warn('Adding notification filter failed:', error);
Expand Down
Loading