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
20 changes: 12 additions & 8 deletions apps/docs/content/docs/en/knowledgebase/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,24 @@ Connectors continuously sync documents from external services into your knowledg

<Image src="/static/connectors/connectors-sources.png" alt="Connect Source picker showing a searchable list of available connectors including Airtable, Asana, Confluence, Discord, Dropbox, Evernote, Fireflies, GitHub, and Gmail" width={800} height={500} />

Sim ships with 49 built-in connectors:
Sim ships with 61 built-in connectors:

| Category | Connectors |
|----------|-----------|
| **Productivity** | Notion, Confluence, Asana, Linear, Jira, Jira Service Management, Monday, Google Calendar, Google Sheets, Google Forms, Typeform |
| **Cloud Storage** | Google Drive, Dropbox, OneDrive, SharePoint, Amazon S3 |
| **Documents** | Google Docs, WordPress, Webflow, DocuSign |
| **Productivity** | Notion, Confluence, Asana, Linear, Jira, Jira Service Management, Monday, Trello, ClickUp, Google Calendar, Google Sheets, Google Forms, Microsoft Excel, Typeform |
| **Cloud Storage** | Google Drive, Dropbox, OneDrive, SharePoint, Box, Amazon S3, SFTP |
| **Documents** | Google Docs, Google Slides, Mintlify, WordPress, Webflow, DocuSign |
| **Development** | GitHub, GitLab, Azure DevOps, Sentry |
| **Communication** | Slack, Discord, Microsoft Teams, Reddit, YouTube |
| **Communication** | Slack, Discord, Microsoft Teams, Reddit, X, YouTube |
| **Email** | Gmail, Outlook |
| **CRM** | HubSpot, Salesforce |
| **Support** | Intercom, ServiceNow, Zendesk |
| **Incident Management** | incident.io, Rootly |
| **Support** | Intercom, ServiceNow, Zendesk, Zoho Desk |
| **Incident Management** | incident.io, Rootly, PagerDuty |
| **Data** | Airtable |
| **Note-taking** | Evernote, Obsidian |
| **Meetings** | Zoom, Gong, Grain, Granola, Fathom, Fireflies |
| **Meetings** | Zoom, Google Meet, Gong, Grain, Granola, Fathom, Fireflies |
| **Recruiting** | Greenhouse, Ashby |
| **Compliance** | Google Vault |

## Adding a Connector

Expand All @@ -55,6 +56,9 @@ Other connectors use **API keys** or **personal access tokens** instead. The set
| **YouTube** | YouTube Data API key from the Google Cloud Console |
| **Amazon S3** | Secret Access Key (the Access Key ID, region, and bucket are entered as config fields) |
| **Sentry** | Auth token with `project:read` and `event:read` scopes |
| **PagerDuty** | REST API key from Integrations → API Access Keys |
| **SFTP** | Password or unencrypted private key (host, port, username, and root path are entered as config fields) |
| **Mintlify** | API key — optional for public documentation sites, which sync from `llms.txt` |

<Callout type="info">
If you rotate an API key in the external service, update it in Sim as well — OAuth tokens refresh automatically, but API keys do not.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
'x-ms-file-name': validatedData.fileName,
},
body: fileBuffer,
/**
* The tool's own `stripAuthOnRedirect` only covers the hop to this
* route. Dataverse redirects file operations to signed storage hosts,
* so this outbound call has to drop the bearer token itself or the
* redirect target receives a reusable OAuth credential.
*/
stripAuthOnRedirect: true,
},
'environmentUrl'
)
Expand Down
105 changes: 104 additions & 1 deletion apps/sim/app/api/tools/sftp/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { createHash } from 'node:crypto'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { toError } from '@sim/utils/errors'
import { type Attributes, Client, type ConnectConfig, type SFTPWrapper } from 'ssh2'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
import { readNodeStreamToBufferWithLimit } from '@/lib/core/utils/stream-limits'

const logger = createLogger('SftpUtils')

const S_IFMT = 0o170000
const S_IFDIR = 0o040000
const S_IFREG = 0o100000
Expand All @@ -15,9 +20,44 @@ export interface SftpConnectionConfig {
password?: string | null
privateKey?: string | null
passphrase?: string | null
/**
* Idle socket timeout in ms, forwarded to ssh2's `sock.setTimeout`. Left
* unset the socket has no idle timeout at all (ssh2 defaults it to `0`).
*/
timeout?: number
keepaliveInterval?: number
readyTimeout?: number
/**
* Expected SHA-256 host key fingerprint in the format `ssh-keyscan` and
* OpenSSH print (`SHA256:<base64>`). The `SHA256:` prefix and any base64
* padding are optional. When set, a server presenting a different host key is
* rejected before authentication runs. When omitted, the host is not
* verified — ssh2's default behavior.
*/
hostFingerprint?: string | null
}

/**
* Normalizes a user-supplied SHA-256 fingerprint for comparison: trims, drops
* an optional `SHA256:` prefix, and strips base64 `=` padding, which OpenSSH
* omits but copy/paste sources sometimes include.
*/
function normalizeSha256Fingerprint(value: string): string {
return value
.trim()
.replace(/^sha256:/i, '')
.replace(/=+$/, '')
.trim()
}

