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
13 changes: 9 additions & 4 deletions apps/sim/app/(auth)/signup/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { usePostHog } from 'posthog-js/react'
import { Input, Label } from '@/components/emcn'
import { client, useSession } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { captureClientEvent, captureEvent } from '@/lib/posthog/client'
Expand Down Expand Up @@ -102,10 +103,14 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
useEffect(() => {
setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
}, [])
const redirectUrl = useMemo(
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
[searchParams]
)
const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || ''
const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false
const invalidCallbackRef = useRef(false)
if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) {
invalidCallbackRef.current = true
logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl })
}
const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : ''
const isInviteFlow = useMemo(
() =>
searchParams.get('invite_flow') === 'true' ||
Expand Down
12 changes: 10 additions & 2 deletions apps/sim/app/(auth)/verify/use-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter, useSearchParams } from 'next/navigation'
import { client, useSession } from '@/lib/auth/auth-client'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'

const logger = createLogger('useVerification')

Expand Down Expand Up @@ -55,8 +56,11 @@ export function useVerification({
}

const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl')
if (storedRedirectUrl) {
if (storedRedirectUrl && validateCallbackUrl(storedRedirectUrl)) {
setRedirectUrl(storedRedirectUrl)
} else if (storedRedirectUrl) {
logger.warn('Ignoring unsafe stored invite redirect URL', { url: storedRedirectUrl })
sessionStorage.removeItem('inviteRedirectUrl')
}

const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow')
Expand All @@ -67,7 +71,11 @@ export function useVerification({

const redirectParam = searchParams.get('redirectAfter')
if (redirectParam) {
setRedirectUrl(redirectParam)
if (validateCallbackUrl(redirectParam)) {
setRedirectUrl(redirectParam)
} else {
logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam })
}
}

const inviteFlowParam = searchParams.get('invite_flow')
Expand Down
51 changes: 36 additions & 15 deletions apps/sim/connectors/obsidian/obsidian.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { ObsidianIcon } from '@/components/icons'
import { validateExternalUrl } from '@/lib/core/security/input-validation'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { joinTagArray, parseTagDate } from '@/connectors/utils'

const logger = createLogger('ObsidianConnector')

const DOCS_PER_PAGE = 50
const DEFAULT_VAULT_URL = 'https://127.0.0.1:27124'

interface NoteJson {
content: string
Expand All @@ -22,10 +24,29 @@ interface NoteJson {
}

/**
* Normalizes the vault URL by removing trailing slashes.
* Normalizes the vault URL and validates it against SSRF protections.
*
* The Obsidian Local REST API plugin runs on the user's own machine, so there
* is no SaaS domain to allowlist — the vault URL is fully user-controlled. We
* defer to the shared `validateExternalUrl` policy:
* - hosted Sim: blocks localhost, private IPs, HTTP (forces HTTPS)
* - self-hosted Sim: allows http://localhost (built-in carve-out), still
* blocks non-loopback private IPs and dangerous ports (22, 25, 3306,
* 5432, 6379, 27017, 9200)
*
* This does not defend against DNS rebinding; for hosted deployments the user
* must expose the plugin through a public URL (tunnel, port-forward).
*/
function normalizeVaultUrl(url: string): string {
return url.trim().replace(/\/+$/, '')
function resolveVaultEndpoint(rawUrl: string | undefined): string {
let url = (rawUrl || DEFAULT_VAULT_URL).trim().replace(/\/+$/, '')
if (url && !url.startsWith('https://') && !url.startsWith('http://')) {
url = `https://${url}`
}
const validation = validateExternalUrl(url, 'vaultUrl')
if (!validation.isValid) {
throw new Error(validation.error || 'Invalid vault URL')
}
return url
Comment thread
waleedlatif1 marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -61,9 +82,6 @@ async function listDirectory(
return data.files ?? []
}

/**
* Recursively lists all markdown files in the vault or a specific folder.
*/
const MAX_RECURSION_DEPTH = 20

async function listVaultFiles(
Expand Down Expand Up @@ -183,9 +201,7 @@ export const obsidianConnector: ConnectorConfig = {
cursor?: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const baseUrl = normalizeVaultUrl(
(sourceConfig.vaultUrl as string) || 'https://127.0.0.1:27124'
)
const baseUrl = resolveVaultEndpoint(sourceConfig.vaultUrl as string)
const folderPath = (sourceConfig.folderPath as string) || ''

let allFiles = syncContext?.allFiles as string[] | undefined
Expand Down Expand Up @@ -230,9 +246,7 @@ export const obsidianConnector: ConnectorConfig = {
externalId: string,
_syncContext?: Record<string, unknown>
): Promise<ExternalDocument | null> => {
const baseUrl = normalizeVaultUrl(
(sourceConfig.vaultUrl as string) || 'https://127.0.0.1:27124'
)
const baseUrl = resolveVaultEndpoint(sourceConfig.vaultUrl as string)

try {
const note = await fetchNote(baseUrl, accessToken, externalId)
Expand Down Expand Up @@ -275,7 +289,12 @@ export const obsidianConnector: ConnectorConfig = {
return { valid: false, error: 'Vault URL is required' }
}

const baseUrl = normalizeVaultUrl(rawUrl)
let baseUrl: string
try {
baseUrl = resolveVaultEndpoint(rawUrl)
} catch (error) {
return { valid: false, error: toError(error).message }
}

try {
const response = await fetchWithRetry(
Expand Down Expand Up @@ -313,8 +332,10 @@ export const obsidianConnector: ConnectorConfig = {

return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to connect to Obsidian vault'
return { valid: false, error: message }
return {
valid: false,
error: toError(error).message || 'Failed to connect to Obsidian vault',
}
}
},

Expand Down
37 changes: 26 additions & 11 deletions apps/sim/connectors/servicenow/servicenow.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { ServiceNowIcon } from '@/components/icons'
import { validateServiceNowInstanceUrl } from '@/lib/core/security/input-validation'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { htmlToPlainText, parseTagDate } from '@/connectors/utils'
Expand Down Expand Up @@ -45,15 +46,23 @@ interface Incident extends ServiceNowRecord {
}

/**
* Normalizes the instance URL to ensure it has the correct format.
* Normalizes and validates the ServiceNow instance URL.
*
* Prepends https:// if the scheme is missing, strips trailing slashes, then
* enforces a ServiceNow-owned domain allowlist to prevent SSRF — the instance
* URL is user-controlled and was previously fetched server-side with no
* validation.
*/
function normalizeInstanceUrl(instanceUrl: string): string {
let url = instanceUrl.trim()
url = url.replace(/\/+$/, '')
if (!url.startsWith('https://') && !url.startsWith('http://')) {
function resolveServiceNowInstanceUrl(rawUrl: string): string {
let url = (rawUrl ?? '').trim().replace(/\/+$/, '')
if (url && !url.startsWith('https://') && !url.startsWith('http://')) {
url = `https://${url}`
}
return url
const validation = validateServiceNowInstanceUrl(url)
if (!validation.isValid) {
throw new Error(validation.error || 'Invalid instance URL')
}
return validation.sanitized ?? url
}

/**
Expand Down Expand Up @@ -430,7 +439,7 @@ export const servicenowConnector: ConnectorConfig = {
cursor?: string,
_syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const instanceUrl = normalizeInstanceUrl(sourceConfig.instanceUrl as string)
const instanceUrl = resolveServiceNowInstanceUrl(sourceConfig.instanceUrl as string)
const contentType = (sourceConfig.contentType as string) || 'kb_knowledge'
const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : DEFAULT_MAX_ITEMS
const authHeader = buildAuthHeader(accessToken, sourceConfig)
Expand Down Expand Up @@ -504,7 +513,6 @@ export const servicenowConnector: ConnectorConfig = {
sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const instanceUrl = normalizeInstanceUrl(sourceConfig.instanceUrl as string)
const contentType = (sourceConfig.contentType as string) || 'kb_knowledge'
const authHeader = buildAuthHeader(accessToken, sourceConfig)
const isKB = contentType === 'kb_knowledge'
Expand All @@ -514,6 +522,8 @@ export const servicenowConnector: ConnectorConfig = {
? 'sys_id,short_description,text,wiki,workflow_state,kb_category,kb_knowledge_base,number,author,sys_created_by,sys_updated_by,sys_updated_on,sys_created_on'
: 'sys_id,number,short_description,description,state,priority,category,assigned_to,opened_by,close_notes,resolution_notes,sys_created_by,sys_updated_by,sys_updated_on,sys_created_on'

const instanceUrl = resolveServiceNowInstanceUrl(sourceConfig.instanceUrl as string)

try {
const { result } = await serviceNowApiGet(instanceUrl, tableName, authHeader, {
sysparm_query: `sys_id=${externalId}`,
Expand Down Expand Up @@ -568,7 +578,13 @@ export const servicenowConnector: ConnectorConfig = {
return { valid: false, error: 'Max items must be a positive number' }
}

const normalizedUrl = normalizeInstanceUrl(instanceUrl)
let normalizedUrl: string
try {
normalizedUrl = resolveServiceNowInstanceUrl(instanceUrl)
} catch (error) {
return { valid: false, error: toError(error).message }
}

const authHeader = buildAuthHeader(accessToken, sourceConfig)
const tableName = contentType === 'kb_knowledge' ? 'kb_knowledge' : 'incident'

Expand All @@ -585,8 +601,7 @@ export const servicenowConnector: ConnectorConfig = {
)
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to connect to ServiceNow'
return { valid: false, error: message }
return { valid: false, error: toError(error).message || 'Failed to connect to ServiceNow' }
}
},

Expand Down
Loading
Loading