# The TODO shipped to npm: an unauthenticated route that could stop any city's AI workflow (Your Priorities, @yrpri/api < 9.0.244)

> Source: <https://dev.to/santosh_kumarpuppala_96e/the-todo-shipped-to-npm-an-unauthenticated-route-that-could-stop-any-citys-ai-workflow-your-pkd>
> Published: 2026-09-02 10:40:59+00:00

`//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.<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*
