chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user