Skip to content

Artifact read endpoints can expose persisted content without the admin gate #47

Description

@chenshj73

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions