Skip to content

Commit fa5ab28

Browse files
committed
fix(obsidian): use isomorphic SSRF validation to unblock client build
The Obsidian connector is reachable from client bundles via `connectors/registry.ts` (the knowledge UI reads metadata like `.icon`/`.name`). Importing `validateUrlWithDNS` / `secureFetchWithPinnedIP` from `input-validation.server` pulled `dns/promises`, `http`, `https`, `net` into client chunks, breaking the Turbopack build: Module not found: Can't resolve 'dns/promises' ./apps/sim/lib/core/security/input-validation.server.ts [Client Component Browser] ./apps/sim/connectors/obsidian/obsidian.ts [Client Component Browser] ./apps/sim/connectors/registry.ts [Client Component Browser] Once that file polluted a browser context, Turbopack also failed to resolve the Node builtins in its legitimate server-route imports, cascading the error across App Routes and Server Components. Fix: switch the Obsidian connector to the isomorphic `validateExternalUrl` + `fetchWithRetry` helpers, matching the pattern used by every other connector in the registry. This keeps the core SSRF protections: - hosted Sim: blocks localhost, private IPs, HTTP (HTTPS enforced) - self-hosted Sim: allows localhost + HTTP, still blocks non-loopback private IPs and dangerous ports (22, 25, 3306, 5432, 6379, 27017, 9200) Drops the DNS-rebinding defense specifically (the IP-pinned fetch chain). The trade-off is acceptable because the vault URL is entered by the workspace admin — not arbitrary untrusted input — and hosted deployments already force the plugin to be exposed through a public URL (tunnel/port-forward), making rebinding a narrow threat. Also reverts the `secureFetchWithPinnedIPAndRetry` wrapper in `lib/knowledge/documents/utils.ts` (no longer needed, and its `.server` import was the original source of the client-bundle pollution).
1 parent d738429 commit fa5ab28

2 files changed

Lines changed: 29 additions & 105 deletions

File tree

apps/sim/connectors/obsidian/obsidian.ts

Lines changed: 29 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { ObsidianIcon } from '@/components/icons'
4-
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
5-
import {
6-
secureFetchWithPinnedIPAndRetry,
7-
VALIDATE_RETRY_OPTIONS,
8-
} from '@/lib/knowledge/documents/utils'
4+
import { validateExternalUrl } from '@/lib/core/security/input-validation'
5+
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
96
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
107
import { joinTagArray, parseTagDate } from '@/connectors/utils'
118

@@ -27,31 +24,28 @@ interface NoteJson {
2724
}
2825

