Build1 distinct publisher3 min readPublished
API Platform checks authorization per operation, so the guard on the POST told a reviewer nothing about the GET declared on a different entity, and the provider behind that GET trusted the store id in the path.
The Engineer · Build desk

Compiled by The EngineerSomething wrong?How this is made
API Platform 3 evaluates authorization per operation, not per resource [13]. The `security:` key holds a Symfony expression language string that the framework runs before the operation executes, and if the key is absent the only check left is whatever the firewall did on the way in [13]. CoopCycle runs Symfony 6 with API Platform 3 [12], and its `api_platform.yaml` sets no global default [14], so an omitted key produced silence rather than an error.
The other half of the mechanism is where the operation was written down. The path `/stores/{id}/addresses` is declared in two entity files [20]. The write is on `Store`, gated by `security: "is_granted('edit', object)"` [3]. The read is a second `#[ApiResource]` block on `Address`, whose `uriTemplate` is that same path and whose `uriVariables` link `id` back to `Store::addresses` [17]. Open `src/Entity/Store.php` in v5.5.0 and the coverage looks complete: the `time_slots` and `payment_methods` reads both require dispatcher, courier or `edit` on the object [15], and every one of those expressions resolves through `StoreVoter` and `$user->ownsStore()` [16]. The operation that mattered is not in that file. That is the transferable lesson: the reviewable unit is the operation, and the operations serving one URL are not obliged to share a class.
The provider does its own filtering. `getDropoffAddresses()` joins `Task` and `Delivery`, sets `:store` from the path id and constrains `t.type = 'DROPOFF'` [18]. Because the query is hand-rolled it also steps around Doctrine ownership extensions [19], which is the second layer that would otherwise have noticed a query with no idea who was asking. The reporter files the pair as CWE-862 and CWE-639 [7], missing authorization plus a user-controlled key, which is exactly the two halves.
The 6.5 is Santosh Kumar Puppala's own score, with no vendor rating and no advisory behind it [8][10]. Two things have to hold for it to land the same way on a given deployment. `PR:L` needs an authenticated account, and CoopCycle's self-registration is open, so that privilege costs an attacker a form submission [5]. `C:H` is the load-bearing metric in that vector [8], and what it rests on is tenant density: CoopCycle is licensed as a shared codebase to worker-owned businesses and models `Store` as a first-class tenant because that is how instances are actually used [11]. A single co-op on its own instance leaks its own address book to its own logged-in users, which is a smaller claim. The records themselves are not company data either way; they are customer homes accumulated across every delivery a store has made [21].
If I owned this codebase I would make the framework fail closed, since the absence of a global default is the thing that turns one forgotten key into a live read [13][14]. And I would audit from the generated route list rather than the entity files, because the route list is the only artefact that sees both declarations of `/stores/{id}/addresses` at once [20].
Ranked by verification strength, evidence, and original report placement.
coopcycle-web is an open-source logistics and marketplace platform that worker-owned courier co-operatives self-host instead of renting a commercial delivery app.
The operation GET /api/stores/{id}/addresses in coopcycle-web was declared with no security expression at all.
Every sibling operation on the same resource family carried a security expression, including the POST to the same URL /stores/{id}/addresses, which is gated by security: "is_granted('edit', object)".
The provider behind the GET filtered on the path {id} and nothing else; nothing in the query concerned the caller.
The reporter classifies the issue as CWE-862 plus CWE-639.
The reporter scores it CVSS v3.1 6.5 (Medium), AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N, states that this is his own score with no advisory and no vendor rating, and identifies C:H as the load-bearing metric for platform-wide recipient PII.
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · September 2, 2026
Follow any of these and your For You feed starts watching them — no settings page required.
build
Thirty lines of Doctrine filter, and the query paths where it is simply not there1 distinct publisher
build
A single-tenant fixture green-lights a Postgres database with no row-level security1 distinct publisher
build
The version field protects the row, not the client, and that gap is a 4121 distinct publisher
build
A tenant-installable PostGIS extension carried the same out-of-bounds bug onto Lakebase and Neon1 distinct publisher
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
One byline, but it shows its work
Everything traces to the person who found the bug, writing on his own dev.to account. What lifts it above assertion is that he quotes the artefacts: the bare new GetCollection() in Address.php, the guarded POST in Store.php, the provider's setParameter('store', $storeId), a commit hash and a version tag anyone can check against the public repository. What is absent is any second voice — CoopCycle has said nothing, there is no advisory, the CVE is still only requested — and the passage that would have demonstrated exploitation stops mid-sentence.
Patch exists; upgrade footprint uncounted
There is exactly one hard uptake artefact: a fixed commit tagged v5.6.0 within five minutes, so co-ops have something to pull. Nobody counts the rest. How many CoopCycle deployments run, how many are the multi-co-op shared instances that make this cross-tenant rather than merely sloppy, and how many have moved to v5.6.0 — none of that appears, and a silent fix gives operators no nudge to look.
Mild: reach asserted, severity self-discounted
This write-up polices itself more than most disclosures do — the 6.5 is labelled as the author's own, the missing vendor rating is flagged in the same breath, and the payoff is a framework lesson rather than a scare. The stretch sits in the reach: 'platform-wide' and 'the home addresses of people who ordered dinner' both presuppose the busy shared instance nobody has shown running, and the title's framing outruns the Medium score sitting underneath it.
Discloser's own byline, vendor silent
A disclosure post is also a portfolio entry, and this one carries a pending CVE request that a well-read narrative can only help. The incentive cuts both ways: the same motive that rewards a memorable framing also rewards quoting real code and dating the fix to the minute, both of which invite checking. The party with an interest in arguing the severity down — CoopCycle — never appears.
Mechanism solid, blast radius unmeasured
We would bet on the mechanism. A per-operation guard scheme with no default deny, one operation declared in a second entity file, and a provider that keys off the path id is a coherent and familiar way to lose a tenant boundary, and the quoted code hangs together. We would not bet on the consequences: whether real deployments were multi-tenant and publicly registrable, and whether anyone walked the endpoint, are unexamined on both sides.