| Version | Supported |
|---|---|
| 5.x | ✅ Active |
| < 5.0 | ❌ No patches |
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.
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 only —
FLOWBOARD_HOSTdefaults to127.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/::1is 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.0default, 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.
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-rayheader present) → must present a valid session, else403. If auth is not configured, external requests are rejected outright. - Non-loopback source IP, auth not configured →
403(no implicit trust of arbitrary hosts). - Direct loopback (
127.0.0.1/::1) → allowed without a token unlessAUTH_ALWAYS=true. This is the local-first operator path. - Authentication auto-enables when
TELEGRAM_BOT_TOKEN,JWT_SECRET(≥ 32 chars, HS256 pinned) andALLOWED_USER_IDSare all set.AUTH_ALWAYS=trueforces auth even for loopback. Seedocs/concepts/auth-model.mdand 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.
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-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).
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.logrecording timestamp, action, project, target, and actor (dashboard/audit-log.js). The actor is the self-asserted agent-id (orlocalhost-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 requiresFLOWBOARD_ENABLE_SELF_UPDATE=trueand a typed body{"confirmation":"update-confirmed"}. The operator CLI path (node scripts/setup.mjs --update) is unaffected. - Project hard-delete requires
?confirm=<name>, an explicithardDeleteacknowledgement, 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=all→delete-task-cascade), empty-trash (empty-trash), canvas batch-note delete (delete-notes), and canvas connection delete (delete-connections). Missing/wrong token →400with 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.
| 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.
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.mdsections 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).
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.
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. |
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.