Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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://learn.microsoft.com/en-us/sharepoint/required-urls-and-ports
* @see https://learn.microsoft.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