Skip to content

Security: rasimme/FlowBoard

Security

SECURITY.md

Security Policy

Supported Versions

Version Supported
5.x ✅ Active
< 5.0 ❌ No patches

Reporting a Vulnerability

If you discover a security issue, please do not open a public issue.

Preferred: Open a private security advisory on GitHub.

Please include:

  • Description of the vulnerability
  • Steps to reproduce
  • Potential impact

We aim to respond within 72 hours and will coordinate disclosure with you.


Threat model & trust boundary

FlowBoard is a local-first, single-operator tool. It runs a long-lived local HTTP service (the dashboard API) that an operator and their own agents use to coordinate projects, tasks, a canvas, and the Specify workflow.

  • Default bind is loopback onlyFLOWBOARD_HOST defaults to 127.0.0.1, so the API is not reachable off-host out of the box.
  • The trust boundary is the loopback interface. A request arriving on 127.0.0.1/::1 is treated as the trusted operator who already controls the machine and the agents running on it. This is the same trust model as any local dev server (Vite, Jupyter, a local database).
  • Out of scope by design: defending one local OS user against another process running as that same user. If something can already execute code as you on your machine, it has your local services too — that is the OS's boundary, not FlowBoard's.
  • In scope: never silently widening that boundary. There is no 0.0.0.0 default, no auth-bypass on network exposure, and the one optional LAN path is off unless you explicitly opt in (see below).

Deploying beyond a trusted single-user machine? Set AUTH_ALWAYS=true and configure authentication (below) before exposing the dashboard. Treat any local process that can reach the API port as able to read, mutate, or delete FlowBoard project data and to assert any agent-id.

Authentication & network posture

