Files
simstudioai--sim/apps/sim/tools/agiloft/utils.server.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

133 lines
4.6 KiB
TypeScript

import { createLogger } from '@sim/logger'
import {
type SecureFetchResponse,
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import type { AgiloftBaseParams } from '@/tools/agiloft/types'
import type { HttpMethod, ToolResponse } from '@/tools/types'
const logger = createLogger('AgiloftAuthServer')
interface AgiloftRequestConfig {
url: string
method: HttpMethod
headers?: Record<string, string>
body?: string
}
/**
* Validates the Agiloft instance URL and resolves its DNS once, returning the
* resolved IP so subsequent requests can pin to it. This prevents DNS-rebinding
* (TOCTOU) SSRF where the hostname could resolve to a private IP on a later
* lookup. Server-only — uses node:dns/promises.
*/
export async function resolveAgiloftInstance(instanceUrl: string): Promise<string> {
const validation = await validateUrlWithDNS(instanceUrl, 'instanceUrl')
if (!validation.isValid || !validation.resolvedIP) {
throw new Error(validation.error || 'Invalid Agiloft instance URL')
}
return validation.resolvedIP
}
/**
* DNS-pinned variant of agiloftLogin. Requires a pre-resolved IP so the
* connection cannot be steered to a different host between validation and
* the actual TCP connection.
*/
export async function agiloftLoginPinned(
params: AgiloftBaseParams,
resolvedIP: string
): Promise<string> {
const base = params.instanceUrl.replace(/\/$/, '')
const kb = encodeURIComponent(params.knowledgeBase)
const login = encodeURIComponent(params.login)
const password = encodeURIComponent(params.password)
const url = `${base}/ewws/EWLogin?$KB=${kb}&$login=${login}&$password=${password}`
const response = await secureFetchWithPinnedIP(url, resolvedIP, { method: 'POST' })
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Agiloft login failed: ${response.status} - ${errorText}`)
}
const data = (await response.json()) as { access_token?: string }
const token = data.access_token
if (!token) {
throw new Error('Agiloft login did not return an access token')
}
return token
}
/**
* DNS-pinned variant of agiloftLogout. Best-effort — failures are logged but
* not thrown.
*/
export async function agiloftLogoutPinned(
instanceUrl: string,
knowledgeBase: string,
token: string,
resolvedIP: string
): Promise<void> {
try {
const base = instanceUrl.replace(/\/$/, '')
const kb = encodeURIComponent(knowledgeBase)
await secureFetchWithPinnedIP(`${base}/ewws/EWLogout?$KB=${kb}`, resolvedIP, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
} catch (error) {
logger.warn('Agiloft logout failed (best-effort)', { error })
}
}
/**
* Shared wrapper that handles the full Agiloft auth lifecycle behind the
* codebase's SSRF-safe fetch path. The instance URL is validated and resolved
* to a concrete IP once via `validateUrlWithDNS` (which rejects hostnames that
* resolve to private/reserved addresses), and every hop — login, the operation
* request, and logout — is issued through `secureFetchWithPinnedIP` so the
* connection is pinned to that validated IP. This defeats DNS-rebinding (TOCTOU)
* SSRF where a hostname could resolve to an internal address on a later lookup.
*
* 1. Validate + resolve the instance URL once.
* 2. Login to obtain a Bearer token.
* 3. Execute the operation request with the token.
* 4. Logout to clean up the session (best-effort).
*
* The `buildRequest` callback receives the base URL and returns the request
* config. The `transformResponse` callback converts the raw response into the
* tool's output format.
*
* Server-only — uses node:dns/promises and node:http(s) via the pinned fetch.
*/
export async function executeAgiloftRequest<R extends ToolResponse>(
params: AgiloftBaseParams,
buildRequest: (base: string) => AgiloftRequestConfig,
transformResponse: (response: SecureFetchResponse) => Promise<R>
): Promise<R> {
const resolvedIP = await resolveAgiloftInstance(params.instanceUrl)
const token = await agiloftLoginPinned(params, resolvedIP)
const base = params.instanceUrl.replace(/\/$/, '')
try {
const req = buildRequest(base)
const response = await secureFetchWithPinnedIP(req.url, resolvedIP, {
method: req.method,
headers: {
...req.headers,
Authorization: `Bearer ${token}`,
},
body: req.body,
})
return await transformResponse(response)
} finally {
await agiloftLogoutPinned(params.instanceUrl, params.knowledgeBase, token, resolvedIP)
}
}
export type { SecureFetchResponse }