11import { createLogger } from '@sim/logger'
22import { toError } from '@sim/utils/errors'
33import { 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'
96import type { ConnectorConfig , ExternalDocument , ExternalDocumentList } from '@/connectors/types'
107import { 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 */
6155async 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- */
9684const MAX_RECURSION_DEPTH = 20
9785
9886async 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 */
152132async 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
0 commit comments