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
34 changes: 31 additions & 3 deletions apps/sim/app/api/auth/forget-password/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 },
Expand All @@ -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')
Expand All @@ -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: {
Expand Down
10 changes: 8 additions & 2 deletions apps/sim/app/api/auth/forget-password/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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) {
Expand Down
22 changes: 21 additions & 1 deletion apps/sim/lib/core/config/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 19 additions & 14 deletions apps/sim/lib/core/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 19 additions & 0 deletions apps/sim/lib/core/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading