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
2 changes: 1 addition & 1 deletion apps/sim/app/api/environment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { generateId } from '@/lib/core/utils/uuid'
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
import type { EnvironmentVariable } from '@/stores/settings/environment'
import type { EnvironmentVariable } from '@/lib/environment/api'

const logger = createLogger('EnvironmentAPI')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
type PendingCredentialCreateRequest,
readPendingCredentialCreateRequest,
} from '@/lib/credentials/client-state'
import type { WorkspaceEnvironmentData } from '@/lib/environment/api'
import { getUserColor } from '@/lib/workspaces/colors'
import { isValidEnvVarName } from '@/executor/constants'
import {
Expand All @@ -48,9 +49,9 @@ import {
useSavePersonalEnvironment,
useUpsertWorkspaceEnvironment,
useWorkspaceEnvironment,
type WorkspaceEnvironmentData,
} from '@/hooks/queries/environment'
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'

const logger = createLogger('SecretsManager')

Expand Down Expand Up @@ -482,6 +483,15 @@ export function CredentialsManager() {
hasChangesRef.current = hasChanges
shouldBlockNavRef.current = hasChanges || isDetailsDirty

const setNavGuardDirty = useSettingsDirtyStore((s) => s.setDirty)
const resetNavGuard = useSettingsDirtyStore((s) => s.reset)

useEffect(() => {
setNavGuardDirty(hasChanges || isDetailsDirty)
}, [hasChanges, isDetailsDirty, setNavGuardDirty])

useEffect(() => () => resetNavGuard(), [resetNavGuard])

// --- Effects ---
useEffect(() => {
if (hasSavedRef.current) return
Expand Down Expand Up @@ -981,6 +991,7 @@ export function CredentialsManager() {

const handleDiscardAndNavigate = useCallback(() => {
shouldBlockNavRef.current = false
resetNavGuard()
resetToSaved()
setSelectedCredentialId(null)

Expand All @@ -989,7 +1000,7 @@ export function CredentialsManager() {
pendingNavigationUrlRef.current = null
router.push(url)
}
}, [router, resetToSaved])
}, [router, resetToSaved, resetNavGuard])

const renderEnvVarRow = useCallback(
(envVar: UIEnvironmentVariable, originalIndex: number) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
} from '@/hooks/queries/oauth/oauth-connections'
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'

const logger = createLogger('IntegrationsManager')

Expand Down Expand Up @@ -247,6 +248,15 @@ export function IntegrationsManager() {

const isDetailsDirty = isDescriptionDirty || isDisplayNameDirty

const setNavGuardDirty = useSettingsDirtyStore((s) => s.setDirty)
const resetNavGuard = useSettingsDirtyStore((s) => s.reset)

useEffect(() => {
setNavGuardDirty(isDetailsDirty)
}, [isDetailsDirty, setNavGuardDirty])

useEffect(() => () => resetNavGuard(), [resetNavGuard])

const handleSaveDetails = async () => {
if (!selectedCredential || !isSelectedAdmin || !isDetailsDirty || updateCredential.isPending)
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,8 @@ import {
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import {
usePersonalEnvironment,
useWorkspaceEnvironment,
type WorkspaceEnvironmentData,
} from '@/hooks/queries/environment'
import type { WorkspaceEnvironmentData } from '@/lib/environment/api'
import { usePersonalEnvironment, useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
'use client'

import { useCallback, useMemo } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { ChevronDown, Skeleton } from '@/components/emcn'
import {
Button,
ChevronDown,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Skeleton,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { isHosted } from '@/lib/core/config/feature-flags'
Expand All @@ -23,6 +32,7 @@ import { useOrganizations } from '@/hooks/queries/organization'
import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/subscription'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'

const SKELETON_SECTIONS = [3, 2, 2] as const

Expand All @@ -41,6 +51,13 @@ export function SettingsSidebar({
const router = useRouter()

const queryClient = useQueryClient()

const requestNavigation = useSettingsDirtyStore((s) => s.requestNavigation)
const confirmNavigation = useSettingsDirtyStore((s) => s.confirmNavigation)
const cancelNavigation = useSettingsDirtyStore((s) => s.cancelNavigation)
const isDirty = useSettingsDirtyStore((s) => s.isDirty)
const [showDiscardDialog, setShowDiscardDialog] = useState(false)

const { data: session, isPending: sessionLoading } = useSession()
const { data: organizationsData, isLoading: orgsLoading } = useOrganizations()
const { data: generalSettings } = useGeneralSettings()
Expand Down Expand Up @@ -180,8 +197,27 @@ export function SettingsSidebar({
const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation()

const handleBack = useCallback(() => {
if (isDirty) {
setShowDiscardDialog(true)
return
}
router.push(popSettingsReturnUrl(`/workspace/${workspaceId}/home`))
}, [router, popSettingsReturnUrl, workspaceId])
}, [router, popSettingsReturnUrl, workspaceId, isDirty])

const handleConfirmDiscard = useCallback(() => {
const section = confirmNavigation()
setShowDiscardDialog(false)
if (section) {
router.replace(getSettingsHref({ section }), { scroll: false })
} else {
router.push(popSettingsReturnUrl(`/workspace/${workspaceId}/home`))
}
}, [confirmNavigation, router, getSettingsHref, popSettingsReturnUrl, workspaceId])

const handleCancelDiscard = useCallback(() => {
cancelNavigation()
setShowDiscardDialog(false)
}, [cancelNavigation])

return (
<>
Expand Down Expand Up @@ -286,11 +322,15 @@ export function SettingsSidebar({
className={itemClassName}
onMouseEnter={() => handlePrefetch(item.id)}
onFocus={() => handlePrefetch(item.id)}
onClick={() =>
router.replace(getSettingsHref({ section: item.id as SettingsSection }), {
scroll: false,
})
}
onClick={() => {
const section = item.id as SettingsSection
if (section === activeSection) return
if (!requestNavigation(section)) {
setShowDiscardDialog(true)
return
}
router.replace(getSettingsHref({ section }), { scroll: false })
}}
>
{content}
</button>
Expand All @@ -312,6 +352,25 @@ export function SettingsSidebar({
})
)}
</div>

<Modal open={showDiscardDialog} onOpenChange={(open) => !open && handleCancelDiscard()}>
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to discard them?
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleCancelDiscard}>
Keep Editing
</Button>
<Button variant='destructive' onClick={handleConfirmDiscard}>
Discard Changes
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
9 changes: 2 additions & 7 deletions apps/sim/hooks/queries/environment.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { WorkspaceEnvironmentData } from '@/lib/environment/api'
import type { EnvironmentVariable, WorkspaceEnvironmentData } from '@/lib/environment/api'
import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api'
import { workspaceCredentialKeys } from '@/hooks/queries/credentials'
import { API_ENDPOINTS } from '@/stores/constants'
import type { EnvironmentVariable } from '@/stores/settings/environment'

export type { WorkspaceEnvironmentData } from '@/lib/environment/api'
export type { EnvironmentVariable } from '@/stores/settings/environment'

const logger = createLogger('EnvironmentQueries')

Expand All @@ -27,8 +23,7 @@ export function usePersonalEnvironment() {
return useQuery({
queryKey: environmentKeys.personal(),
queryFn: ({ signal }) => fetchPersonalEnvironment(signal),
staleTime: 60 * 1000, // 1 minute
placeholderData: keepPreviousData,
staleTime: 60 * 1000,
})
}

Expand Down
59 changes: 41 additions & 18 deletions apps/sim/lib/core/security/input-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,18 +741,8 @@ export function validateExternalUrl(
}
}

// Block suspicious ports commonly used for internal services
const port = parsedUrl.port
const blockedPorts = [
'22', // SSH
'23', // Telnet
'25', // SMTP
'3306', // MySQL
'5432', // PostgreSQL
'6379', // Redis
'27017', // MongoDB
'9200', // Elasticsearch
]
const blockedPorts = ['22', '23', '25', '3306', '5432', '6379', '27017', '9200']

if (port && blockedPorts.includes(port)) {
return {
Expand Down Expand Up @@ -842,7 +832,6 @@ export function validateAirtableId(
}
}

// Airtable IDs: prefix (3 chars) + 14 alphanumeric characters = 17 chars total
const airtableIdPattern = new RegExp(`^${expectedPrefix}[a-zA-Z0-9]{14}$`)

if (!airtableIdPattern.test(value)) {
Expand Down Expand Up @@ -893,11 +882,6 @@ export function validateAwsRegion(
}
}

// AWS region patterns:
// - Standard: af|ap|ca|eu|me|sa|us|il followed by direction and number
// - GovCloud: us-gov-east-1, us-gov-west-1
// - China: cn-north-1, cn-northwest-1
// - ISO: us-iso-east-1, us-iso-west-1, us-isob-east-1
const awsRegionPattern =
/^(af|ap|ca|cn|eu|il|me|sa|us|us-gov|us-iso|us-isob)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/

Expand Down Expand Up @@ -1156,7 +1140,6 @@ export function validatePaginationCursor(
}
}

// Allow alphanumeric, base64 chars (+, /, =), and URL-safe chars (-, _, ., ~, %)
const cursorPattern = /^[A-Za-z0-9+/=\-_.~%]+$/
if (!cursorPattern.test(value)) {
logger.warn('Pagination cursor contains disallowed characters', {
Expand Down Expand Up @@ -1224,3 +1207,43 @@ export function validateOktaDomain(rawDomain: string): string {
}
return domain
}

const MICROSOFT_CONTENT_SUFFIXES = [
'sharepoint.com',
'sharepoint.us',
'sharepoint.de',
'sharepoint.cn',
'sharepointonline.com',
'onedrive.com',
'onedrive.live.com',
'1drv.ms',
'1drv.com',
'microsoftpersonalcontent.com',
] as const

/**
* Returns true if the given URL is hosted on a trusted Microsoft SharePoint or
* OneDrive domain. Validates the parsed hostname against an allowlist using exact
* match or subdomain suffix, preventing incomplete-substring bypasses.
*
* Covers SharePoint Online (commercial, GCC/GCC High/DoD, Germany, China),
* OneDrive business and consumer, OneDrive short-link and CDN domains,
* and Microsoft personal content CDN.
*
* @see https://un5hru1qgj43w9rdtvyj8.julianrbryant.com/en-us/sharepoint/required-urls-and-ports
* @see https://un5hru1qgj43w9rdtvyj8.julianrbryant.com/en-us/microsoft-365/enterprise/microsoft-365-u-s-government-gcc-high-endpoints
*
* @param url - The URL to check
* @returns Whether the URL belongs to a trusted Microsoft content host
*/
export function isMicrosoftContentUrl(url: string): boolean {
let hostname: string
try {
hostname = new URL(url).hostname.toLowerCase()
} catch {
return false
}
return MICROSOFT_CONTENT_SUFFIXES.some(
(suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`)
)
}
6 changes: 5 additions & 1 deletion apps/sim/lib/environment/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { API_ENDPOINTS } from '@/stores/constants'
import type { EnvironmentVariable } from '@/stores/settings/environment'

export interface EnvironmentVariable {
key: string
value: string
}

export interface WorkspaceEnvironmentData {
workspace: Record<string, string>
Expand Down
Loading
Loading