11import { createLogger } from '@sim/logger'
2- import { toError } from '@sim/utils/errors'
3- import { validateAgiloftInstanceUrl } from '@/lib/core/security/input-validation'
4- import {
5- type SecureFetchResponse ,
6- secureFetchWithPinnedIP ,
7- validateUrlWithDNS ,
8- } from '@/lib/core/security/input-validation.server'
2+ import { validateExternalUrl } from '@/lib/core/security/input-validation'
93import type {
104 AgiloftAttachmentInfoParams ,
115 AgiloftBaseParams ,
@@ -27,69 +21,40 @@ interface AgiloftRequestConfig {
2721 url : string
2822 method : HttpMethod
2923 headers ?: Record < string , string >
30- body ?: string | Buffer | Uint8Array
31- }
32-
33- /**
34- * Result of a successful Agiloft authentication. The `resolvedIP` is the
35- * DNS-resolved address of the instance and MUST be reused (via
36- * `secureFetchWithPinnedIP`) for every follow-up request to defeat DNS
37- * rebinding between the validation step and the actual call.
38- */
39- export interface AgiloftSession {
40- token : string
41- resolvedIP : string
42- }
43-
44- /**
45- * Validates the user-supplied Agiloft instance URL with DNS resolution so
46- * SSRF protections cannot be bypassed by hostnames that resolve to internal
47- * addresses (or that flip after a sync-only check).
48- */
49- async function validateInstanceUrl ( instanceUrl : string ) : Promise < string > {
50- const surfaceCheck = validateAgiloftInstanceUrl ( instanceUrl )
51- if ( ! surfaceCheck . isValid ) {
52- throw new Error ( `Invalid Agiloft instance URL: ${ surfaceCheck . error } ` )
53- }
54-
55- const dnsCheck = await validateUrlWithDNS ( instanceUrl , 'instanceUrl' )
56- if ( ! dnsCheck . isValid || ! dnsCheck . resolvedIP ) {
57- throw new Error ( `Invalid Agiloft instance URL: ${ dnsCheck . error ?? 'unresolved hostname' } ` )
58- }
59-
60- return dnsCheck . resolvedIP
24+ body ?: BodyInit
6125}
6226
6327/**
6428 * Exchanges login/password for a short-lived Bearer token via EWLogin.
65- *
66- * Returns the token alongside the DNS-resolved IP of the instance so callers
67- * can pin every subsequent request to the same address.
6829 */
69- async function agiloftLogin ( params : AgiloftBaseParams ) : Promise < AgiloftSession > {
70- const resolvedIP = await validateInstanceUrl ( params . instanceUrl )
30+ async function agiloftLogin ( params : AgiloftBaseParams ) : Promise < string > {
7131 const base = params . instanceUrl . replace ( / \/ $ / , '' )
7232
33+ const urlValidation = validateExternalUrl ( params . instanceUrl , 'instanceUrl' )
34+ if ( ! urlValidation . isValid ) {
35+ throw new Error ( `Invalid Agiloft instance URL: ${ urlValidation . error } ` )
36+ }
37+
7338 const kb = encodeURIComponent ( params . knowledgeBase )
7439 const login = encodeURIComponent ( params . login )
7540 const password = encodeURIComponent ( params . password )
7641
7742 const url = `${ base } /ewws/EWLogin?$KB=${ kb } &$login=${ login } &$password=${ password } `
78- const response = await secureFetchWithPinnedIP ( url , resolvedIP , { method : 'POST' } )
43+ const response = await fetch ( url , { method : 'POST' } )
7944
8045 if ( ! response . ok ) {
8146 const errorText = await response . text ( )
8247 throw new Error ( `Agiloft login failed: ${ response . status } - ${ errorText } ` )
8348 }
8449
85- const data = ( await response . json ( ) ) as { access_token ?: string }
50+ const data = await response . json ( )
8651 const token = data . access_token
8752
8853 if ( ! token ) {
8954 throw new Error ( 'Agiloft login did not return an access token' )
9055 }
9156
92- return { token, resolvedIP }
57+ return token
9358}
9459
9560/**
@@ -98,41 +63,41 @@ async function agiloftLogin(params: AgiloftBaseParams): Promise<AgiloftSession>
9863async function agiloftLogout (
9964 instanceUrl : string ,
10065 knowledgeBase : string ,
101- token : string ,
102- resolvedIP : string
66+ token : string
10367) : Promise < void > {
10468 try {
10569 const base = instanceUrl . replace ( / \/ $ / , '' )
10670 const kb = encodeURIComponent ( knowledgeBase )
107- await secureFetchWithPinnedIP ( `${ base } /ewws/EWLogout?$KB=${ kb } ` , resolvedIP , {
71+ await fetch ( `${ base } /ewws/EWLogout?$KB=${ kb } ` , {
10872 method : 'POST' ,
10973 headers : { Authorization : `Bearer ${ token } ` } ,
11074 } )
11175 } catch ( error ) {
112- logger . warn ( 'Agiloft logout failed (best-effort)' , { error : toError ( error ) . message } )
76+ logger . warn ( 'Agiloft logout failed (best-effort)' , { error } )
11377 }
11478}
11579
11680/**
11781 * Shared wrapper that handles the full auth lifecycle:
118- * 1. Validate the instance URL (with DNS resolution) and login to get a Bearer token
119- * 2. Execute the request against the pinned IP with the token
120- * 3. Logout to clean up the session (also pinned)
82+ * 1. Login to get Bearer token
83+ * 2. Execute the request with the token
84+ * 3. Logout to clean up the session
12185 *
122- * Every fetch is performed via `secureFetchWithPinnedIP`, so DNS rebinding
123- * between validation and the subsequent calls is not possible.
86+ * The `buildRequest` callback receives the token and base URL, and returns
87+ * the request config. The `transformResponse` callback converts the raw
88+ * Response into the tool's output format.
12489 */
12590export async function executeAgiloftRequest < R extends ToolResponse > (
12691 params : AgiloftBaseParams ,
12792 buildRequest : ( base : string ) => AgiloftRequestConfig ,
128- transformResponse : ( response : SecureFetchResponse ) => Promise < R >
93+ transformResponse : ( response : Response ) => Promise < R >
12994) : Promise < R > {
130- const { token, resolvedIP } = await agiloftLogin ( params )
95+ const token = await agiloftLogin ( params )
13196 const base = params . instanceUrl . replace ( / \/ $ / , '' )
13297
13398 try {
13499 const req = buildRequest ( base )
135- const response = await secureFetchWithPinnedIP ( req . url , resolvedIP , {
100+ const response = await fetch ( req . url , {
136101 method : req . method ,
137102 headers : {
138103 ...req . headers ,
@@ -142,14 +107,12 @@ export async function executeAgiloftRequest<R extends ToolResponse>(
142107 } )
143108 return await transformResponse ( response )
144109 } finally {
145- await agiloftLogout ( params . instanceUrl , params . knowledgeBase , token , resolvedIP )
110+ await agiloftLogout ( params . instanceUrl , params . knowledgeBase , token )
146111 }
147112}
148113
149114/**
150- * Login helper exported for use in the attach/retrieve API routes. The route
151- * is responsible for using the returned `resolvedIP` with
152- * `secureFetchWithPinnedIP` (or `agiloftLogout`) on every follow-up request.
115+ * Login helper exported for use in the attach file API route.
153116 */
154117export { agiloftLogin , agiloftLogout }
155118
0 commit comments