n8n Login Flow has Open Redirect Vulnerability
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.
| Package | Affected versions | Patched versions |
|---|---|---|
n8nnpm | < 1.98.0 | 1.98.0 |
Affected products
1Patches
14865d1e360a0fix(editor): Stop nefarious redirects during sign in (#16034)
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- github.com/advisories/GHSA-5vj6-wjr7-5v9fghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-49592ghsaADVISORY
- github.com/n8n-io/n8n/commit/4865d1e360a0fe7b045e295b5e1a29daad12314eghsax_refsource_MISCWEB
- github.com/n8n-io/n8n/pull/16034ghsax_refsource_MISCWEB
- github.com/n8n-io/n8n/releases/tag/n8n%401.98.0ghsax_refsource_MISCWEB
- github.com/n8n-io/n8n/security/advisories/GHSA-5vj6-wjr7-5v9fghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.