2926
/**
30-
* Normalizes the vault URL and resolves its hostname to a concrete IP that
31-
* will be pinned for the lifetime of this request sequence.
27+
* Normalizes the vault URL and validates it against SSRF protections.
3228
*
33-
* The Obsidian Local REST API plugin runs on the user's own machine there
34-
* is no Obsidian SaaS domain we can allowlist. For hosted Sim deployments the
35-
* user must expose the plugin through a public URL (tunnel, port-forward).
36-
* Because the hostname is fully user-controlled, we resolve DNS once through
37-
* validateUrlWithDNS (which blocks private IPs/localhost in hosted mode,
38-
* allows localhost in self-hosted mode, and rejects dangerous ports) and
39-
* then reuse that IP on every outgoing fetch via secureFetchWithPinnedIP —
40-
* this prevents DNS rebinding attacks where a malicious nameserver would
41-
* otherwise swap in a private IP between validation and the actual request.
29+
* The Obsidian Local REST API plugin runs on the user's own machine, so there
30+
* is no SaaS domain to allowlist — the vault URL is fully user-controlled. We
31+
* defer to the shared `validateExternalUrl` policy:
32+
* - hosted Sim: blocks localhost, private IPs, HTTP (forces HTTPS)
33+
* - self-hosted Sim: allows localhost + HTTP, still blocks non-loopback
34+
* private IPs and dangerous ports (22, 25, 3306, 5432, 6379, 27017, 9200)
35+
*
36+
* This does not defend against DNS rebinding; for hosted deployments the user
37+
* must expose the plugin through a public URL (tunnel, port-forward).
4238
*/
43-
async function resolveVaultEndpoint(
44-
rawUrl: string | undefined
45-
): Promise<{ baseUrl: string; resolvedIP: string }> {
39+
function resolveVaultEndpoint(rawUrl: string | undefined): string {
4640
let url = (rawUrl || DEFAULT_VAULT_URL).trim().replace(/\/+$/, '')
4741
if (url && !url.startsWith('https://') && !url.startsWith('http://')) {
4842
url = `https://${url}`
4943
}
50-
const validation = await validateUrlWithDNS(url, 'vaultUrl', { allowHttp: true })
51-
if (!validation.isValid || !validation.resolvedIP) {
44+
const validation = validateExternalUrl(url, 'vaultUrl', { allowHttp: true })
45+
if (!validation.isValid) {
5246
throw new Error(validation.error || 'Invalid vault URL')
5347
}
54-
return { baseUrl: url, resolvedIP: validation.resolvedIP }
48+
return url
5549
}
5650

5751
/**
@@ -60,24 +54,21 @@ async function resolveVaultEndpoint(
6054
*/
6155
async function listDirectory(
6256
baseUrl: string,
63-
resolvedIP: string,
6457
accessToken: string,
6558
dirPath: string,
66-
retryOptions?: Parameters<typeof secureFetchWithPinnedIPAndRetry>[3]
59+
retryOptions?: Parameters<typeof fetchWithRetry>[2]
6760
): Promise<string[]> {
6861
const encodedDir = dirPath ? dirPath.split('/').map(encodeURIComponent).join('/') : ''
6962
const endpoint = encodedDir ? `${baseUrl}/vault/${encodedDir}/` : `${baseUrl}/vault/`
7063

71-
const response = await secureFetchWithPinnedIPAndRetry(
64+
const response = await fetchWithRetry(
7265
endpoint,
73-
resolvedIP,
7466
{
7567
method: 'GET',
7668
headers: {
7769
Authorization: `Bearer ${accessToken}`,
7870
Accept: 'application/json',
7971
},
80-
allowHttp: true,
8172
},
8273
retryOptions
8374
)
@@ -90,17 +81,13 @@ async function listDirectory(
9081
return data.files ?? []
9182
}
9283

93-
/**
94-
* Recursively lists all markdown files in the vault or a specific folder.
95-
*/
9684
const MAX_RECURSION_DEPTH = 20
9785

9886
async function listVaultFiles(
9987
baseUrl: string,
100-
resolvedIP: string,
10188
accessToken: string,
10289
folderPath?: string,
103-
retryOptions?: Parameters<typeof secureFetchWithPinnedIPAndRetry>[3],
90+
retryOptions?: Parameters<typeof fetchWithRetry>[2],
10491
depth = 0
10592
): Promise<string[]> {
10693
if (depth > MAX_RECURSION_DEPTH) {
@@ -109,7 +96,7 @@ async function listVaultFiles(
10996
}
11097

11198
const rootPath = folderPath || ''
112-
const entries = await listDirectory(baseUrl, resolvedIP, accessToken, rootPath, retryOptions)
99+
const entries = await listDirectory(baseUrl, accessToken, rootPath, retryOptions)
113100

114101
const mdFiles: string[] = []
115102
const subDirs: string[] = []
@@ -126,14 +113,7 @@ async function listVaultFiles(
126113

127114
for (const dir of subDirs) {
128115
try {
129-
const nested = await listVaultFiles(
130-
baseUrl,
131-
resolvedIP,
132-
accessToken,
133-
dir,
134-
retryOptions,
135-
depth + 1
136-
)
116+
const nested = await listVaultFiles(baseUrl, accessToken, dir, retryOptions, depth + 1)
137117
mdFiles.push(...nested)
138118
} catch (error) {
139119
logger.warn('Failed to list subdirectory', {
@@ -151,21 +131,18 @@ async function listVaultFiles(
151131
*/
152132
async function fetchNote(
153133
baseUrl: string,
154-
resolvedIP: string,
155134
accessToken: string,
156135
filePath: string,
157-
retryOptions?: Parameters<typeof secureFetchWithPinnedIPAndRetry>[3]
136+
retryOptions?: Parameters<typeof fetchWithRetry>[2]
158137
): Promise<NoteJson> {
159-
const response = await secureFetchWithPinnedIPAndRetry(
138+
const response = await fetchWithRetry(
160139
`${baseUrl}/vault/${filePath.split('/').map(encodeURIComponent).join('/')}`,
161-
resolvedIP,
162140
{
163141
method: 'GET',
164142
headers: {
165143
Authorization: `Bearer ${accessToken}`,
166144
Accept: 'application/vnd.olrapi.note+json',
167145
},
168-
allowHttp: true,
169146
},
170147
retryOptions
171148
)
@@ -223,13 +200,13 @@ export const obsidianConnector: ConnectorConfig = {
223200
cursor?: string,
224201
syncContext?: Record<string, unknown>
225202
): Promise<ExternalDocumentList> => {
226-
const { baseUrl, resolvedIP } = await resolveVaultEndpoint(sourceConfig.vaultUrl as string)
203+
const baseUrl = resolveVaultEndpoint(sourceConfig.vaultUrl as string)
227204
const folderPath = (sourceConfig.folderPath as string) || ''
228205

229206
let allFiles = syncContext?.allFiles as string[] | undefined
230207
if (!allFiles) {
231208
logger.info('Listing all vault files', { baseUrl, folderPath })
232-
allFiles = await listVaultFiles(baseUrl, resolvedIP, accessToken, folderPath || undefined)
209+
allFiles = await listVaultFiles(baseUrl, accessToken, folderPath || undefined)
233210
if (syncContext) {
234211
syncContext.allFiles = allFiles
235212
}
@@ -268,10 +245,10 @@ export const obsidianConnector: ConnectorConfig = {
268245
externalId: string,
269246
_syncContext?: Record<string, unknown>
270247
): Promise<ExternalDocument | null> => {
271-
const { baseUrl, resolvedIP } = await resolveVaultEndpoint(sourceConfig.vaultUrl as string)
248+
const baseUrl = resolveVaultEndpoint(sourceConfig.vaultUrl as string)
272249

273250
try {
274-
const note = await fetchNote(baseUrl, resolvedIP, accessToken, externalId)
251+
const note = await fetchNote(baseUrl, accessToken, externalId)
275252
const content = note.content || ''
276253

277254
return {
@@ -312,23 +289,18 @@ export const obsidianConnector: ConnectorConfig = {
312289
}
313290

314291
let baseUrl: string
315-
let resolvedIP: string
316292
try {
317-
const endpoint = await resolveVaultEndpoint(rawUrl)
318-
baseUrl = endpoint.baseUrl
319-
resolvedIP = endpoint.resolvedIP
293+
baseUrl = resolveVaultEndpoint(rawUrl)
320294
} catch (error) {
321295
return { valid: false, error: toError(error).message }
322296
}
323297

324298
try {
325-
const response = await secureFetchWithPinnedIPAndRetry(
299+
const response = await fetchWithRetry(
326300
`${baseUrl}/`,
327-
resolvedIP,
328301
{
329302
method: 'GET',
330303
headers: { Authorization: `Bearer ${accessToken}` },
331-
allowHttp: true,
332304
},
333305
VALIDATE_RETRY_OPTIONS
334306
)
@@ -348,7 +320,6 @@ export const obsidianConnector: ConnectorConfig = {
348320
if (folderPath.trim()) {
349321
const entries = await listDirectory(
350322
baseUrl,
351-
resolvedIP,
352323
accessToken,
353324
folderPath.trim(),
354325
VALIDATE_RETRY_OPTIONS

apps/sim/lib/knowledge/documents/utils.ts

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { sleep } from '@sim/utils/helpers'
4-
import {
5-
type SecureFetchOptions,
6-
type SecureFetchResponse,
7-
secureFetchWithPinnedIP,
8-
} from '@/lib/core/security/input-validation.server'
94

105
const logger = createLogger('RetryUtils')
116

@@ -213,45 +208,3 @@ export async function fetchWithRetry(
213208
return response
214209
}, retryOptions)
215210
}
216-
217-
/**
218-
* Wrapper for secure fetch requests with retry logic and DNS pinning.
219-
* Use when connecting to user-controlled hostnames that cannot be restricted
220-
* to a domain allowlist (e.g. self-hosted plugin endpoints). The DNS lookup
221-
* happens once during validation; this helper reuses that resolved IP so a
222-
* malicious nameserver cannot rebind to a private IP between validation and
223-
* the actual request.
224-
*/
225-
export async function secureFetchWithPinnedIPAndRetry(
226-
url: string,
227-
resolvedIP: string,
228-
options: SecureFetchOptions & { allowHttp?: boolean } = {},
229-
retryOptions: RetryOptions = {}
230-
): Promise<SecureFetchResponse> {
231-
return retryWithExponentialBackoff(async () => {
232-
const response = await secureFetchWithPinnedIP(url, resolvedIP, options)
233-
234-
if (!response.ok && isRetryableError({ status: response.status })) {
235-
const errorText = await response.text()
236-
const error: HTTPError = new Error(
237-
`HTTP ${response.status}: ${response.statusText} - ${errorText}`
238-
)
239-
error.status = response.status
240-
error.statusText = response.statusText
241-
242-
const retryAfter = response.headers.get('Retry-After')
243-
if (retryAfter) {
244-
const waitMs = Number.isNaN(Number(retryAfter))
245-
? Math.max(0, new Date(retryAfter).getTime() - Date.now())
246-
: Number(retryAfter) * 1000
247-
if (waitMs > 0) {
248-
error.retryAfterMs = waitMs
249-
}
250-
}
251-
252-
throw error
253-
}
254-
255-
return response
256-
}, retryOptions)
257-
}

0 commit comments

Comments
 (0)