The TODO shipped to npm: an unauthenticated route that could stop any city's AI workflow (Your Priorities, @yrpri/api < 9.0.244)
TL;DR What: In Your Priorities — the Citizens Foundation's open-source participatory-democracy platform, the one municipalities self-host to run citizen deliberation — two Express routes in the AI-assistant controller were registered with no authorization middleware at all, directly beneath a co

TL;DR What: In Your Priorities — the Citizens Foundation's open-source participatory-democracy platform, the one municipalities self-host to run citizen deliberation — two Express routes in the AI-assistant controller were registered with no authorization middleware at all, directly beneath a comment reading //TODO: Add auth for below. The handler for PUT /api/assistants/:groupId/:agentId/:runId/advanceOrStopWorkflow resolved the target run by bare primary key and never compared it to a caller. Impact: With no session cookie and no token, an anonymous HTTP client could advance or force-fail any group's AI-agent workflow run. runId is a sequential integer, so the whole population is enumerable. CWE-862. I score it CVSS v3.1 6.5 (Medium) — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L. That is my score: no advisory was published, so no vendor rating exists. Fixed: commit ae4b4704, shipped as @yrpri/api 9.0.244 — the same day I reported it. Reported by me, Santosh Kumar Puppala, under coordinated disclosure. The maintainer credited me by name in the commit subject and in the release note. No CVE yet; one has been requested. Your Priorities is not a startup's side project. It is the deliberation engine behind Better Reykjavík and a long line of municipal and national participation programmes — the software a city stands up when it wants residents to file, debate and prioritise ideas, and then wants the results to carry political weight. Its newer AI-assistant layer runs agent workflows on behalf of groups: multi-step runs that process submissions, summarise, and advance through a state machine. So the asset here is not a row in a database. It is the integrity of a process a public body is going to act on, and the availability of the machinery that produces it. An anonymous client could reach into that state machine and turn the crank. The backend is Node/TypeScript on Express, published to npm as @yrpri/api. Authorization is not ambient — it is per-route middleware, auth.can("..."), applied at registration time in each controller. The global middleware stack tells you immediately that there is no safety net. In app.ts (around L952) the app mounts: app.use(passport.initialize()); app.use(passport.session()); Both of those are populate-only. They hydrate req.user when a valid session exists and do nothing at all when one doesn't. Neither rejects an anonymous request. Whether a route is protected is decided entirely by what that route asks for, and the assistants router is mounted at app.ts:714 with no gate on the path. Which is fine, because the controller mostly does ask. Sibling routes in the very same file: // assistantsController.ts — the house idiom, used correctly this.router.put("/:domainId/chat", auth.can("view domain"), this.chat.bind(this)); and the read paths for the same agent feature check by hand: // getUpdatedWorkflow / getAgentConfigurationAnswers if (!req.user) { res.sendStatus(401); return; } That is the negative control. This is not a codebase that forgot authorization exists. The pattern is present, it is enforced, and the reads on this exact feature are gated. Now the block that isn't. Around line 105 of assistantsController.ts: // assistantsController.ts ~L105 // TODO: Add auth for below // ... and then, at L143-144: this.router.put("/:groupId/:agentId/:runId/advanceOrStopWorkflow", this.advanceOrStopWorkflow.bind(this)); No auth.can(...). No if (!req.user). The handler runs for anyone who can reach the port: // assistantsController.ts:231-251 async advanceOrStopWorkflow(req: Request, res: Response) { const { runId, status } = { ...req.params, ...req.body }; const wsClientId = req.params.wsClientId; await notificationManager.advanceWorkflowStepOrCompleteAgentRun( parseInt(runId), status, wsClientId ); res.json({ success: true }); } And the manager it delegates to — notificationAgentQueueManager.ts:211-300 — contains the detail I find genuinely striking: const agentRun = await YpAgentProductRun.findByPk(agentRunId, { include: [{ model: YpAgentProductBundle, include: [{ model: YpSubscription, attributes: ["user_id"] }] }] }); // agentRun.YpAgentProductBundle.YpSubscription.user_id — fetched, never compared if (status === "failed") { await agentRun.update({ status: "failed", completedAt: new Date() }); } else { workflow.currentStepIndex += 1; await agentRun.save(); } Read that include again. The query deliberately joins two levels down — bundle, then subscription — and pulls exactly one column: user_id. The owner of the run. It is loaded into memory, sitting on the object, and then the function mutates the run without ever looking at it. The identity needed to make the decision was already in the result set. The line that compares it was the line that was never written — and a comment in the file says so out loud. That is what makes this one worth writing up rather than filing as another missing-middleware bug. There was no architectural gap to close. No refactor, no new query, no plumbing a user through three layers. The ownership data had already been fetched by someone who clearly intended to check it. The same //TODO block also covered a sibling: PUT /api/assistants/:groupId/:agentId/startWorkflowAgent, which enqueued a BullMQ agent-processing job for any supplied agentId, unauthenticated. That one is an LLM-execution cost sink — anonymous callers making someone else's inference bill go up. I reported both together; one commit closed both. Confirmed end to end on 2026-06-17 against the published @yrpri/api v9.0.242 build, running on Node 22 with containerised PostgreSQL 13 and Redis 6.2 bound to 127.0.0.1. Synthetic rows only. Nothing was ever sent to a live deployment. Every request below carries no Authorization header and no session cookie. [MUTATE] PUT /api/assistants/1/1/1/advanceOrStopWorkflow {"status":"failed"} -> 200 run 1 in DB: running -> failed [MUTATE] PUT /api/assistants/1/1/3/advanceOrStopWorkflow {"status":"advance"} -> 200 run 3 workflow.currentStepIndex: 0 -> 1 [CONTROL] PUT /api/assistants/1/chat (auth.can("view domain")) -> 401 The 401 is the entire argument. Same app, same process, same controller file, same anonymous client — and the guarded sibling correctly refuses. The auth middleware is present, loaded and working. These routes simply never asked it anything. The two mutations were verified in Postgres, not inferred from the response body: a run belonging to a seeded subscription flipped to failed, and a second run's state machine stepped forward, both at the request of a caller with no identity whatsoever. I emailed the maintainer on 2026-07-10 at 16:48. He replied at 17:36 — forty-eight minutes later — confirming the issue and linking the commit that fixed it. Commit ae4b4704 closes both routes: advanceOrStopWorkflow gains an explicit ownership predicate — the run is resolved within the caller's authorized scope instead of by bare primary key, so the user_id that was already being fetched is finally the thing the decision turns on. startWorkflowAgent, the sibling in the same //TODO block, was removed outright rather than guarded. It shipped as @yrpri/api 9.0.244 the same day. The release note reads, in full: Fix for security related issues in agent workflows reported by Santosh Kumar Puppala. Honesty note on evidence class: I verified the fix from the maintainer's reply, the fix commit and the published npm artifact — reported→confirmed→released, all on 2026-07-10. I did not rebuild a patched image and re-run the PoC against 9.0.244, so this is "confirmed from the commit and the release," not a re-tested NOT_REPRODUCED verdict. The weaker of the two, and worth labelling as such. I am also describing the shape of the patch rather than quoting a diff line by line. One thing this was not is a silent fix. The commit subject names me and links my GitHub profile; the release note names me. The only thing missing is a GHSA — which is why self-hosters got no upgrade signal, and why the CVE is still being chased through MITRE's CNA of last resort. Grep your own TODOs as an attack surface, not a backlog. TODO: Add auth, FIXME: check perms, XXX: temporary — these are not notes to the team. In a shipped artifact they are a signed confession, indexed and searchable, telling anyone who clones the repo exactly which line to look at first. This one had been published to npm. If you run one grep after reading this, make it grep -rn "TODO.*auth\|FIXME.*perm" src/. A guarded read next to an unguarded write is the highest-signal pattern in authorization auditing. Here getUpdatedWorkflow returned 401 to anonymous callers while advanceOrStopWorkflow — on the same feature, in the same file — returned 200 and changed the database. When the reads are protected and the mutations are not, you never have to argue about intent: the developers already documented where the boundary goes, and the state-changing path is on the wrong side of it. Watch for ownership data that is fetched and then ignored. That nested include pulling Subscription.user_id is a fingerprint. Somebody wrote a join whose only purpose is authorization and then didn't finish the thought. When you are reading unfamiliar code, a query that retrieves an owner id which never appears again on any code path is worth more of your attention than a hundred routes with no ownership concept at all — because it tells you the boundary was supposed to be there. Date Event 2026-06-15 Found during a source review of the assistants controller — spotted as a read/write authorization asymmetry inside a //TODO block 2026-06-17 Live PoC confirmed on @yrpri/api v9.0.242: unauthenticated force-fail and unauthenticated workflow advance, with a 401 control on the guarded sibling 2026-07-10 16:48 Reported privately by email to the maintainer, under coordinated disclosure (the repo had private vulnerability reporting disabled and no SECURITY.md) 2026-07-10 17:36 Maintainer replies — 48 minutes — confirming the issue and linking the fix commit ae4b4704 2026-07-10 @yrpri/api 9.0.244 published, crediting the report by name in the release note — No security advisory published; a CVE has been requested and is pending If you run Your Priorities: you want @yrpri/api 9.0.244 or later. Because there is no GHSA, no dependency scanner is going to tell you that. Reported by Santosh Kumar Puppala — GitHub: @Santoshkumarpuppala, under coordinated disclosure. No CVE has been assigned yet; one has been requested. Thanks to the Citizens Foundation maintainer for a same-day fix and for crediting the report publicly — that is not the norm, and it should be. If you maintain a Node service, here is a ten-minute job. List every router.<verb>(...) call in your codebase. List every one that names a middleware. Diff the two. Then, for whatever is left, ask one more question of each handler: does it load an owner id it never compares? Both of those greps are shorter than you think, and both of them find real bugs. Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq & Veridor). GitHub: @Santoshkumarpuppala
Key Takeaways
- •TL;DR What: In Your Priorities — the Citizens Foundation's open-source participatory-democracy platform, the one municipalities self-host to run citizen deliberation — two Express routes in the AI-assistant controller were registered with no authorization middleware at all, directly beneath a co
- •This story was reported by Dev.to, covering developments in the dev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article:
Read Full Article on Dev.to →


