mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(auth): added same-origin validation to forget password route, added confirmation for disable auth FF (#2447)
* fix(auth): added same-origin validation to forget password route, added confirmation for disable auth FF * ack PR comments
This commit is contained in:
@@ -6,6 +6,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest, setupAuthApiMocks } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getBaseUrl: vi.fn(() => 'https://app.example.com'),
|
||||
}))
|
||||
|
||||
describe('Forget Password API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
@@ -15,7 +19,7 @@ describe('Forget Password API Route', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should send password reset email successfully', async () => {
|
||||
it('should send password reset email successfully with same-origin redirectTo', async () => {
|
||||
setupAuthApiMocks({
|
||||
operations: {
|
||||
forgetPassword: { success: true },
|
||||
@@ -24,7 +28,7 @@ describe('Forget Password API Route', () => {
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
email: 'test@example.com',
|
||||
redirectTo: 'https://example.com/reset',
|
||||
redirectTo: 'https://app.example.com/reset',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/auth/forget-password/route')
|
||||
@@ -39,12 +43,36 @@ describe('Forget Password API Route', () => {
|
||||
expect(auth.auth.api.forgetPassword).toHaveBeenCalledWith({
|
||||
body: {
|
||||
email: 'test@example.com',
|
||||
redirectTo: 'https://example.com/reset',
|
||||
redirectTo: 'https://app.example.com/reset',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject external redirectTo URL', async () => {
|
||||
setupAuthApiMocks({
|
||||
operations: {
|
||||
forgetPassword: { success: true },
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
email: 'test@example.com',
|
||||
redirectTo: 'https://evil.com/phishing',
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/auth/forget-password/route')
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.message).toBe('Redirect URL must be a valid same-origin URL')
|
||||
|
||||
const auth = await import('@/lib/auth')
|
||||
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should send password reset email without redirectTo', async () => {
|
||||
setupAuthApiMocks({
|
||||
operations: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { isSameOrigin } from '@/lib/core/utils/validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -13,10 +14,15 @@ const forgetPasswordSchema = z.object({
|
||||
.email('Please provide a valid email address'),
|
||||
redirectTo: z
|
||||
.string()
|
||||
.url('Redirect URL must be a valid URL')
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.transform((val) => (val === '' ? undefined : val)),
|
||||
.transform((val) => (val === '' || val === undefined ? undefined : val))
|
||||
.refine(
|
||||
(val) => val === undefined || (z.string().url().safeParse(val).success && isSameOrigin(val)),
|
||||
{
|
||||
message: 'Redirect URL must be a valid same-origin URL',
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
@@ -37,8 +37,28 @@ export const isEmailVerificationEnabled = isTruthy(env.EMAIL_VERIFICATION_ENABLE
|
||||
|
||||
/**
|
||||
* Is authentication disabled (for self-hosted deployments behind private networks)
|
||||
* This flag is blocked when isHosted is true.
|
||||
*/
|
||||
export const isAuthDisabled = isTruthy(env.DISABLE_AUTH)
|
||||
export const isAuthDisabled = isTruthy(env.DISABLE_AUTH) && !isHosted
|
||||
|
||||
if (isTruthy(env.DISABLE_AUTH)) {
|
||||
import('@/lib/logs/console/logger')
|
||||
.then(({ createLogger }) => {
|
||||
const logger = createLogger('FeatureFlags')
|
||||
if (isHosted) {
|
||||
logger.error(
|
||||
'DISABLE_AUTH is set but ignored on hosted environment. Authentication remains enabled for security.'
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
'DISABLE_AUTH is enabled. Authentication is bypassed and all requests use an anonymous session. Only use this in trusted private networks.'
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback during config compilation when logger is unavailable
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Is user registration disabled
|
||||
|
||||
@@ -31,20 +31,25 @@ vi.mock('crypto', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
OPENAI_API_KEY_1: 'test-openai-key-1',
|
||||
OPENAI_API_KEY_2: 'test-openai-key-2',
|
||||
OPENAI_API_KEY_3: 'test-openai-key-3',
|
||||
ANTHROPIC_API_KEY_1: 'test-anthropic-key-1',
|
||||
ANTHROPIC_API_KEY_2: 'test-anthropic-key-2',
|
||||
ANTHROPIC_API_KEY_3: 'test-anthropic-key-3',
|
||||
GEMINI_API_KEY_1: 'test-gemini-key-1',
|
||||
GEMINI_API_KEY_2: 'test-gemini-key-2',
|
||||
GEMINI_API_KEY_3: 'test-gemini-key-3',
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/core/config/env', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/core/config/env')>()
|
||||
return {
|
||||
...actual,
|
||||
env: {
|
||||
...actual.env,
|
||||
ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', // fake key for testing
|
||||
OPENAI_API_KEY_1: 'test-openai-key-1', // fake key for testing
|
||||
OPENAI_API_KEY_2: 'test-openai-key-2', // fake key for testing
|
||||
OPENAI_API_KEY_3: 'test-openai-key-3', // fake key for testing
|
||||
ANTHROPIC_API_KEY_1: 'test-anthropic-key-1', // fake key for testing
|
||||
ANTHROPIC_API_KEY_2: 'test-anthropic-key-2', // fake key for testing
|
||||
ANTHROPIC_API_KEY_3: 'test-anthropic-key-3', // fake key for testing
|
||||
GEMINI_API_KEY_1: 'test-gemini-key-1', // fake key for testing
|
||||
GEMINI_API_KEY_2: 'test-gemini-key-2', // fake key for testing
|
||||
GEMINI_API_KEY_3: 'test-gemini-key-3', // fake key for testing
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
import { getBaseUrl } from './urls'
|
||||
|
||||
/**
|
||||
* Checks if a URL is same-origin with the application's base URL.
|
||||
* Used to prevent open redirect vulnerabilities.
|
||||
*
|
||||
* @param url - The URL to validate
|
||||
* @returns True if the URL is same-origin, false otherwise (secure default)
|
||||
*/
|
||||
export function isSameOrigin(url: string): boolean {
|
||||
try {
|
||||
const targetUrl = new URL(url)
|
||||
const appUrl = new URL(getBaseUrl())
|
||||
return targetUrl.origin === appUrl.origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a name by removing any characters that could cause issues
|
||||
* with variable references or node naming.
|
||||
|
||||
Reference in New Issue
Block a user