From 989a401f281d6640681090ba6d61b0bd04729be8 Mon Sep 17 00:00:00 2001 From: jakeaturner Date: Wed, 20 May 2026 05:25:36 +0000 Subject: [PATCH] fix(AI): improve remote Ollama url validation to prevent SSRF vulnerability --- admin/app/controllers/ollama_controller.ts | 8 +++--- admin/app/validators/common.ts | 30 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/admin/app/controllers/ollama_controller.ts b/admin/app/controllers/ollama_controller.ts index fe50783..3bf9e73 100644 --- a/admin/app/controllers/ollama_controller.ts +++ b/admin/app/controllers/ollama_controller.ts @@ -6,6 +6,7 @@ import Service from '#models/service' import KVStore from '#models/kv_store' import { modelNameSchema } from '#validators/download' import { chatSchema, getAvailableModelsSchema, unloadChatModelsSchema } from '#validators/ollama' +import { assertNotCloudMetadataUrl } from '#validators/common' import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' import { RAG_CONTEXT_LIMITS, SYSTEM_PROMPTS } from '../../constants/ollama.js' @@ -242,11 +243,12 @@ export default class OllamaController { } } - // Validate URL format - if (!remoteUrl.startsWith('http')) { + try { + assertNotCloudMetadataUrl(remoteUrl) + } catch (err) { return response.status(400).send({ success: false, - message: 'Invalid URL. Must start with http:// or https://', + message: err instanceof Error ? err.message : 'Invalid URL.', }) } diff --git a/admin/app/validators/common.ts b/admin/app/validators/common.ts index ba9f107..5c386ff 100644 --- a/admin/app/validators/common.ts +++ b/admin/app/validators/common.ts @@ -31,6 +31,36 @@ export function assertNotPrivateUrl(urlString: string): void { } } +/** + * Narrower SSRF guard for "remote service" URLs the user points NOMAD at + * (e.g. an OpenAI-compatible endpoint like LM Studio, llama.cpp, vLLM, or a + * sibling Ollama container). Unlike `assertNotPrivateUrl`, this intentionally + * ALLOWS loopback, link-local-ish, and RFC1918 hosts because the legitimate + * target is frequently on the same host or LAN (host.docker.internal, + * the docker bridge gateway, or a LAN IP). + * + * It blocks only: + * - the cloud instance-metadata IP (169.254.169.254), to avoid leaking + * IAM creds on a misconfigured cloud VM + * - non-HTTP schemes (file:, gopher:, etc.) + */ +export function assertNotCloudMetadataUrl(urlString: string): void { + const parsed = new URL(urlString) + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`URL must use http or https scheme: ${parsed.protocol}`) + } + + const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '') + + if ( + hostname === '169.254.169.254' || + hostname === 'fd00:ec2::254' // IPv6 EC2 IMDS + ) { + throw new Error(`URL must not point to the cloud instance metadata endpoint: ${hostname}`) + } +} + export const remoteDownloadValidator = vine.compile( vine.object({ url: vine