Artifact read endpoints can expose persisted content without the admin gate
Hi! I was reviewing the local proxy/dashboard surface and noticed a possible access-control gap around persisted artifacts.
Admin-mutating endpoints are protected by _admin_auth_failure():
166 def _admin_auth_failure(request: Request) -> JSONResponse | None:
167 """Return a 401/403 response when admin endpoints should be blocked.
168
169 Admin endpoints (connections, providers, spend write, routing-config write, selector write)
170 can mutate API keys and routing behavior. Two policies:
171
172 * ``UNCOMMON_ROUTE_ADMIN_TOKEN`` is set: require ``Authorization: Bearer <token>``.
173 * Not set: only accept requests from a local client.
174 """
175 token = os.environ.get(_ADMIN_ENV_VAR, "").strip()
176 if token:
177 hdr = request.headers.get("authorization", "")
178 if hdr.startswith("Bearer ") and hdr[7:].strip() == token:
179 return None
180 return JSONResponse({"error": f"admin token required (set {_ADMIN_ENV_VAR})"}, status_code=401)
181 client_host = getattr(request.client, "host", "") if request.client else ""
182 if client_host in _LOCAL_CLIENT_HOSTS:
183 return None
184 return JSONResponse(
185 {
186 "error": (
187 "admin endpoints require a local client; "
188 f"set {_ADMIN_ENV_VAR} to allow remote access"
189 )
190 },
191 status_code=403,
192 )
For example, connection/provider mutations call that guard:
3257 async def handle_connections(request: Request) -> JSONResponse:
3258 denied = _admin_auth_failure(request)
3259 if denied is not None:
3260 return denied
3261 if request.method == "GET":
3262 return JSONResponse(_current_connection_payload())
3286 async def handle_providers(request: Request) -> JSONResponse:
3287 denied = _admin_auth_failure(request)
3288 if denied is not None:
3289 return denied
3290 if request.method == "GET":
3291 return JSONResponse(_providers_payload())
But the artifact endpoints do not appear to apply the same check:
3662 async def handle_artifacts(request: Request) -> JSONResponse:
3663 limit = int(request.query_params.get("limit", "50"))
3664 return JSONResponse({
3665 "count": _artifacts.count(),
3666 "items": _artifacts.list(limit=max(1, min(limit, 200))),
3667 })
3668
3669 async def handle_artifact(request: Request) -> JSONResponse:
3670 artifact_id = request.path_params["artifact_id"]
3671 artifact = _artifacts.get(artifact_id)
3672 if artifact is None:
3673 return JSONResponse({"error": "Artifact not found"}, status_code=404)
3674 return JSONResponse(artifact)
The route table exposes those handlers directly:
6087 Route("/v1/scenes", handle_scenes, methods=["GET", "POST"]),
6088 Route("/v1/scenes/{name:str}", handle_scene_detail, methods=["GET"]),
6089 Route("/v1/artifacts", handle_artifacts, methods=["GET"]),
6090 Route("/v1/artifacts/{artifact_id:str}", handle_artifact, methods=["GET"]),
6091 Route("/v1/feedback", handle_feedback, methods=["GET", "POST"]),
6092 Route("/v1/stats/recent", handle_recent, methods=["GET"]),
6093 Route("/v1/events/stream", handle_events_stream, methods=["GET"]),
6094 Route("/v1/traces", handle_traces, methods=["GET"]),
6095 Route("/v1/traces/{request_id:str}", handle_trace_detail, methods=["GET"]),
Why I think this matters:
ArtifactStore.get() returns the full stored content, not only metadata:
155 def get(self, artifact_id: str) -> dict[str, Any] | None:
156 if not self._enabled:
157 return None
158 meta_path = self._meta_path(artifact_id)
159 content_path = self._content_path(artifact_id)
160 if not meta_path.exists() or not content_path.exists():
161 return None
162 meta = json.loads(meta_path.read_text())
163 meta["content"] = content_path.read_text()
164 return meta
Artifacts can contain offloaded conversation/tool content:
373 if (
374 artifact_store.enabled
375 and isinstance(content, str)
376 and len(content) >= policy.artifact_threshold_chars
377 ):
378 summary = _summarize_tool_output(content)
379 compacted = content
380 record = artifact_store.store_text(
381 compacted,
382 kind="tool-output",
383 role=str(msg.get("role", "")),
384 session_id=session_id,
385 tool_name=str(msg.get("name") or msg.get("tool_name") or ""),
386 tool_call_id=str(msg.get("tool_call_id") or ""),
387 summary=summary,
388 )
389 new_msg["content"] = _build_artifact_stub(compacted, record, policy)
The filesystem storage itself is private (0700 directory and 0600 files), which suggests this content is intended to be sensitive/local:
82 if self._enabled:
83 self._root.mkdir(parents=True, exist_ok=True, mode=0o700)
35 def _write_private_text(path: Path, text: str) -> None:
36 fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
37 try:
38 with os.fdopen(fd, "w", encoding="utf-8") as f:
39 f.write(text)
The default server bind is local-only:
6110 def serve(
6111 port: int = DEFAULT_PORT,
6112 host: str = "127.0.0.1",
6113 upstream: str | None = DEFAULT_UPSTREAM,
However, the CLI supports a custom host:
194 --host <addr> Host to bind (default: 127.0.0.1)
So if an operator binds to 0.0.0.0 and sets UNCOMMON_ROUTE_ADMIN_TOKEN, they may reasonably expect admin/dashboard-sensitive data to be protected, but /v1/artifacts can still list artifact IDs/previews and /v1/artifacts/{id} can return full stored content without that token.
Possible fixes:
- Apply
_admin_auth_failure(request) to handle_artifacts() and handle_artifact().
- Alternatively, allow local-only unauthenticated reads but require
UNCOMMON_ROUTE_ADMIN_TOKEN when the server is remotely bound.
- Consider whether other read-only dashboard endpoints such as
/v1/stats/recent, /v1/feedback, or /v1/scenes should use the same policy if they expose user/request-derived state.
Thanks for the project. This is easy to miss because the on-disk permissions are careful, but the HTTP read path bypasses that boundary.
Artifact read endpoints can expose persisted content without the admin gate
Hi! I was reviewing the local proxy/dashboard surface and noticed a possible access-control gap around persisted artifacts.
Admin-mutating endpoints are protected by
_admin_auth_failure():For example, connection/provider mutations call that guard:
But the artifact endpoints do not appear to apply the same check:
The route table exposes those handlers directly:
Why I think this matters:
ArtifactStore.get()returns the full stored content, not only metadata:Artifacts can contain offloaded conversation/tool content:
The filesystem storage itself is private (
0700directory and0600files), which suggests this content is intended to be sensitive/local:The default server bind is local-only:
However, the CLI supports a custom host:
So if an operator binds to
0.0.0.0and setsUNCOMMON_ROUTE_ADMIN_TOKEN, they may reasonably expect admin/dashboard-sensitive data to be protected, but/v1/artifactscan still list artifact IDs/previews and/v1/artifacts/{id}can return full stored content without that token.Possible fixes:
_admin_auth_failure(request)tohandle_artifacts()andhandle_artifact().UNCOMMON_ROUTE_ADMIN_TOKENwhen the server is remotely bound./v1/stats/recent,/v1/feedback, or/v1/scenesshould use the same policy if they expose user/request-derived state.Thanks for the project. This is easy to miss because the on-disk permissions are careful, but the HTTP read path bypasses that boundary.