11import { createLogger } from '@sim/logger'
22import { toError } from '@sim/utils/errors'
33import { ObsidianIcon } from '@/components/icons'
4- import { fetchWithRetry , VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
4+ import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
5+ import {
6+ secureFetchWithPinnedIPAndRetry ,
7+ VALIDATE_RETRY_OPTIONS ,
8+ } from '@/lib/knowledge/documents/utils'
59import type { ConnectorConfig , ExternalDocument , ExternalDocumentList } from '@/connectors/types'
610import { joinTagArray , parseTagDate } from '@/connectors/utils'
711
812const logger = createLogger ( 'ObsidianConnector' )
913
1014const DOCS_PER_PAGE = 50
15+ const DEFAULT_VAULT_URL = 'https://127.0.0.1:27124'
1116
1217interface NoteJson {
1318 content : string
@@ -22,10 +27,31 @@ interface NoteJson {
2227}
2328
2429/**
25- * Normalizes the vault URL by removing trailing slashes.
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.
32+ *
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.
2642 */
27- function normalizeVaultUrl ( url : string ) : string {
28- return url . trim ( ) . replace ( / \/ + $ / , '' )
43+ async function resolveVaultEndpoint (
44+ rawUrl : string | undefined
45+ ) : Promise < { baseUrl : string ; resolvedIP : string } > {
46+ let url = ( rawUrl || DEFAULT_VAULT_URL ) . trim ( ) . replace ( / \/ + $ / , '' )
47+ if ( url && ! url . startsWith ( 'https://' ) && ! url . startsWith ( 'http://' ) ) {
48+ url = `https://${ url } `
49+ }
50+ const validation = await validateUrlWithDNS ( url , 'vaultUrl' , { allowHttp : true } )
51+ if ( ! validation . isValid || ! validation . resolvedIP ) {
52+ throw new Error ( validation . error || 'Invalid vault URL' )
53+ }
54+ return { baseUrl : url , resolvedIP : validation . resolvedIP }
2955}
3056
3157/**
@@ -34,21 +60,24 @@ function normalizeVaultUrl(url: string): string {
3460 */
3561async function listDirectory (
3662 baseUrl : string ,
63+ resolvedIP : string ,
3764 accessToken : string ,
3865 dirPath : string ,
39- retryOptions ?: Parameters < typeof fetchWithRetry > [ 2 ]
66+ retryOptions ?: Parameters < typeof secureFetchWithPinnedIPAndRetry > [ 3 ]
4067) : Promise < string [ ] > {
4168 const encodedDir = dirPath ? dirPath . split ( '/' ) . map ( encodeURIComponent ) . join ( '/' ) : ''
4269 const endpoint = encodedDir ? `${ baseUrl } /vault/${ encodedDir } /` : `${ baseUrl } /vault/`
4370
44- const response = await fetchWithRetry (
71+ const response = await secureFetchWithPinnedIPAndRetry (
4572 endpoint ,
73+ resolvedIP ,
4674 {
4775 method : 'GET' ,
4876 headers : {
4977 Authorization : `Bearer ${ accessToken } ` ,
5078 Accept : 'application/json' ,
5179 } ,
80+ allowHttp : true ,
5281 } ,
5382 retryOptions
5483 )
@@ -68,9 +97,10 @@ const MAX_RECURSION_DEPTH = 20
6897
6998async function listVaultFiles (
7099 baseUrl : string ,
100+ resolvedIP : string ,
71101 accessToken : string ,
72102 folderPath ?: string ,
73- retryOptions ?: Parameters < typeof fetchWithRetry > [ 2 ] ,
103+ retryOptions ?: Parameters < typeof secureFetchWithPinnedIPAndRetry > [ 3 ] ,
74104 depth = 0
75105) : Promise < string [ ] > {
76106 if ( depth > MAX_RECURSION_DEPTH ) {
@@ -79,7 +109,7 @@ async function listVaultFiles(
79109 }
80110
81111 const rootPath = folderPath || ''
82- const entries = await listDirectory ( baseUrl , accessToken , rootPath , retryOptions )
112+ const entries = await listDirectory ( baseUrl , resolvedIP , accessToken , rootPath , retryOptions )
83113
84114 const mdFiles : string [ ] = [ ]
85115 const subDirs : string [ ] = [ ]
@@ -96,7 +126,14 @@ async function listVaultFiles(
96126
97127 for ( const dir of subDirs ) {
98128 try {
99- const nested = await listVaultFiles ( baseUrl , accessToken , dir , retryOptions , depth + 1 )
129+ const nested = await listVaultFiles (
130+ baseUrl ,
131+ resolvedIP ,
132+ accessToken ,
133+ dir ,
134+ retryOptions ,
135+ depth + 1
136+ )
100137 mdFiles . push ( ...nested )
101138 } catch ( error ) {
102139 logger . warn ( 'Failed to list subdirectory' , {
@@ -114,18 +151,21 @@ async function listVaultFiles(
114151 */
115152async function fetchNote (
116153 baseUrl : string ,
154+ resolvedIP : string ,
117155 accessToken : string ,
118156 filePath : string ,
119- retryOptions ?: Parameters < typeof fetchWithRetry > [ 2 ]
157+ retryOptions ?: Parameters < typeof secureFetchWithPinnedIPAndRetry > [ 3 ]
120158) : Promise < NoteJson > {
121- const response = await fetchWithRetry (
159+ const response = await secureFetchWithPinnedIPAndRetry (
122160 `${ baseUrl } /vault/${ filePath . split ( '/' ) . map ( encodeURIComponent ) . join ( '/' ) } ` ,
161+ resolvedIP ,
123162 {
124163 method : 'GET' ,
125164 headers : {
126165 Authorization : `Bearer ${ accessToken } ` ,
127166 Accept : 'application/vnd.olrapi.note+json' ,
128167 } ,
168+ allowHttp : true ,
129169 } ,
130170 retryOptions
131171 )
@@ -183,15 +223,13 @@ export const obsidianConnector: ConnectorConfig = {
183223 cursor ?: string ,
184224 syncContext ?: Record < string , unknown >
185225 ) : Promise < ExternalDocumentList > => {
186- const baseUrl = normalizeVaultUrl (
187- ( sourceConfig . vaultUrl as string ) || 'https://127.0.0.1:27124'
188- )
226+ const { baseUrl, resolvedIP } = await resolveVaultEndpoint ( sourceConfig . vaultUrl as string )
189227 const folderPath = ( sourceConfig . folderPath as string ) || ''
190228
191229 let allFiles = syncContext ?. allFiles as string [ ] | undefined
192230 if ( ! allFiles ) {
193231 logger . info ( 'Listing all vault files' , { baseUrl, folderPath } )
194- allFiles = await listVaultFiles ( baseUrl , accessToken , folderPath || undefined )
232+ allFiles = await listVaultFiles ( baseUrl , resolvedIP , accessToken , folderPath || undefined )
195233 if ( syncContext ) {
196234 syncContext . allFiles = allFiles
197235 }
@@ -230,12 +268,10 @@ export const obsidianConnector: ConnectorConfig = {
230268 externalId : string ,
231269 _syncContext ?: Record < string , unknown >
232270 ) : Promise < ExternalDocument | null > => {
233- const baseUrl = normalizeVaultUrl (
234- ( sourceConfig . vaultUrl as string ) || 'https://127.0.0.1:27124'
235- )
271+ const { baseUrl, resolvedIP } = await resolveVaultEndpoint ( sourceConfig . vaultUrl as string )
236272
237273 try {
238- const note = await fetchNote ( baseUrl , accessToken , externalId )
274+ const note = await fetchNote ( baseUrl , resolvedIP , accessToken , externalId )
239275 const content = note . content || ''
240276
241277 return {
@@ -275,14 +311,24 @@ export const obsidianConnector: ConnectorConfig = {
275311 return { valid : false , error : 'Vault URL is required' }
276312 }
277313
278- const baseUrl = normalizeVaultUrl ( rawUrl )
314+ let baseUrl : string
315+ let resolvedIP : string
316+ try {
317+ const endpoint = await resolveVaultEndpoint ( rawUrl )
318+ baseUrl = endpoint . baseUrl
319+ resolvedIP = endpoint . resolvedIP
320+ } catch ( error ) {
321+ return { valid : false , error : toError ( error ) . message }
322+ }
279323
280324 try {
281- const response = await fetchWithRetry (
325+ const response = await secureFetchWithPinnedIPAndRetry (
282326 `${ baseUrl } /` ,
327+ resolvedIP ,
283328 {
284329 method : 'GET' ,
285330 headers : { Authorization : `Bearer ${ accessToken } ` } ,
331+ allowHttp : true ,
286332 } ,
287333 VALIDATE_RETRY_OPTIONS
288334 )
@@ -302,6 +348,7 @@ export const obsidianConnector: ConnectorConfig = {
302348 if ( folderPath . trim ( ) ) {
303349 const entries = await listDirectory (
304350 baseUrl ,
351+ resolvedIP ,
305352 accessToken ,
306353 folderPath . trim ( ) ,
307354 VALIDATE_RETRY_OPTIONS
@@ -313,8 +360,10 @@ export const obsidianConnector: ConnectorConfig = {
313360
314361 return { valid : true }
315362 } catch ( error ) {
316- const message = error instanceof Error ? error . message : 'Failed to connect to Obsidian vault'
317- return { valid : false , error : message }
363+ return {
364+ valid : false ,
365+ error : toError ( error ) . message || 'Failed to connect to Obsidian vault' ,
366+ }
318367 }
319368 } ,
320369
0 commit comments