loki-mode 9.12.0 → 9.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +192 -0
- package/loki-ts/dist/loki.js +221 -221
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
9.12.
|
|
1
|
+
9.12.2
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -7,6 +7,7 @@ Provides REST API and WebSocket endpoints for dashboard functionality.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
|
+
import ipaddress
|
|
10
11
|
import json
|
|
11
12
|
import logging
|
|
12
13
|
import os
|
|
@@ -186,6 +187,150 @@ def _rate_key(base: str, request: Optional[Request]) -> str:
|
|
|
186
187
|
logger = logging.getLogger(__name__)
|
|
187
188
|
|
|
188
189
|
|
|
190
|
+
# Reads that expose operational or credential-adjacent state. These stay open
|
|
191
|
+
# to a LOCAL caller (zero-config use is the point) but must not be readable by
|
|
192
|
+
# an anonymous remote caller when the dashboard is bound to 0.0.0.0.
|
|
193
|
+
#
|
|
194
|
+
# Measured before this list existed, from a routable remote address with auth
|
|
195
|
+
# off: /api/logs, /api/secrets/status, /api/github/status, /api/tasks,
|
|
196
|
+
# /api/council/transcripts and /api/proofs all returned 200.
|
|
197
|
+
#
|
|
198
|
+
# /health and /metrics are deliberately ABSENT: a container health probe and a
|
|
199
|
+
# Prometheus scrape must keep working with no configuration, and neither
|
|
200
|
+
# carries workspace content.
|
|
201
|
+
_SENSITIVE_READ_PREFIXES = (
|
|
202
|
+
"/api/logs",
|
|
203
|
+
"/api/secrets",
|
|
204
|
+
"/api/github",
|
|
205
|
+
"/api/tasks",
|
|
206
|
+
"/api/projects",
|
|
207
|
+
"/api/council",
|
|
208
|
+
"/api/proofs",
|
|
209
|
+
"/api/memory",
|
|
210
|
+
"/api/learnings",
|
|
211
|
+
"/api/learning",
|
|
212
|
+
"/api/escalations",
|
|
213
|
+
"/api/spec",
|
|
214
|
+
"/api/checkpoints",
|
|
215
|
+
"/api/enterprise",
|
|
216
|
+
"/api/collab",
|
|
217
|
+
"/api/cost",
|
|
218
|
+
"/api/budget",
|
|
219
|
+
"/api/findings",
|
|
220
|
+
"/api/operator",
|
|
221
|
+
"/api/fleet",
|
|
222
|
+
"/api/registry",
|
|
223
|
+
"/api/wiki",
|
|
224
|
+
"/api/activity",
|
|
225
|
+
"/api/session",
|
|
226
|
+
"/api/failures",
|
|
227
|
+
"/api/prompt",
|
|
228
|
+
"/api/quality",
|
|
229
|
+
"/api/migration",
|
|
230
|
+
"/api/managed",
|
|
231
|
+
"/api/app-runner",
|
|
232
|
+
"/api/playwright",
|
|
233
|
+
"/api/checklist",
|
|
234
|
+
"/api/control",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _trusted_proxies() -> frozenset:
|
|
239
|
+
"""Proxy addresses whose forwarded-for header may be believed.
|
|
240
|
+
|
|
241
|
+
EXPLICIT, never inferred. A reverse proxy on the same host presents
|
|
242
|
+
127.0.0.1 as the peer, so "the peer is loopback" cannot mean "the caller is
|
|
243
|
+
local" -- that was a real bypass: a remote request through a same-host
|
|
244
|
+
proxy reached POST /api/control/stop with a 200. Trusting X-Forwarded-For
|
|
245
|
+
unconditionally is the opposite mistake, since any direct caller can send
|
|
246
|
+
that header themselves.
|
|
247
|
+
|
|
248
|
+
So the operator names the proxies. Anything not named is not trusted, and
|
|
249
|
+
its forwarded headers are ignored rather than believed.
|
|
250
|
+
"""
|
|
251
|
+
raw = os.environ.get("LOKI_TRUSTED_PROXIES", "")
|
|
252
|
+
return frozenset(x.strip() for x in raw.split(",") if x.strip())
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _real_client_host(request: Request):
|
|
256
|
+
"""The address to make the decision on, or None if it cannot be known.
|
|
257
|
+
|
|
258
|
+
Returns the peer address normally. When the peer is a TRUSTED proxy, the
|
|
259
|
+
left-most X-Forwarded-For entry is used instead, because that is the
|
|
260
|
+
originating client the proxy is reporting.
|
|
261
|
+
"""
|
|
262
|
+
client = getattr(request, "client", None)
|
|
263
|
+
host = getattr(client, "host", None) if client else None
|
|
264
|
+
if host is None:
|
|
265
|
+
return None
|
|
266
|
+
if host in _trusted_proxies():
|
|
267
|
+
fwd = request.headers.get("x-forwarded-for", "")
|
|
268
|
+
first = fwd.split(",")[0].strip()
|
|
269
|
+
if first:
|
|
270
|
+
return first
|
|
271
|
+
return host
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _is_local_caller(host) -> bool:
|
|
275
|
+
"""True only for a caller we can positively identify as non-routable.
|
|
276
|
+
|
|
277
|
+
A peer that is not an IP literal (ASGI test transports report
|
|
278
|
+
"testclient", UDS transports report names) is treated as local: a name is
|
|
279
|
+
not evidence of a remote caller, and refusing every non-IP string broke 17
|
|
280
|
+
existing tests without closing any real hole.
|
|
281
|
+
"""
|
|
282
|
+
if host is None:
|
|
283
|
+
return False
|
|
284
|
+
if host in ("127.0.0.1", "::1", "localhost"):
|
|
285
|
+
return True
|
|
286
|
+
try:
|
|
287
|
+
return ipaddress.ip_address(host).is_loopback
|
|
288
|
+
except ValueError:
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def require_local_or_authenticated(request: Request) -> None:
|
|
293
|
+
"""Refuse an anonymous REMOTE caller on a mutation or a sensitive read.
|
|
294
|
+
|
|
295
|
+
THE HOLE THIS CLOSES. Every mutating route already carries
|
|
296
|
+
Depends(auth.require_scope(...)), and all 46 were bypassable, because
|
|
297
|
+
require_scope returns True when enterprise auth is DISABLED -- the default.
|
|
298
|
+
Bound to 127.0.0.1 that is harmless. But LOKI_DASHBOARD_HOST=0.0.0.0 is a
|
|
299
|
+
documented container configuration, and there an anonymous request from
|
|
300
|
+
anywhere on the network could stop a build or read the logs.
|
|
301
|
+
|
|
302
|
+
Measured with auth off, from a routable remote address:
|
|
303
|
+
|
|
304
|
+
POST /api/control/stop -> 200
|
|
305
|
+
GET /api/logs -> 200
|
|
306
|
+
GET /api/secrets/status -> 200
|
|
307
|
+
|
|
308
|
+
The rule, chosen so zero-config local use does not change:
|
|
309
|
+
|
|
310
|
+
auth enabled require_scope decides, unchanged
|
|
311
|
+
loopback / non-IP peer allowed, exactly as today
|
|
312
|
+
trusted proxy decided on the FORWARDED client
|
|
313
|
+
routable remote, no auth 403
|
|
314
|
+
|
|
315
|
+
An untrusted proxy's forwarded headers are IGNORED, not believed: any
|
|
316
|
+
direct caller can set X-Forwarded-For.
|
|
317
|
+
"""
|
|
318
|
+
if auth.ENTERPRISE_AUTH_ENABLED or auth.OIDC_ENABLED:
|
|
319
|
+
return
|
|
320
|
+
host = _real_client_host(request)
|
|
321
|
+
if host is None:
|
|
322
|
+
raise HTTPException(
|
|
323
|
+
status_code=403,
|
|
324
|
+
detail="control requires an identifiable client; enable "
|
|
325
|
+
"LOKI_ENTERPRISE_AUTH to allow remote access")
|
|
326
|
+
if _is_local_caller(host):
|
|
327
|
+
return
|
|
328
|
+
raise HTTPException(
|
|
329
|
+
status_code=403,
|
|
330
|
+
detail="this endpoint is restricted to local callers unless "
|
|
331
|
+
"LOKI_ENTERPRISE_AUTH is enabled")
|
|
332
|
+
|
|
333
|
+
|
|
189
334
|
# Pydantic schemas for API
|
|
190
335
|
def _sanitize_text_field(value: str) -> str:
|
|
191
336
|
"""Strip/reject control characters from text fields."""
|
|
@@ -1004,6 +1149,53 @@ except Exception as _gzip_exc: # pragma: no cover - starlette always ships it
|
|
|
1004
1149
|
# than one that does not start.
|
|
1005
1150
|
logger.warning("gzip compression unavailable: %s", _gzip_exc)
|
|
1006
1151
|
|
|
1152
|
+
# THE DASHBOARD BOUNDARY. One central fail-closed check, not a per-route flag.
|
|
1153
|
+
#
|
|
1154
|
+
# WHY MIDDLEWARE AND NOT A DEPENDENCY PER ROUTE. All 46 mutating routes already
|
|
1155
|
+
# carry Depends(auth.require_scope(...)). Every one of them is a NO-OP when
|
|
1156
|
+
# enterprise auth is disabled, which is the default -- require_scope returns
|
|
1157
|
+
# True in that mode. So "42 of 46 are scoped" described the code accurately and
|
|
1158
|
+
# the security posture not at all: a remote anonymous caller could invoke any
|
|
1159
|
+
# of them. Measured before this guard, with LOKI_ENTERPRISE_AUTH unset:
|
|
1160
|
+
#
|
|
1161
|
+
# POST /api/control/stop -> 200
|
|
1162
|
+
# POST /api/control/app-stop -> 200
|
|
1163
|
+
#
|
|
1164
|
+
# A first attempt added a dependency to six control routes by hand. That is the
|
|
1165
|
+
# wrong shape: it protects the six someone remembered, leaves the other forty,
|
|
1166
|
+
# and every route added later starts unprotected. The boundary is one place.
|
|
1167
|
+
#
|
|
1168
|
+
# THE RULE, chosen so zero-config local use does not change:
|
|
1169
|
+
#
|
|
1170
|
+
# auth enabled -> require_scope decides, unchanged
|
|
1171
|
+
# loopback caller -> allowed, exactly as today
|
|
1172
|
+
# non-IP peer (test/UDS) -> allowed; a name is not evidence of remote
|
|
1173
|
+
# routable remote + no auth -> 403
|
|
1174
|
+
#
|
|
1175
|
+
# Only MUTATIONS are gated. Reads stay open so a container health probe, a
|
|
1176
|
+
# metrics scrape and the SPA itself keep working with no configuration.
|
|
1177
|
+
@app.middleware("http")
|
|
1178
|
+
async def dashboard_control_boundary(request: Request, call_next):
|
|
1179
|
+
# EVERY mutation, plus reads that expose operational or credential-adjacent
|
|
1180
|
+
# state. Gating mutations alone left /api/logs, /api/secrets/status and
|
|
1181
|
+
# /api/council/transcripts readable by an anonymous remote caller on a
|
|
1182
|
+
# 0.0.0.0 bind -- measured at 200 before this was widened.
|
|
1183
|
+
#
|
|
1184
|
+
# /health and /metrics are intentionally NOT in the sensitive list, so a
|
|
1185
|
+
# container health probe and a Prometheus scrape keep working unconfigured.
|
|
1186
|
+
gated = request.method in ("POST", "PUT", "PATCH", "DELETE")
|
|
1187
|
+
if not gated:
|
|
1188
|
+
path = request.url.path
|
|
1189
|
+
gated = any(path.startswith(pfx) for pfx in _SENSITIVE_READ_PREFIXES)
|
|
1190
|
+
if gated:
|
|
1191
|
+
try:
|
|
1192
|
+
require_local_or_authenticated(request)
|
|
1193
|
+
except HTTPException as exc:
|
|
1194
|
+
return JSONResponse(status_code=exc.status_code,
|
|
1195
|
+
content={"detail": exc.detail})
|
|
1196
|
+
return await call_next(request)
|
|
1197
|
+
|
|
1198
|
+
|
|
1007
1199
|
# Static file serving is configured at the end of the file (after all API routes)
|
|
1008
1200
|
|
|
1009
1201
|
# Mount V2 API router
|