DEV Community

Cover image for Hardening an Edge AI Platform: What a Real Security Release Looks Like (NeoMind v0.9.21)
Ming
Ming

Posted on

Hardening an Edge AI Platform: What a Real Security Release Looks Like (NeoMind v0.9.21)

Edge devices are a brutal attack surface. They bind to the LAN with nobody babysitting them, they hold credentials for cloud APIs, they run third-party extension code, and "patch it Tuesday" usually means someone drives to the site. When you ship an edge platform, the security work is not a blog post — it's a pile of specific, unglamorous fixes.

NeoMind is an open-source, Rust-powered edge AI platform for IoT automation (single binary: HTTP API + embedded MQTT broker + rule engine + AI agent runtime). Its just-shipped v0.9.21 is largely a security and data-safety release, and I think it's worth walking through in public — both what was found and how it was fixed. Everything below reflects the repository at commit 8a75c57ee09b and the official release notes.

1. Close the front door: self-registration was open

POST /api/auth/register was a public, unconditionally-open account-creation endpoint — on a server that binds 0.0.0.0. Any client on the LAN could mint a user account.

The fix in 0.9.21: registration now returns 403 unless an admin explicitly opens it (admin-only setting, persisted so the choice survives restarts). Nothing regresses for legitimate flows — the first admin comes from the setup wizard and additional users from the admin-only user-management endpoint. The open endpoint had no honest product caller; it was just a hole.

Related, from 0.9.20 and still the backbone: the public auth endpoints are brute-force throttled — login counts credential failures at 5 per 15 minutes keyed per username AND per client IP (either dimension over the cap blocks, stopping both targeted guessing and account-spraying; a successful login clears the counters), and registration/first-run setup count every attempt per IP.

2. Secrets at rest: the asymmetry fix

The platform's own API keys were encrypted with AES-256-GCM, but LLM provider API keys were stored as plaintext JSON in settings.redb. A copied data directory leaked every cloud key while looking superficially protected.

0.9.21 seals both stores with the shared crypto service (same data/encryption_key the auth store uses; an env override still wins). Legacy plaintext rows load unchanged and get sealed on next save — upgrades are transparent. Config-change history previously duplicated the plaintext key on every tracked save; it now records the sealed form.

Closing the asymmetry: LLM provider keys sealed at rest like every other secret

3. The path-traversal family

If you take one lesson from this release, take this one: every path join is a vulnerability candidate. 0.9.20 + 0.9.21 together close four of them:

  • Share proxy dot-segments: /share/{token}/proxy/devices/../../auth/keys passed the first-segment allowlist and the loopback forwarder normalized the path away — an anonymous share viewer reached any authenticated route via the internal-proxy header. Dot-segments are rejected outright.
  • Absolute-path asset join: the extension asset server joined an attacker-controlled path onto the extension dir; an absolute path (/etc/passwd) replaced the base with no .. needed. Absolute paths are rejected and a canonicalized containment check backstops every join.
  • file_path request fields: extension register/upload/validate accepted any host path, making them a read-and-try-load primitive for anyone holding credentials. Paths now resolve against (and must stay inside) the data directory, after canonicalization.
  • Marketplace ID interpolation: the raw extension ID was interpolated into market URLs — ../.. turned the marketplace client into a limited arbitrary-GET against the market host. IDs are validated before URL building.

Every path join is a vulnerability candidate: containment checks stop traversal

4. Supply chain: sandbox escapes and unverified packages

NeoMind extensions run in sandboxed processes; WASM tooling sits underneath. The 0.9.21 release-blocker repairs bump wasmtime 36.0.13 → 36.0.14 for RUSTSEC-2026-0269 — a HIGH severity filesystem sandbox escape when paths or symlinks contain trailing slashes, published 2026-08-31. It started failing cargo audit the day the advisory landed, and the lock update shipped in the same release. (We verified the advisory against the RustSec database; if you run wasmtime anywhere, check your lockfile.)