/**
* Computes the OpenSSH SHA-256 fingerprint of a host key. ssh2 hands the
* verifier the raw SSH wire-format public key blob — the same bytes OpenSSH
* base64-encodes into `known_hosts` — so hashing it directly reproduces the
* unpadded base64 digest that `ssh-keyscan | ssh-keygen -lf -` prints.
*/
function computeHostKeyFingerprint(hostKey: Buffer): string {
return createHash('sha256').update(hostKey).digest('base64').replace(/=+$/, '')
}

/**
Expand Down Expand Up @@ -93,6 +133,11 @@ function formatSftpError(err: Error, config: { host: string; port: number }): Er
/**
* Creates an SSH connection for SFTP using the provided configuration.
* Uses ssh2 library defaults which align with OpenSSH standards.
*
* When `hostFingerprint` is supplied the server's host key is pinned to it and
* a mismatch aborts the handshake before any credential is sent. Without it
* ssh2 accepts whatever host key answers, which is the pre-existing behavior
* kept for backward compatibility.
*/
export async function createSftpConnection(config: SftpConnectionConfig): Promise<Client> {
const host = config.host
Expand Down Expand Up @@ -132,6 +177,50 @@ export async function createSftpConnection(config: SftpConnectionConfig): Promis
if (config.keepaliveInterval !== undefined) {
connectConfig.keepaliveInterval = config.keepaliveInterval
}
if (config.timeout !== undefined) {
connectConfig.timeout = config.timeout
}

const suppliedFingerprint = config.hostFingerprint?.trim()
const expectedFingerprint = suppliedFingerprint
? normalizeSha256Fingerprint(suppliedFingerprint)
: undefined

/**
* Fail closed rather than silently skipping verification. A value that is
* non-blank but normalizes away (`SHA256:`, `=`) would otherwise leave no
* `hostVerifier` installed, trusting whatever host answers — the opposite
* of what supplying a fingerprint asks for.
*/
if (suppliedFingerprint && !expectedFingerprint) {
throw new Error(
'Host key fingerprint is not a valid SHA-256 fingerprint. Expected the base64 form printed by `ssh-keyscan <host> | ssh-keygen -lf -`.'
)
}

/**
* Set when the pinned fingerprint does not match. ssh2 reports the
* rejection through a generic `'error'` event, so the precise cause is
* carried out of the verifier rather than re-derived from that message.
*/
let hostKeyRejection: Error | undefined

if (expectedFingerprint) {
connectConfig.hostVerifier = (hostKey: Buffer): boolean => {
const actualFingerprint = computeHostKeyFingerprint(hostKey)
if (safeCompare(actualFingerprint, expectedFingerprint)) {
return true
}
hostKeyRejection = new Error(
`Host key verification failed for ${host}:${port}. ` +
`Expected SHA256:${expectedFingerprint} but the server presented SHA256:${actualFingerprint}. ` +
`Either the server's host key changed, or the connection was intercepted. ` +
`Re-run "ssh-keyscan -t rsa,ecdsa,ed25519 ${host}" to confirm the current key before updating the fingerprint.`
)
logger.warn('SFTP host key fingerprint mismatch', { host, port })
return false
}
}

if (hasPrivateKey) {
connectConfig.privateKey = config.privateKey!
Expand All @@ -147,7 +236,21 @@ export async function createSftpConnection(config: SftpConnectionConfig): Promis
})

client.on('error', (err) => {
reject(formatSftpError(err, { host, port }))
reject(hostKeyRejection ?? formatSftpError(err, { host, port }))
})

/**
* ssh2 only re-emits the socket's `'timeout'` event; it never destroys the
* socket, so without this the connection would sit open forever after the
* idle timeout elapsed.
*/
client.on('timeout', () => {
client.destroy()
reject(
new Error(
`Connection to ${host}:${port} timed out after ${config.timeout}ms of inactivity.`
)
)
})

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export function AddConnectorModal({

const connectorConfig = selectedType ? CONNECTOR_META_REGISTRY[selectedType] : null
const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey'
/** True when the connector declares its key optional (public sources need none). */
const isApiKeyOptional =
connectorConfig?.auth.mode === 'apiKey' && connectorConfig.auth.optional === true
const connectorProviderId = useMemo(
() =>
connectorConfig && connectorConfig.auth.mode === 'oauth'
Expand Down Expand Up @@ -160,7 +163,7 @@ export function AddConnectorModal({
const canSubmit = useMemo(() => {
if (!connectorConfig) return false
if (isApiKeyMode) {
if (!apiKeyValue.trim()) return false
if (!isApiKeyOptional && !apiKeyValue.trim()) return false
} else {
if (!effectiveCredentialId) return false
}
Expand All @@ -174,6 +177,7 @@ export function AddConnectorModal({
}, [
connectorConfig,
isApiKeyMode,
isApiKeyOptional,
apiKeyValue,
effectiveCredentialId,
isFieldVisible,
Expand Down Expand Up @@ -207,7 +211,11 @@ export function AddConnectorModal({
{
knowledgeBaseId,
connectorType: selectedType,
...(isApiKeyMode ? { apiKey: apiKeyValue } : { credentialId: effectiveCredentialId! }),
...(isApiKeyMode
? apiKeyValue.trim()
? { apiKey: apiKeyValue }
: {}
: { credentialId: effectiveCredentialId! }),
sourceConfig: finalSourceConfig,
syncIntervalMinutes: syncInterval,
},
Expand Down
Loading
Loading