fix(editor): Stop nefarious redirects during sign in (#16034)

This commit is contained in:
Marc Littlemore
2025-06-05 08:31:55 +01:00
committed by GitHub
parent 02ed7b6631
commit 4865d1e360
2 changed files with 123 additions and 17 deletions

View File

@@ -2,11 +2,12 @@ import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useRouter } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import SigninView from '@/views/SigninView.vue'; import SigninView from '@/views/SigninView.vue';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { VIEWS } from '@/constants';
vi.mock('vue-router', () => { vi.mock('vue-router', () => {
const push = vi.fn(); const push = vi.fn();
@@ -14,7 +15,7 @@ vi.mock('vue-router', () => {
useRouter: () => ({ useRouter: () => ({
push, push,
}), }),
useRoute: () => ({ useRoute: vi.fn().mockReturnValue({
query: { query: {
redirect: '/home/workflows', redirect: '/home/workflows',
}, },
@@ -43,20 +44,7 @@ let router: ReturnType<typeof useRouter>;
let telemetry: ReturnType<typeof useTelemetry>; let telemetry: ReturnType<typeof useTelemetry>;
describe('SigninView', () => { describe('SigninView', () => {
beforeEach(() => { const signInWithValidUser = async () => {
createTestingPinia();
usersStore = mockedStore(useUsersStore);
settingsStore = mockedStore(useSettingsStore);
router = useRouter();
telemetry = useTelemetry();
});
it('should not throw error when opened', () => {
expect(() => renderComponent()).not.toThrow();
});
it('should show and submit email/password form (happy path)', async () => {
settingsStore.isCloudDeployment = false; settingsStore.isCloudDeployment = false;
usersStore.loginWithCreds.mockResolvedValueOnce(); usersStore.loginWithCreds.mockResolvedValueOnce();
@@ -83,6 +71,27 @@ describe('SigninView', () => {
await userEvent.type(passwordInput, 'password'); await userEvent.type(passwordInput, 'password');
await userEvent.click(submitButton); await userEvent.click(submitButton);
};
beforeEach(() => {
createTestingPinia();
usersStore = mockedStore(useUsersStore);
settingsStore = mockedStore(useSettingsStore);
router = useRouter();
telemetry = useTelemetry();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should not throw error when opened', () => {
expect(() => renderComponent()).not.toThrow();
});
it('should show and submit email/password form (happy path)', async () => {
await signInWithValidUser();
expect(usersStore.loginWithCreds).toHaveBeenCalledWith({ expect(usersStore.loginWithCreds).toHaveBeenCalledWith({
emailOrLdapLoginId: 'test@n8n.io', emailOrLdapLoginId: 'test@n8n.io',
@@ -97,4 +106,89 @@ describe('SigninView', () => {
expect(router.push).toHaveBeenCalledWith('/home/workflows'); expect(router.push).toHaveBeenCalledWith('/home/workflows');
}); });
describe('when redirect query parameter is set', () => {
const ORIGIN_URL = 'https://n8n.local';
let route: ReturnType<typeof useRoute>;
beforeEach(() => {
route = useRoute();
global.window = Object.create(window);
Object.defineProperty(window, 'location', {
value: {
href: '',
origin: ORIGIN_URL,
},
writable: true,
});
});
it('should redirect to homepage with router if redirect url does not contain the origin domain', async () => {
vi.spyOn(route, 'query', 'get').mockReturnValue({
redirect: 'https://n8n.local.evil.com',
});
const hrefSpy = vi.spyOn(window.location, 'href', 'set');
await signInWithValidUser();
expect(hrefSpy).not.toHaveBeenCalled();
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should redirect to homepage with router if redirect url does not contain a valid URL', async () => {
vi.spyOn(route, 'query', 'get').mockReturnValue({
redirect: 'not-a-valid-url',
});
const hrefSpy = vi.spyOn(window.location, 'href', 'set');
await signInWithValidUser();
expect(hrefSpy).not.toHaveBeenCalled();
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should redirect to given route if redirect url contains the origin domain', async () => {
const validRedirectUrl = 'https://n8n.local/valid-redirect';
vi.spyOn(route, 'query', 'get').mockReturnValue({
redirect: validRedirectUrl,
});
const hrefSpy = vi.spyOn(window.location, 'href', 'set');
await signInWithValidUser();
expect(hrefSpy).toHaveBeenCalledWith(validRedirectUrl);
expect(router.push).not.toHaveBeenCalled();
});
it('should redirect with router to given route if redirect url is a local path', async () => {
const validLocalRedirectUrl = '/valid-redirect';
vi.spyOn(route, 'query', 'get').mockReturnValue({
redirect: validLocalRedirectUrl,
});
const hrefSpy = vi.spyOn(window.location, 'href', 'set');
await signInWithValidUser();
expect(hrefSpy).not.toHaveBeenCalled();
expect(router.push).toHaveBeenCalledWith(validLocalRedirectUrl);
});
it('should redirect to homepage with router if redirect url is empty', async () => {
vi.spyOn(route, 'query', 'get').mockReturnValue({
redirect: '',
});
const hrefSpy = vi.spyOn(window.location, 'href', 'set');
await signInWithValidUser();
expect(hrefSpy).not.toHaveBeenCalled();
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
});
}); });

View File

@@ -101,7 +101,19 @@ const onEmailPasswordSubmitted = async (form: EmailOrLdapLoginIdAndPassword) =>
const isRedirectSafe = () => { const isRedirectSafe = () => {
const redirect = getRedirectQueryParameter(); const redirect = getRedirectQueryParameter();
return redirect.startsWith('/') || redirect.startsWith(window.location.origin);
// Allow local redirects
if (redirect.startsWith('/')) {
return true;
}
try {
// Only allow origin domain redirects
const url = new URL(redirect);
return url.origin === window.location.origin;
} catch {
return false;
}
}; };
const getRedirectQueryParameter = () => { const getRedirectQueryParameter = () => {