Skip to the content.

Project-N-E-K-O/N.E.K.O — security scan

Repository: Project-N-E-K-O/N.E.K.O Commit scanned: 35073e400bf9 Scan date: 2026-07-29 Disclosure status:resolved — fixed upstream in PR #2559, merged ~14 hours after filing

Summary

Severity Count
Critical 2
High 387
Medium 394
Low 0
Info 0

Total findings: 783 (1 real after curation)

N.E.K.O. (2.3k★, Apache-2.0) is a desktop AI companion — the first consumer-facing one in this series. It is a “digital life” that lives on your machine: real-time voice and vision, a five-tier memory system, Live2D / VRM / MMD / PNGTuber avatars, proactive chat, a plugin marketplace, Steam Workshop character sharing, and an agent layer that drives your browser and your computer. It ships free on Steam, runs on 14+ model providers, and is developed in the open at a remarkable clip — 831 merged PRs from 17 distinct authors in the last 60 days.

That shape matters for curation. Almost every scan in this series targets a server, where the first question is “what is exposed to the network?” Here the answer is: nothing. Every core service — main server, memory server, agent server, plugin host, tool server — binds 127.0.0.1. So the question flips to the one that actually applies to a desktop app:

Not “what can a remote attacker reach?” but “what can a web page reach on loopback, while the user is browsing?”

Asked that way, one endpoint stands out. And the reason it counts as a real finding rather than a theoretical one is that N.E.K.O already implements the correct defence — three times, in three sibling components — just not on the endpoint that hands out every API key you own.

Top findings

1. The loopback config API returns every provider API key in plaintext, with no Origin/Host/CSRF check — scanner-silent

2. verify_local_access checks the peer IP — which is always 127.0.0.1

3. The monitor server binds 0.0.0.0 with an unauthenticated write path

4. Telemetry and survey upload over cleartext HTTP with a shipped HMAC key

Patterns observed

The best-built parts are exactly the parts I expected to break. I came to this repo with a list: a plugin marketplace, Steam Workshop UGC, five avatar-format importers, drag-and-drop file ingestion, an agent that runs code. That is a lot of untrusted content arriving from strangers. Every one of those paths is defended, and several are defended better than in projects with a security team. There is no extractall anywhere in the codebase — all five archive importers walk infolist() member by member and check target.resolve().is_relative_to(root.resolve()) before writing (mmd_router.py:257, characters_router/cards.py:736, jukebox_router.py:109, memory/external_markdown_import.py:75). The markdown importer goes further and runs imported text through an _INJECTION_PATTERNS table — prompt-injection filtering on third-party content, which almost nobody ships. The plugin market bridge pairs remote origins through a one-time code, writes its token file 0o600, and validates the Host header. The autostart routes require a CSRF token compared with secrets.compare_digest against an Origin allowlist. There is even a sk-CANARY-APIKEY-9182 planted in a memory-export smoke test to prove keys don’t leak into exports.

Which is what makes the real finding worth filing. This is not a project that doesn’t understand the threat — it is a project that solved this exact threat three times and left the highest-value endpoint outside the fix. The gap is architectural, not conceptual: each guard was written as a local decorator on the component that needed it, so a route added elsewhere silently inherits nothing. One piece of app-wide middleware turns three good local answers into one global one. That is a much better bug to report than “you forgot about security,” and a much easier one to fix.

783 findings, 1 real — and the ratio is a property of the surface, not the risk. This is now a well-established pattern in the series (IBM ContextForge hit 946/0): finding count scales with how much stuff a project does. N.E.K.O has 2,515 Python files, a React frontend, GitHub Actions, four lockfiles and a bundled plugin ecosystem, so it trips every rule in the book. The clusters:

The exec is honest, so it isn’t a finding. brain/computer_use.py:1191 runs model-generated pyautogui code with full builtins and os in scope, and brain/cua/agents/worker.py:205 evals a generated plan. On any other project that is the headline. Here the product’s front page says it operates your browser and your computer, and — critically — nothing in the codebase claims the executor is sandboxed. That is the AG2 case: an honest “no boundary” is product surface, not a vulnerability. It is the precise inverse of Agently, where a component named PythonSandbox promised a boundary it could not enforce. An advertised-but-unenforced boundary is worse than a documented absence of one, and N.E.K.O documents the absence.

Both “criticals” are unreachable. CVE-2026-27962 in authlib==1.6.8 appears twice (once per lockfile) and is the only Critical in the report. authlib has zero import sites in 2,515 Python files; it arrives transitively through browser-use, which uses it for cloud-sync OAuth — a path N.E.K.O never touches, since it drives a local browser. Version-match, not reachable. Worth bumping because it is free, not because it is live. The genuine dependency work is elsewhere and is ordinary refresh: Pillow (a long DoS/heap tail, and genuinely reachable via avatar and screen-capture image handling), pypdf, Tornado, python-multipart, plus a 17-high frontend/plugin-manager/package-lock.json.

Notes on the tool

Disclosure timeline

Resolution

The maintainer (@MingTianSang) fixed both halves and took the structural option on each.

The masking gap. All 32 sensitive fields returned by GET /api/config/core_api are now replaced with a fixed sentinel rather than plaintext. The interesting part is what that forced: because the front end can no longer read the real key, the PR also had to make the write path sentinel-aware — a masked value posted back must mean “keep what’s stored”, an explicit empty must mean “clear it”, and a provider switch must not cross-wire one provider’s key into another. The connectivity-test button now detects a masked key and asks for re-entry instead of shipping the sentinel to a provider as if it were a credential. That is the whole reason a redaction change touched 244 lines of core_config.py and 175 of api_key_settings.js: masking a read-back field is only safe once round-tripping is safe.

The missing boundary. Rather than bolting a Host check onto the one leaking route, the PR adds utils/host_origin_guard.py (360 new lines) and registers it as middleware on all four servers — main_server/__init__.py, memory_server/runtime.py, agent_server/api_shared.py, and plugin/server/http_app.py — with WebSocket Origin rejection alongside the Host allowlist. Untrusted Host now returns 400. Custom domains and mDNS names are opted in through NEKO_TRUSTED_HOSTS / NEKO_TRUSTED_ORIGINS (documented in docs/config/environment-vars.md), and docker/entrypoint.sh auto-allows the existing SSL_DOMAIN so reverse-proxy deployments don’t break. Loopback and bare IPs are unaffected.

This is the outcome the intra-repo differential framing was arguing for. The report’s recommendation was not “add a check here” — it was “you already wrote this guard three times in this repo, promote it to app-wide middleware.” That is precisely what shipped, and it now also covers the WebSocket Origin surface, which the report had only raised as a secondary item.

Three new test files came with it — tests/unit/test_host_origin_guard.py (311 lines), tests/unit/test_core_config_secret_redaction.py (513 lines), and tests/frontend/api_key_secret_masking.test.cjs (286 lines) — and the PR reports a main-branch control run: on main, the config endpoint still returned plaintext, a forged Host still returned 200, and a hostile WebSocket Origin still connected. An independent confirmation that the issue reproduced as described, which is a more useful artifact than any severity label.

Reproduce

GIT_LFS_SKIP_SMUDGE=1 git clone https://github.com/Project-N-E-K-O/N.E.K.O /tmp/scan-target
python scanner/run_scan.py --repo /tmp/scan-target --reports-dir ./reports/project-n-e-k-o-n-e-k-o --min-severity medium