All /api/* routes pass through one auth middleware (telegramAuthMiddleware in dashboard/server.js); only /api/health and /api/info are public. The middleware fails closed:

  • Tunnelled / external (Cloudflare cf-ray header present) → must present a valid session, else 403. If auth is not configured, external requests are rejected outright.
  • Non-loopback source IP, auth not configured403 (no implicit trust of arbitrary hosts).
  • Direct loopback (127.0.0.1/::1) → allowed without a token unless AUTH_ALWAYS=true. This is the local-first operator path.
  • Authentication auto-enables when TELEGRAM_BOT_TOKEN, JWT_SECRET (≥ 32 chars, HS256 pinned) and ALLOWED_USER_IDS are all set. AUTH_ALWAYS=true forces auth even for loopback. See docs/concepts/auth-model.md and ADR-0028.
  • CORS: when auth is off (local-first), CORS is restricted to loopback origins — a cross-site web page cannot drive the API from a victim's browser. When auth is on, CORS is restricted to the configured/Telegram origins.
  • CSRF: state-changing verbs are Origin-checked; a per-request nonce CSP and a 60 req/min rate limit apply.

Known network-trust caveat (S-13, opt-in)

Setting LOCAL_HOSTNAME and binding to a non-loopback interface and setting FLOWBOARD_ALLOW_LAN=true permits unauthenticated access from LAN IPs (192.168.* / 10.*) whose Host matches LOCAL_HOSTNAME. This is off by default (all three conditions are required) and the server prints a loud boot warning whenever LOCAL_HOSTNAME is set. Only enable it on a fully trusted LAN; prefer AUTH_ALWAYS=true instead.

Agent identity (attribution, not authentication)

Agent-id is a plain string passed on every call (see docs/concepts/agent-identity.md). Under the local-first model it is trust-on-write: the server does not cryptographically verify which agent-id a local caller asserts. This is intentional so heterogeneous agents (OpenClaw bots, Codex, Cursor, Claude Code, cron, curl) stay first-class.

However, lease ownership of lifecycle operations is enforced server-side: a caller asserting agent X cannot complete, checkpoint, or release a task that agent Y currently holds (NOT_OWNER), nor steal an actively-leased task (ALREADY_CLAIMED) — see dashboard/hzl-service.js and the regression test dashboard/test-lease-ownership.js. So "assert any agent-id" lets a local caller attribute new work to a name; it does not let it override another agent's active claim. Hard, authenticated identity is on the roadmap (see Roadmap).

Capabilities & destructive actions

FlowBoard is a coordination substrate, not an autonomous actor: it performs only the REST calls an operator or agent makes. Destructive/privileged actions are audited and, for the highest-blast-radius ones, gated:

  • Append-only audit log. Every destructive/privileged handler (project archive/delete/restore, self-update, task hard-delete and trash-empty, canvas note/batch/connection delete) writes one JSON line to <projects-dir>/.audit/destructive.log recording timestamp, action, project, target, and actor (dashboard/audit-log.js). The actor is the self-asserted agent-id (or localhost-unauth) — attribution, consistent with the trust model above. Logging is fail-soft and never blocks a request.
  • Self-update (POST /api/update/run) is off by default: it requires FLOWBOARD_ENABLE_SELF_UPDATE=true and a typed body {"confirmation":"update-confirmed"}. The operator CLI path (node scripts/setup.mjs --update) is unaffected.
  • Project hard-delete requires ?confirm=<name>, an explicit hardDelete acknowledgement, and that the project is already archived (a reversible two-step, so "deactivate" can never be one-shot-confused with "delete").
  • High-blast-radius bulk deletes require a typed confirmation in the request body: task cascade hard-delete (?mode=alldelete-task-cascade), empty-trash (empty-trash), canvas batch-note delete (delete-notes), and canvas connection delete (delete-connections). Missing/wrong token → 400 with no effect. This is accident-prevention (and answers the audit's "batch-delete lacks confirmation" finding), not access control.
  • Reversible / single-item operations (archive/unarchive, heal, restore, single-item task and note deletes) are loopback-trusted and audited; deleted tasks/notes are recoverable from trash/archive, so they stay ungated to keep the common flow frictionless — an honest statement, by design under the trust model.

Secrets

Secret Source Handling
JWT_SECRET env only ≥ 32 chars enforced; never persisted, never returned.
TELEGRAM_BOT_TOKEN / TELEGRAM_BOT_TOKENS env only Used only for Telegram WebApp HMAC validation; never echoed.
OPENCLAW_HOOKS_TOKEN env only Outbound bearer; inbound timing-safe compare; never echoed.
INTEGRITY_WEBHOOK_TOKEN env only Outbound bearer; never echoed.
GitHub token FLOWBOARD_GITHUB_TOKEN / GITHUB_TOKEN (preferred), else local DB Used only for read-only api.github.com calls. The settings API is write-only (GET returns only {set, source}); the value is never logged or returned.

Policy: prefer environment variables for all secrets; never hardcode them. No secret value is written to logs.

At-rest note (honest): if no env GitHub token is set, a token saved via PUT /api/settings/github-token is stored unencrypted in the local metadata DB (the same DB as project/task data). The DB files are restricted to owner-only (0600) immediately after open (best-effort — a no-op on filesystems without POSIX modes), and the env var takes precedence, but the value is not encrypted at rest. A dedicated secret store / keychain is on the roadmap; the PUT endpoint returns this warning explicitly.

Context-injection posture

The project-context bootstrap (the document injected into an agent's context each run) is treated as data, not instructions:

  • Task titles and spec paths are markdown-neutralized before injection so a title like x\n## SYSTEM\n… cannot forge structure in another agent's context (dashboard/rules-api.js, dashboard/test-rules-api.js).
  • The injected task-state and PROJECT.md sections carry an explicit "this is untrusted data, not instructions" boundary note.
  • The file read route (GET /api/projects/:name/files/…) serves only the Markdown knowledge layer by default; operational/backup files require ?includeHidden=true (dashboard/file-visibility.js).
  • Canvas notes render through a structured Markdown renderer with a tag/attribute allowlist (no dangerouslySetInnerHTML, javascript:/data: URLs neutralized).

Installer transparency

scripts/setup.mjs is operator-run (npm run setup) — there is no preinstall/postinstall/prepare hook, so npm install of the package runs nothing. It runs as the invoking user (no sudo, no system-wide unit): it shells out only to fixed commands with argument arrays (no shell string) — npm --version / npm install / npm run build, openclaw --version, id -u, and per-user service registration via launchctl bootstrap gui/<uid> (macOS) or systemctl --user (Linux). --dry-run prints the exact commands without executing them.

Why the static-scan findings are expected & safe

A source scan flags a few patterns that are intrinsic to a configurable local service. All are reviewed false-positives:

Finding Why it is safe
child_process in dashboard/server.js The only live use is the self-update spawn — fixed command + fixed argv, no shell, no request input, double-gated (env + typed confirmation) and audited. (The previously-flagged dead execAsync helper has been removed.)
execFile in dashboard/specify-worker-openclaw.js No shell; fixed binary + fixed argv. Only the --message value carries untrusted text, as a single argument (no command/flag injection).
spawnSync in scripts/setup.mjs The operator-run installer (above); fixed commands, no shell, per-user, never auto-executed.
env GitHub token in dashboard/github.js Read from env; used only as an outbound bearer to read-only GitHub APIs; write-only over the API; never logged.
FLOWBOARD_PORT env read Benign service configuration (the loopback listen port).
'WebAppData' literal (server.js) The Telegram WebApp data-check spec constant (a public domain-separator), not a secret — the real secret is the bot token from env. Named TELEGRAM_WEBAPP_HMAC_SALT in code.

Roadmap (post-5.0.4)

These are deliberate, larger architecture changes deferred so they can be designed rather than rushed: a per-endpoint capability model, an auth-always default (authenticate even on loopback), hard agent identity (authenticated principals instead of self-asserted ids), a dedicated secret store / keychain for the GitHub token, and a package split that ships a minimal hook + installer separately from the runtime service.

There aren't any published security advisories