VYPR
Moderate severityNVD Advisory· Published Jun 26, 2025· Updated Jun 26, 2025

n8n Login Flow has Open Redirect Vulnerability

CVE-2025-49592

Description

n8n is a workflow automation platform. Versions prior to 1.98.0 have an Open Redirect vulnerability in the login flow. Authenticated users can be redirected to untrusted, attacker-controlled domains after logging in, by crafting malicious URLs with a misleading redirect query parameter. This may lead to phishing attacks by impersonating the n8n UI on lookalike domains (e.g., n8n.local.evil.com), credential or 2FA theft if users are tricked into re-entering sensitive information, and/or reputation risk due to the visual similarity between attacker-controlled domains and trusted ones. The vulnerability affects anyone hosting n8n and exposing the /signin endpoint to users. The issue has been patched in version 1.98.0. All users should upgrade to this version or later. The fix introduces strict origin validation for redirect URLs, ensuring only same-origin or relative paths are allowed after login.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
n8nnpm
< 1.98.01.98.0

Affected products

1

Patches

1
4865d1e360a0

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

https://github.com/n8n-io/n8nMarc LittlemoreJun 5, 2025via ghsa
2 files changed · +123 17
  • packages/frontend/editor-ui/src/views/SigninView.test.ts+110 16 modified
    @@ -2,19 +2,20 @@ import { createComponentRenderer } from '@/__tests__/render';
     import { mockedStore } from '@/__tests__/utils';
     import { createTestingPinia } from '@pinia/testing';
     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 { useUsersStore } from '@/stores/users.store';
     import { useSettingsStore } from '@/stores/settings.store';
     import { useTelemetry } from '@/composables/useTelemetry';
    +import { VIEWS } from '@/constants';
     
     vi.mock('vue-router', () => {
     	const push = vi.fn();
     	return {
     		useRouter: () => ({
     			push,
     		}),
    -		useRoute: () => ({
    +		useRoute: vi.fn().mockReturnValue({
     			query: {
     				redirect: '/home/workflows',
     			},
    @@ -43,20 +44,7 @@ let router: ReturnType<typeof useRouter>;
     let telemetry: ReturnType<typeof useTelemetry>;
     
     describe('SigninView', () => {
    -	beforeEach(() => {
    -		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 () => {
    +	const signInWithValidUser = async () => {
     		settingsStore.isCloudDeployment = false;
     		usersStore.loginWithCreds.mockResolvedValueOnce();
     
    @@ -83,6 +71,27 @@ describe('SigninView', () => {
     		await userEvent.type(passwordInput, 'password');
     
     		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({
     			emailOrLdapLoginId: 'test@n8n.io',
    @@ -97,4 +106,89 @@ describe('SigninView', () => {
     
     		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 });
    +		});
    +	});
     });
    
  • packages/frontend/editor-ui/src/views/SigninView.vue+13 1 modified
    @@ -101,7 +101,19 @@ const onEmailPasswordSubmitted = async (form: EmailOrLdapLoginIdAndPassword) =>
     
     const isRedirectSafe = () => {
     	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 = () => {
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.