Two more supply-chain fixes:

  • Package integrity is now actually checked. The marketplace index carried no sha256, so the fail-closed verification branch never fired. The installer now falls back to release-level checksums.txt, warns loudly when no integrity data exists, and NEOMIND_STRICT_PACKAGE_SHA256=1 refuses unverified packages outright.
  • Zip-bomb defenses unified. The async package-extraction path had no size/count caps while the sync installer did — same crate, two implementations, asymmetric defenses. Both now share one set of caps and explicitly reject symlink entries. (A follow-up found a third extraction path had slipped the caps during unification — it got them too.)

5. Credential lifecycle

  • Deleting a user or changing a password revokes sessions immediately — both the in-memory whitelist and every persisted row. Previously a JWT minted earlier kept working for up to 7 days: a leaked token survived password rotation.
  • Remote-instance API keys are no longer handed back by the API. The instance list previously returned every instance's full key XOR-"encrypted" with a cipher hardcoded in the open-source repo — anyone who could list instances recovered every credential. Now only masked keys leave the server.
  • Interactive share links no longer mean "full write". An allow_interactive share token previously skipped the method check — an anonymous holder could POST/PUT/DELETE under the proxied prefixes (install extensions, delete agents). Both share modes now pass the same method gate; interactive adds exactly one write — device command actuation. Configuration editing stays blocked.

6. Upgrades across a privilege boundary (without sudoers)

The 0.9.21 headline for ops: the About page can upgrade a server deployment — check for updates, download with live progress, apply, reload. The security-relevant part is how the privilege boundary is handled:

  • The API runs as the sandboxed neomind user (ProtectSystem=full + NoNewPrivileges=true). It cannot write /usr/local/bin and cannot sudo.
  • So the API only stages: stream the release into data/upgrade/v<ver>/ (2GB cap, binary --version-verified before anything is touched), then write apply.trigger.
  • A root systemd .path unit watches that file with inotify and runs the apply step: back up → atomic install -m 755 swap → web-dir swap → restart. No sudoers rule, nothing relaxed in the main unit's sandbox.
  • The upgrade endpoints sit in the JWT-gated admin route group — API keys cannot trigger an upgrade.

Two-phase upgrade across the privilege boundary: sandboxed staging, root apply via systemd path unit

7. Data safety is security too

Two additions that belong in any edge threat model:

Verified backups. neomind backup copies every database plus the two secret files (encryption_key — without it the sealed keys in the backup are undecryptable — and .jwt_secret) into a timestamped directory (0700; secrets 0600), verifies each copied database opens, writes a manifest, and only then renames the staging dir into place. A crashed backup never masquerades as a restorable one. A scheduler runs it on a configurable interval (6h–7d) with retention.

Rollback guard. The realistic corruption risk on edge boxes isn't old data meeting new code — it's the reverse: a rolled-back install opening newer data, silently dropping unknown fields, then destroying newer-format rows on the first save-back. Every storage database is now version-stamped, and a store refuses to open a database stamped by a newer build, with an explicit "upgrade instead of rolling back" error. About 100 lines; no framework needed.

8. Observe it or it didn't happen

A new public Prometheus endpoint (/api/metrics) exposes request/response counters, uptime, build info, and — my favorite — neomind_eventbus_dropped_total. The event bus's lagged-subscriber warning log has literally said "surface this, don't let the system fail quietly" since it was written. Now drops are measurable: non-zero and growing means an automation subscriber is missing events.

Checklist for anyone shipping edge software

  1. Audit your registration and account-creation endpoints — open-by-default on 0.0.0.0 is commoner than you think.
  2. Encrypt every secret at rest, then check for the asymmetry: one plaintext field next to ten encrypted ones.
  3. Treat every path join as hostile: reject dot-segments and absolute paths, canonicalize, containment-check.
  4. Verify third-party packages (checksums), and keep cargo audit (or equivalent) red-blocking your CI.
  5. Revoke sessions on credential events, and never return secrets from list endpoints.
  6. Design upgrades around privilege boundaries, not sudoers.
  7. Back up with verification; guard against rollback as a data-integrity attack.

Full details are in the release notes, and the code is open — review it, run it, file issues: github.com/camthink-ai/NeoMind. The project wiki covers the platform itself.

All facts reflect the NeoMind repository at commit 8a75c57ee09b (release v0.9.21) and its official release notes; RUSTSEC-2026-0269 verified against the RustSec Advisory Database.

Top comments (0)