The TODO shipped to npm: an unauthenticated route that could stop any city's AI workflow (Your Priorities, @yrpri/api < 9.0.244) A critical unauthenticated vulnerability was discovered in Your Priorities' @yrpri/api package, affecting versions prior to 9.0.244. The flaw allows any anonymous client to advance or stop AI agent workflows by exploiting a missing authorization check on the 'advanceOrStopWorkflow' route, potentially disrupting municipal deliberation processes. The issue, identified as CWE-862, was fixed in version 9.0.244. //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. runId is a sequential integer, so the whole population is enumerable. CWE-862. I score it CVSS v3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L . That is ae4b4704 @yrpri/api 9.0.244Your 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: js 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 user id that was already being fetched is finally the thing the decision turns on. startWorkflowAgent , the sibling in the same //TODO block, was 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 | published, crediting the report by name in the release note @yrpri/api 9.0.244 | | — | 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 https://github.com/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.