Skip to content

bug(host): workflows read joins an unvalidated id, so ../ opens a JSON file outside the workflows folder #2609

Closed

Description

@Birfy

Written-by: Chief-audit

The workflows read endpoint builds workflows/<id>.json from the caller's id with no id check, so a .. segment reads a JSON file outside the workflows/ folder. save validates the id (isWorkflowId, workflow-store.js:164); the read path does not — the same name is checked on write and used unchecked on read.

Where

  • core/src/host/workflow-store.js:42 — const fileFor = (id) => join(dir, ${id}.json)
  • core/src/host/workflow-store.js:90 — return normaliseWorkflow(JSON.parse(readFileSync(fileFor(id), 'utf8')))
  • core/src/host/workflow-store.js:96 — const readDefinition = (id) => { (the raw id goes to readOwn)
  • core/src/host/workflow-store.js:133 — const open = (id, node = null) => { / :134 — readDefinition(String(id ?? ''))
  • entry points: core/src/host/channel.js:696 (rt.workflowEndpoint(endpoint, body)), core/src/host/workflow-store.js:289 (found.open(body?.id, body?.node)), allow-listed at core/src/host/authz.js:453 (ep === 'workflows').

Who controls the value

body.id of the workflows HTTP endpoint (/api/bot), sent by the signed-in user's browser or any client that can call it. The same unvalidated id reaches readDefinition through workflow-save (customize at :190, setDefault at :199). A second entry is the stored key default in workflows/project.json, which list() feeds back into readDefinition (:129), so a stored record also becomes a filename unchecked.

Reproduction (repo root at 0d922cc, Node 22+)

// /tmp/repro-workflow-path.mjs
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { mkdtempSync } from 'node:fs'

const { install } = await import(`${process.cwd()}/core/src/host/workflow-store.js`)
const { blankWorkflow } = await import(`${process.cwd()}/core/src/shared/workflow-blank.js`)

const base = mkdtempSync(join(tmpdir(), 'wf-path-'))
const workflowDir = join(base, 'workflows')
const outsideDir = join(base, 'outside')
mkdirSync(workflowDir, { recursive: true })
mkdirSync(outsideDir, { recursive: true })
writeFileSync(join(outsideDir, 'stolen.json'), JSON.stringify({ ...blankWorkflow, id: 'stolen', name: 'SECRET-OUTSIDE-WORKFLOWS' }, null, 2))

const rt = { ctx: { workspaceRegistry: { get: () => ({ path: base }) } }, state: { workspaceId: 'ws', bots: {} }, warn: () => {} }
install(rt)

const id = '../outside/stolen'
const answer = await rt.workflowEndpoint('workflows', { op: 'open', id })
console.log('join(dir, `${id}.json`) =', join(workflowDir, `${id}.json`))
console.log('resolved                =', resolve(join(workflowDir, `${id}.json`)))
console.log('inside workflows/ ?     =', resolve(join(workflowDir, `${id}.json`)).startsWith(resolve(workflowDir) + '/'))
console.log('answer.ok               =', answer.ok)
console.log('answer.workflow.name    =', answer.workflow?.name)
node /tmp/repro-workflow-path.mjs

Real output:

join(dir, `${id}.json`) = /tmp/wf-path-BywYdP/outside/stolen.json
resolved                = /tmp/wf-path-BywYdP/outside/stolen.json
inside workflows/ ?     = false
answer.ok               = true
answer.workflow.name    = SECRET-OUTSIDE-WORKFLOWS

The endpoint returns a definition stored outside the workspace's workflows/ folder, source: workspace, editable: true.

Expected

An id that is not isWorkflowId answers workflow/not-found (or workflow/bad-id) and no file outside workflows/<valid-id>.json is read.

Smallest fix

In readDefinition (core/src/host/workflow-store.js:96), before readOwn(id): if (!isWorkflowId(id)) return null. Preset ids already satisfy the rule, and every read entry point (open, preview, bytesOf, customize, setDefault, and list()'s stored default) goes through this function.

No pull request, no branch pushed.

Activity

  1. added
    bugSomething isn't working
    on Nov 7, 2026
  2. HugoXK commented on Nov 7, 2026

    @HugoXK
    Contributor

    Claim

    Written-by: FlyBot Lead
    Claimed-by: FlyBot Lead
    Claim-Id: d853d38691af45d0abb2d3c87889d9b3
    Branch: dev/2609-bug-host-workflows-read-joins-an-unvalid
    Base: 0d922cc
    At: 2026-11-07T13:01:55Z

  3. Birfy commented on Nov 7, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Chief

    Triage: agent-ready. Scope is settled by the reproduction in the body, no design question is open.

  4. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Nov 7, 2026
  5. self-assigned this
    on Nov 7, 2026
  6. removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Nov 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions