<?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>The viewer checked the domain, the delete button didn't: cross-tenant quarantine IDOR in MailWatch (CVE-2026-85749)</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Sat, 07 Nov 2026 15:59:50 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/the-viewer-checked-the-domain-the-delete-button-didnt-cross-tenant-quarantine-idor-in-mailwatch-59om</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/the-viewer-checked-the-domain-the-delete-button-didnt-cross-tenant-quarantine-idor-in-mailwatch-59om</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; In &lt;a href="https://github.com/mailwatch/MailWatch" rel="noopener noreferrer"&gt;MailWatch&lt;/a&gt;, the web front end for MailScanner, the quarantine action endpoint looked a message up by its id alone. The message viewer and the quarantine listings add the caller's domain scope to the same lookup. In my local test on v1.2.26, a regular user of one domain got "not found" when viewing another domain's quarantined message, then deleted it through &lt;code&gt;quarantine_action.php&lt;/code&gt; with their own session and token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; per the advisory, any authenticated user could release to the inbox or permanently delete quarantined mail belonging to other users or domains. Rated &lt;strong&gt;High 8.1&lt;/strong&gt; (&lt;code&gt;CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H&lt;/code&gt;). CWE-639 and CWE-566.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed in 1.2.27&lt;/strong&gt;, advisory &lt;a href="https://github.com/mailwatch/MailWatch/security/advisories/GHSA-w7v5-7g6w-87r4" rel="noopener noreferrer"&gt;GHSA-w7v5-7g6w-87r4&lt;/a&gt;, credited to me as reporter. &lt;a href="https://www.cve.org/CVERecord?id=CVE-2026-85749" rel="noopener noreferrer"&gt;CVE-2026-85749&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;MailWatch's README describes it as a web-based front end to MailScanner. MailScanner holds back mail it flags as spam, a virus or dangerous content, and MailWatch is where people look at that quarantine and decide what to release or delete. One install can serve many users and domains, each with a role: user, domain admin, host, or administrator.&lt;/p&gt;

&lt;p&gt;Quarantine is a security control. Releasing a message delivers something the filter stopped. Deleting one removes it for good.&lt;/p&gt;

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

&lt;p&gt;At login MailWatch builds a SQL condition for the caller's role and stores it in the session as &lt;code&gt;$_SESSION['global_filter']&lt;/code&gt;. For a regular user it limits rows to that user's own addresses. Only an administrator gets everything.&lt;/p&gt;

&lt;p&gt;The read paths use it. This is the message viewer, &lt;code&gt;mailscanner/viewmail.php&lt;/code&gt; at v1.2.26, &lt;a href="https://github.com/mailwatch/MailWatch/blob/v1.2.26/mailscanner/viewmail.php#L57" rel="noopener noreferrer"&gt;line 57&lt;/a&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;$sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SELECT * FROM maillog WHERE id='"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$message_id&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"' AND "&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$_SESSION&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'global_filter'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A message outside your scope returns no row, and the viewer says it was not found.&lt;/p&gt;

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

&lt;p&gt;The release and delete actions go through &lt;code&gt;mailscanner/quarantine_action.php&lt;/code&gt;. At v1.2.26, &lt;a href="https://github.com/mailwatch/MailWatch/blob/v1.2.26/mailscanner/quarantine_action.php#L82-L87" rel="noopener noreferrer"&gt;lines 82-87&lt;/a&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;$id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deepSanitizeInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_GET&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'url'&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="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;validateInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'msgid'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quarantine_list_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&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;&lt;code&gt;quarantine_list_items()&lt;/code&gt; in &lt;code&gt;mailscanner/functions.php&lt;/code&gt;, &lt;a href="https://github.com/mailwatch/MailWatch/blob/v1.2.26/mailscanner/functions.php#L3528-L3543" rel="noopener noreferrer"&gt;lines 3528-3543&lt;/a&gt;, selects by id and nothing else:&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="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;quarantine_list_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$msgid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$rpc_only&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"
SELECT
  hostname,
  DATE_FORMAT(date,'%Y%m%d') AS date,
  id,
  to_address,
  ...
 FROM
  maillog
 WHERE
  id = '&lt;/span&gt;&lt;span class="nv"&gt;$msgid&lt;/span&gt;&lt;span class="s2"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint checks that you are logged in, and each action checks your CSRF token. Neither tells it whose message the id belongs to. So the action ran on whatever message the id named.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The viewer and the delete button asked the database for the same message. Only the viewer asked "and is it yours?"&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;I ran MailWatch v1.2.26 locally in Docker with synthetic data only: &lt;code&gt;php:8.2-apache&lt;/code&gt;, and &lt;code&gt;mariadb:10.6&lt;/code&gt; in place of the project's &lt;code&gt;mysql:5.7&lt;/code&gt; with the same schema. MailScanner itself was not installed, so a minimal config file stood in for it. Two domains, one regular user each: &lt;code&gt;userb@b.example&lt;/code&gt; (the caller) and &lt;code&gt;victimuser@a.example&lt;/code&gt; (the owner). One quarantined message addressed to the owner, id &lt;code&gt;9XyZaBcDeFgHij&lt;/code&gt;, with its file on disk. Both users logged in normally and used their own session and CSRF token.&lt;/p&gt;

&lt;p&gt;These are the recorded requests and results, as the caller:&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 viewer refuses the other domain&lt;span class="s1"&gt;'s message
&lt;/span&gt;&lt;span class="gp"&gt;GET /viewmail.php?id=9XyZaBcDeFgHij&amp;amp;token=&amp;lt;caller's token&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;200, "Message '&lt;/span&gt;9XyZaBcDeFgHij&lt;span class="s1"&gt;' not found"
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;before
&lt;span class="go"&gt;maillog: 9XyZaBcDeFgHij | a.example | quarantined=1
quarantine/20260706/9XyZaBcDeFgHij/message.eml present

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;the action, same &lt;span class="nb"&gt;id&lt;/span&gt;, same token
&lt;span class="gp"&gt;GET /quarantine_action.php?token=&amp;lt;caller's token&amp;gt;&lt;/span&gt;&amp;amp;id&lt;span class="o"&gt;=&lt;/span&gt;9XyZaBcDeFgHij&amp;amp;action&lt;span class="o"&gt;=&lt;/span&gt;delete&amp;amp;html&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&amp;amp;confirm&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="gp"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;200, &lt;span class="s2"&gt;"Delete: deleted file /var/spool/MailScanner/quarantine/20260706/9XyZaBcDeFgHij/message.eml"&lt;/span&gt;
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;after
&lt;span class="go"&gt;maillog: 9XyZaBcDeFgHij | a.example | quarantined=NULL
quarantine/20260706/9XyZaBcDeFgHij/   (empty)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As a positive control, the owner opened the same message in the viewer and got the full page with its own delete link.&lt;/p&gt;

&lt;p&gt;Honest boundary: delete is what the run demonstrated over HTTP. Release goes through the same lookup, so by the source it had the same gap, but I did not run it. One message and two regular users, not domain admins or the host role. The test used MariaDB rather than MySQL and a stub MailScanner config. I did not re-run the test on a fixed version.&lt;/p&gt;

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

&lt;p&gt;Shipped in &lt;strong&gt;1.2.27&lt;/strong&gt; (&lt;a href="https://github.com/mailwatch/MailWatch/releases/tag/v1.2.27" rel="noopener noreferrer"&gt;release&lt;/a&gt;, 2026-07-30), merge commit &lt;a href="https://github.com/mailwatch/MailWatch/commit/bc04f25eb0cec028cbd4ded9c049cfc6f663a15f" rel="noopener noreferrer"&gt;&lt;code&gt;bc04f25&lt;/code&gt;&lt;/a&gt;. The release notes put it as "Apply session filter consistently in quarantine operations". &lt;code&gt;quarantine_list_items()&lt;/code&gt; now takes the filter and adds it to the query, at v1.2.27, &lt;a href="https://github.com/mailwatch/MailWatch/blob/v1.2.27/mailscanner/functions.php#L3529-L3549" rel="noopener noreferrer"&gt;lines 3529-3549&lt;/a&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="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;quarantine_list_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$msgid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$rpc_only&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$global_filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&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="no"&gt;WHERE&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'$msgid'&lt;/span&gt;&lt;span class="s2"&gt;";
    if (null !== &lt;/span&gt;&lt;span class="nv"&gt;$global_filter&lt;/span&gt;&lt;span class="s2"&gt;) {
        &lt;/span&gt;&lt;span class="nv"&gt;$sql&lt;/span&gt;&lt;span class="s2"&gt; .= "&lt;/span&gt;
 &lt;span class="k"&gt;AND&lt;/span&gt;
 &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$global_filter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;";
    }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;quarantine_action.php&lt;/code&gt; passes the session's filter to the lookup and to both actions:&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;$list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quarantine_list_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$_SESSION&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'global_filter'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="mf"&gt;...&lt;/span&gt;
&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quarantine_release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$_SESSION&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'global_filter'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="mf"&gt;...&lt;/span&gt;
&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quarantine_delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$_SESSION&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'global_filter'&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;quarantine_delete()&lt;/code&gt; and &lt;code&gt;quarantine_release()&lt;/code&gt; also re-run the lookup with the filter before they act, so a message outside your scope is not found at all. The same commit touched &lt;code&gt;detail.php&lt;/code&gt; and &lt;code&gt;do_message_ops.php&lt;/code&gt;. The advisory credits endelwar as remediation developer.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;If a lookup is shared, put the scope inside it.&lt;/strong&gt; The viewer added the session filter to its own query. The action called a helper that had no way to receive one. The fix gave the helper a filter parameter, so every caller that passes it gets the same answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Not found" on read should mean "not found" on write.&lt;/strong&gt; If a user cannot see a record, every action that names the record should fail the same way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A CSRF token proves the request came from your session. It says nothing about which records your session may touch.&lt;/strong&gt; Here both checks were present, and neither was an ownership check.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Disclosure timeline (UTC)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-05&lt;/strong&gt;: found during a source review of the quarantine code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-06&lt;/strong&gt;: reproduced locally on v1.2.26 (synthetic data).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-18&lt;/strong&gt;: reported privately through GitHub's private vulnerability reporting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-30&lt;/strong&gt;: v1.2.27 released with the fix, and advisory &lt;a href="https://github.com/mailwatch/MailWatch/security/advisories/GHSA-w7v5-7g6w-87r4" rel="noopener noreferrer"&gt;GHSA-w7v5-7g6w-87r4&lt;/a&gt; published by the maintainers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-10-28&lt;/strong&gt;: &lt;a href="https://www.cve.org/CVERecord?id=CVE-2026-85749" rel="noopener noreferrer"&gt;CVE-2026-85749&lt;/a&gt; published to the CVE List.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;If you run MailWatch, upgrade to the latest release. The fix for this issue shipped in 1.2.27, and 1.2.28 and 1.2.29 (November 2026) fix further security issues, including GHSA-fx6j-399x-49w7.&lt;/p&gt;

&lt;p&gt;If you maintain a multi-tenant app, try this: take a record a user can't see, and send its id to every action endpoint, not just the view. Each one should fail the way the view does.&lt;/p&gt;

&lt;p&gt;Reported by me. The advisory credits endelwar as remediation developer. Comments and corrections welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;MailWatch security advisory GHSA-w7v5-7g6w-87r4, "IDOR lets any authenticated user release or delete any other tenant's quarantined mail", published 2026-07-30. &lt;a href="https://github.com/mailwatch/MailWatch/security/advisories/GHSA-w7v5-7g6w-87r4" rel="noopener noreferrer"&gt;https://github.com/mailwatch/MailWatch/security/advisories/GHSA-w7v5-7g6w-87r4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CVE-2026-85749. &lt;a href="https://www.cve.org/CVERecord?id=CVE-2026-85749" rel="noopener noreferrer"&gt;https://www.cve.org/CVERecord?id=CVE-2026-85749&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Fix merge commit &lt;code&gt;bc04f25&lt;/code&gt;. &lt;a href="https://github.com/mailwatch/MailWatch/commit/bc04f25eb0cec028cbd4ded9c049cfc6f663a15f" rel="noopener noreferrer"&gt;https://github.com/mailwatch/MailWatch/commit/bc04f25eb0cec028cbd4ded9c049cfc6f663a15f&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MailWatch release notes, v1.2.27. &lt;a href="https://github.com/mailwatch/MailWatch/releases/tag/v1.2.27" rel="noopener noreferrer"&gt;https://github.com/mailwatch/MailWatch/releases/tag/v1.2.27&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mailscanner/quarantine_action.php&lt;/code&gt; at v1.2.26 (before the fix). &lt;a href="https://github.com/mailwatch/MailWatch/blob/v1.2.26/mailscanner/quarantine_action.php" rel="noopener noreferrer"&gt;https://github.com/mailwatch/MailWatch/blob/v1.2.26/mailscanner/quarantine_action.php&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mailscanner/functions.php&lt;/code&gt; at v1.2.27 (after the fix). &lt;a href="https://github.com/mailwatch/MailWatch/blob/v1.2.27/mailscanner/functions.php" rel="noopener noreferrer"&gt;https://github.com/mailwatch/MailWatch/blob/v1.2.27/mailscanner/functions.php&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MITRE CWE-639, Authorization Bypass Through User-Controlled Key. &lt;a href="https://cwe.mitre.org/data/definitions/639.html" rel="noopener noreferrer"&gt;https://cwe.mitre.org/data/definitions/639.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq). 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>appsec</category>
      <category>email</category>
    </item>
    <item>
      <title>One Range header skipped the firmware assignment check: CVE-2026-16454 in Eclipse hawkBit</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Sat, 07 Nov 2026 15:41:09 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/one-range-header-skipped-the-firmware-assignment-check-cve-2026-16454-in-eclipse-hawkbit-247n</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/one-range-header-skipped-the-firmware-assignment-check-cve-2026-16454-in-eclipse-hawkbit-247n</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; In &lt;a href="https://github.com/eclipse-hawkbit/hawkbit" rel="noopener noreferrer"&gt;Eclipse hawkBit&lt;/a&gt;, the device API's artifact download ran its assignment check only when the request had no &lt;code&gt;Range&lt;/code&gt; header. A device that added &lt;code&gt;Range: bytes=0-&lt;/code&gt; got the file anyway. The advisory lists 1.0.3 and prior as affected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; per the advisory, any authenticated device could download firmware artifacts assigned to other devices in the same tenant. It is not an authentication bypass, and it stays inside the tenant. Rated &lt;strong&gt;Moderate 4.3&lt;/strong&gt; (&lt;code&gt;CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N&lt;/code&gt;). CWE-862 and CWE-284.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed in 1.0.4&lt;/strong&gt;, advisory &lt;a href="https://github.com/eclipse-hawkbit/hawkbit/security/advisories/GHSA-92r3-p8c2-3fpx" rel="noopener noreferrer"&gt;GHSA-92r3-p8c2-3fpx&lt;/a&gt;, credited to me as reporter. &lt;a href="https://www.cve.org/CVERecord?id=CVE-2026-16454" rel="noopener noreferrer"&gt;CVE-2026-16454&lt;/a&gt;, assigned by the Eclipse CNA, lists me as finder.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;hawkBit's README describes it as a domain-independent back end for rolling out software updates to constrained edge devices, controllers and gateways. An operator assigns a distribution set to a device, and that device downloads the artifacts in it.&lt;/p&gt;

&lt;p&gt;The advisory states the boundary plainly: a device should be restricted to the firmware artifacts explicitly assigned to it. This bug removed that boundary for one kind of request. The credentials still had to be valid, and per the advisory the tenant limit held. What failed was the separation between devices inside one tenant.&lt;/p&gt;

&lt;p&gt;A device also needs a module id and an artifact filename to ask for. The advisory and the CVE record both describe a related metadata endpoint: given a module id, it returned the artifact filenames, sizes and hashes, including for a module not assigned to the device.&lt;/p&gt;

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

&lt;p&gt;DDI (Direct Device Integration) is the API devices speak. A device can authenticate with its own token, sent as &lt;code&gt;Authorization: TargetToken &amp;lt;token&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The per-device check is &lt;code&gt;JpaControllerManagement.getActionForDownloadByTargetAndSoftwareModule()&lt;/code&gt;. It takes the device's active actions and looks for one whose distribution set contains the requested module. At tag 1.0.3, &lt;a href="https://github.com/eclipse-hawkbit/hawkbit/blob/1.0.3/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java#L365-L374" rel="noopener noreferrer"&gt;lines 365-374&lt;/a&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;actionRepository&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ActionSpecifications&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;byTargetControllerIdAndActive&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;controllerId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isCancelingOrCanceled&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDistributionSet&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getModules&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;anyMatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;moduleId&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;cast&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findFirst&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SoftwareModuleNotAssignedToTargetException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;moduleId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;controllerId&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That exception extends &lt;code&gt;EntityNotFoundException&lt;/code&gt; and comes back as an HTTP 404. The check works. The question is when it runs.&lt;/p&gt;

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

&lt;p&gt;This is &lt;code&gt;DdiRootController.downloadArtifact()&lt;/code&gt; at tag 1.0.3, in &lt;code&gt;hawkbit-ddi/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java&lt;/code&gt;, &lt;a href="https://github.com/eclipse-hawkbit/hawkbit/blob/1.0.3/hawkbit-ddi/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java#L181-L184" rel="noopener noreferrer"&gt;lines 181-184&lt;/a&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="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ActionStatus&lt;/span&gt; &lt;span class="n"&gt;actionStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RequestResponseContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getHttpServletRequest&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Range"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;logDownload&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RequestResponseContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getHttpServletRequest&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// range request - could have too many - so doesn't check action, don't log action status, and don't publish events&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FileStreamingUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;writeFileResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFilename&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCreatedAt&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
        &lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;logDownload()&lt;/code&gt; holds the only non-test call to the check in the 1.0.3 tree. It looks up the action, then writes an ActionStatus row, at &lt;a href="https://github.com/eclipse-hawkbit/hawkbit/blob/1.0.3/hawkbit-ddi/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java#L586-L594" rel="noopener noreferrer"&gt;lines 586-594&lt;/a&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="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;ActionStatus&lt;/span&gt; &lt;span class="nf"&gt;logDownload&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;HttpServletRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Target&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Action&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;controllerManagement&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getActionForDownloadByTargetAndSoftwareModule&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getControllerId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;module&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;controllerManagement&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addInformationalActionStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;ActionStatusCreate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;actionId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DOWNLOAD&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RepositoryConstants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SERVER_MESSAGE_PREFIX&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"Target downloads "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRequestURI&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So with a &lt;code&gt;Range&lt;/code&gt; header the ternary evaluates to &lt;code&gt;null&lt;/code&gt;, nothing calls the check, and the next statement streams the file.&lt;/p&gt;

&lt;p&gt;The lookup before it doesn't cover the gap. &lt;code&gt;getSoftwareModule()&lt;/code&gt; is &lt;code&gt;softwareModuleRepository.findById(id)&lt;/code&gt;, and it takes no device argument.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzc0rh27bw4agidncc7jt.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzc0rh27bw4agidncc7jt.gif" alt="Device B gets a 404 without a Range header and the artifact with one. In 1.0.4 the assignment check runs before the Range ternary." width="800" height="648"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The check was skipped on purpose and in plain sight, as a load problem. It was also the only thing tying a device to the firmware it asked for.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Until January 2025 the helper was called &lt;code&gt;checkAndLogDownload()&lt;/code&gt;, and it ran on every download (&lt;a href="https://github.com/eclipse-hawkbit/hawkbit/blob/0.7.0/hawkbit-ddi/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java#L598-L601" rel="noopener noreferrer"&gt;tag 0.7.0, lines 192 and 598&lt;/a&gt;). &lt;a href="https://github.com/eclipse-hawkbit/hawkbit/issues/1249" rel="noopener noreferrer"&gt;Issue #1249&lt;/a&gt; reported what that cost streaming installers. RAUC issues Range requests of about 128 KB, so a 150 MB bundle produced more than 1000 ActionStatus rows and hit the quota of 1000 per action.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/eclipse-hawkbit/hawkbit/pull/2261" rel="noopener noreferrer"&gt;PR #2261&lt;/a&gt; dealt with that. Its title is about action statuses, but its body also lists too many action checks as a reason and says "this PR skip action existing check and skip logging". The same PR renamed the helper to &lt;code&gt;logDownload()&lt;/code&gt;. &lt;a href="https://github.com/eclipse-hawkbit/hawkbit/pull/2262" rel="noopener noreferrer"&gt;PR #2262&lt;/a&gt;, merged 28 minutes later, added the ternary and the comment shown above. Going by the source at each tag, 0.8.0 (March 2025) is the first tag that carries the skip.&lt;/p&gt;

&lt;p&gt;So nothing was hidden. The skip is in the PR text and in the code. But neither the PR nor the issue calls that lookup an authorization check. It was described by what it cost, and described that way, skipping it looks like a sensible fix for a real problem. The proof of concept below shows nothing else on that path stood in for it.&lt;/p&gt;

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

&lt;p&gt;Everything ran locally in Docker with synthetic data. No hosted or third-party system was involved. The target was &lt;code&gt;hawkbit/hawkbit-update-server:1.0.3&lt;/code&gt; on port 8585, default tenant &lt;code&gt;DEFAULT&lt;/code&gt;, with target-token authentication enabled (&lt;code&gt;authentication.targettoken.enabled=true&lt;/code&gt;; the update server's &lt;code&gt;application.properties&lt;/code&gt; ships it turned off). deviceA and deviceB each had their own token. Software module 1 holds &lt;code&gt;fw.bin&lt;/code&gt;, whose content is a short marker string, &lt;code&gt;SECRET_FIRMWARE_FOR_DEVICE_A_ONLY_…&lt;/code&gt;, in a distribution set assigned to deviceA only.&lt;/p&gt;

&lt;p&gt;The run recorded the path, the token, the status and the body, not a shell transcript, so read these commands as the form of the requests. Both authenticate as deviceB with deviceB's own token:&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;-i&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: TargetToken &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN_B&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://localhost:8585/DEFAULT/controller/v1/deviceB/softwaremodules/1/artifacts/fw.bin

curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: TargetToken &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN_B&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Range: bytes=0-"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://localhost:8585/DEFAULT/controller/v1/deviceB/softwaremodules/1/artifacts/fw.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What was recorded:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;deviceB, no &lt;code&gt;Range&lt;/code&gt;: &lt;strong&gt;404&lt;/strong&gt;, &lt;code&gt;SoftwareModuleNotAssignedToTargetException&lt;/code&gt;. Correctly denied.&lt;/li&gt;
&lt;li&gt;deviceB, &lt;code&gt;Range: bytes=0-&lt;/code&gt;: &lt;strong&gt;206&lt;/strong&gt;, body &lt;code&gt;SECRET_FIRMWARE_FOR_DEVICE_A_ONLY_…&lt;/code&gt;, the full artifact.&lt;/li&gt;
&lt;li&gt;deviceA (assigned), no &lt;code&gt;Range&lt;/code&gt;: &lt;strong&gt;200&lt;/strong&gt;, the same firmware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only difference between the 404 and the 206 is the header.&lt;/p&gt;

&lt;p&gt;Honest boundary: this is one artifact, two devices and one tenant. I did not test across tenants, so the tenant limit is the advisory's statement, not my result. The marker stands in for firmware, so this shows the boundary was crossed and nothing about what a real image contains.&lt;/p&gt;

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

&lt;p&gt;Shipped in &lt;strong&gt;1.0.4&lt;/strong&gt; (&lt;a href="https://github.com/eclipse-hawkbit/hawkbit/releases/tag/1.0.4" rel="noopener noreferrer"&gt;Release&lt;/a&gt;, 2026-06-26). This is &lt;code&gt;DdiRootController.java&lt;/code&gt; at tag 1.0.4, &lt;a href="https://github.com/eclipse-hawkbit/hawkbit/blob/1.0.4/hawkbit-ddi/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java#L192-L197" rel="noopener noreferrer"&gt;lines 192-197&lt;/a&gt;, from commit &lt;a href="https://github.com/eclipse-hawkbit/hawkbit/commit/6cb34fd77f6a" rel="noopener noreferrer"&gt;&lt;code&gt;6cb34fd77f6a&lt;/code&gt;&lt;/a&gt; on the 1.0 branch and &lt;a href="https://github.com/eclipse-hawkbit/hawkbit/commit/83fee2ba98fb" rel="noopener noreferrer"&gt;&lt;code&gt;83fee2ba98fb&lt;/code&gt;&lt;/a&gt; on master:&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;// check action assigned to device&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Action&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requireNonNull&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;controllerManagement&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getActionForDownloadByTargetAndSoftwareModule&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getControllerId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ActionStatus&lt;/span&gt; &lt;span class="n"&gt;actionStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getHttpServletRequest&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Range"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;logDownload&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// range request - could have too many - so doesn't check action, don't log action status, and don't publish events&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check now runs before the Range ternary, and &lt;code&gt;logDownload()&lt;/code&gt; only receives the action. Range requests still skip the status row, so the action-status quota problem from issue #1249 stays fixed. The comment on the &lt;code&gt;: null&lt;/code&gt; line was left as it was, and at 1.0.4 it is out of date.&lt;/p&gt;

&lt;p&gt;Two related changes shipped with it. The &lt;code&gt;.MD5SUM&lt;/code&gt; path, which was never gated on &lt;code&gt;Range&lt;/code&gt;, now makes the same explicit check. And the artifact metadata listing, &lt;code&gt;getSoftwareModulesArtifacts()&lt;/code&gt;, gained an assignment check (&lt;a href="https://github.com/eclipse-hawkbit/hawkbit/commit/914b8b406bd7" rel="noopener noreferrer"&gt;&lt;code&gt;914b8b406bd7&lt;/code&gt;&lt;/a&gt; on the 1.0 branch, &lt;a href="https://github.com/eclipse-hawkbit/hawkbit/commit/9dc7d2120a05" rel="noopener noreferrer"&gt;&lt;code&gt;9dc7d2120a05&lt;/code&gt;&lt;/a&gt; on master), with a fallback so a device can still list modules in its installed action.&lt;/p&gt;

&lt;p&gt;The maintainer asked me to confirm the fix, so on 18 June I built master at commit &lt;a href="https://github.com/eclipse-hawkbit/hawkbit/commit/6e510bdc37e26df14a66b0af4c7fd60b08cbfc1e" rel="noopener noreferrer"&gt;&lt;code&gt;6e510bdc&lt;/code&gt;&lt;/a&gt; and re-ran the PoC locally, again with synthetic data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;deviceB, &lt;code&gt;Range: bytes=0-&lt;/code&gt;: &lt;strong&gt;404&lt;/strong&gt;, no firmware bytes. This was the 206 on 1.0.3.&lt;/li&gt;
&lt;li&gt;deviceB, no &lt;code&gt;Range&lt;/code&gt;: &lt;strong&gt;404&lt;/strong&gt;, unchanged.&lt;/li&gt;
&lt;li&gt;deviceA, no &lt;code&gt;Range&lt;/code&gt;: &lt;strong&gt;200&lt;/strong&gt; with the firmware, unchanged.&lt;/li&gt;
&lt;li&gt;deviceA, &lt;code&gt;Range: bytes=0-&lt;/code&gt;: &lt;strong&gt;206&lt;/strong&gt; with the firmware. I added this one in the re-run; it was not run on 1.0.3.&lt;/li&gt;
&lt;li&gt;deviceB, metadata listing for module 1: &lt;strong&gt;200&lt;/strong&gt; with the filename, size and hashes. Still open on that build.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two deviceA results show the new 404 is the check firing, not a broken route. I flagged the last result in my reply, and the listing's own check landed on master the same day.&lt;/p&gt;

&lt;p&gt;I did not re-run on the 1.0.4 build. &lt;code&gt;downloadArtifact()&lt;/code&gt; at 1.0.4 is the same code as at that commit. The metadata check is in the 1.0.4 source at lines 132-147; I have not run it.&lt;/p&gt;

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

&lt;p&gt;The advisory rates this &lt;strong&gt;Moderate, CVSS 4.3&lt;/strong&gt;, with confidentiality Low. Its stated reasons are that access stays inside the authenticated tenant and that production firmware is by design not expected to hold highly sensitive data. I filed it at 6.5 with confidentiality High. That is the only difference between the two vectors, and 4.3 is the published rating.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;When a performance change removes a lookup, ask whether that lookup was the authorization decision.&lt;/strong&gt; PR #2261 named the action check and skipped it for load reasons. In hawkBit that one lookup was the whole per-device check on the download path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A good name did not save it.&lt;/strong&gt; The helper was &lt;code&gt;checkAndLogDownload()&lt;/code&gt;, with the check in its name, and the PR that skipped the check also renamed it. A separately named assertion could have been skipped by the same decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getSoftwareModule()&lt;/code&gt; answered whether the module exists, not whether this device may have it.&lt;/strong&gt; Passing that lookup told the download path nothing about the device.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-15&lt;/strong&gt;: found during a source review of &lt;code&gt;DdiRootController&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-16&lt;/strong&gt;: reproduced locally on the &lt;code&gt;hawkbit-update-server:1.0.3&lt;/code&gt; image and reported privately through GitHub's private vulnerability reporting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-17&lt;/strong&gt;: fix landed on master.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-18&lt;/strong&gt;: the maintainer asked me to confirm the fix. I built master at &lt;code&gt;6e510bdc&lt;/code&gt; and re-ran locally. The Range bypass did not reproduce. The same run found the metadata listing still open on that build. I flagged that in my reply, and the check for it landed on master that day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-25&lt;/strong&gt;: 1.0.4 tagged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-26&lt;/strong&gt;: 1.0.4 Release published, then advisory &lt;a href="https://github.com/eclipse-hawkbit/hawkbit/security/advisories/GHSA-92r3-p8c2-3fpx" rel="noopener noreferrer"&gt;GHSA-92r3-p8c2-3fpx&lt;/a&gt; published by the maintainer, crediting me as reporter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-21&lt;/strong&gt;: &lt;a href="https://www.cve.org/CVERecord?id=CVE-2026-16454" rel="noopener noreferrer"&gt;CVE-2026-16454&lt;/a&gt; published by the Eclipse CNA: affected through 1.0.3, unaffected 1.0.4, with me listed as finder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ten days from report to published advisory, with the maintainer asking for the fix to be checked first. That is a well-run disclosure process.&lt;/p&gt;

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

&lt;p&gt;If you run Eclipse hawkBit, upgrade to &lt;strong&gt;1.0.4 or later&lt;/strong&gt;. From 1.1.0, a setting called &lt;code&gt;hawkbit.ddi.skip-artifact-access-check.tenants&lt;/code&gt; can turn this check off per tenant, or for all tenants with &lt;code&gt;*&lt;/code&gt;. It is empty by default, so the check stays on. Leave it empty unless you mean to turn the check off.&lt;/p&gt;

&lt;p&gt;Reported by me. The advisory credits avgustinmm as remediation developer. Comments and corrections welcome.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq). 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>Allowed to view the Location, handed the Device: a GraphQL permission gap in Nautobot (GHSA-mfwj-pjgx-22v2)</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Sat, 07 Nov 2026 14:38:02 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/allowed-to-view-the-location-handed-the-device-a-graphql-permission-gap-in-nautobot-1202</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/allowed-to-view-the-location-handed-the-device-a-graphql-permission-gap-in-nautobot-1202</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/nautobot/nautobot" rel="noopener noreferrer"&gt;Nautobot&lt;/a&gt;'s GraphQL API enforced object permissions at the root of a query but not when a query traversed into related models. In my local test on v3.1.4, an account with view permission on Location and no device permission got a 403 from the REST device list and an empty list from a top-level &lt;code&gt;devices&lt;/code&gt; query. A nested query through &lt;code&gt;location.devices&lt;/code&gt; returned the device's &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;name&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; per the advisory, users with access to some models but not others could retrieve fields from models they were not meant to reach. It needs a valid account, and it is read-only. Rated &lt;strong&gt;Moderate 6.5&lt;/strong&gt; (&lt;code&gt;CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N&lt;/code&gt;). CWE-200 and CWE-285.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed in 2.4.38 and 3.2.0&lt;/strong&gt;, advisory &lt;a href="https://github.com/nautobot/nautobot/security/advisories/GHSA-mfwj-pjgx-22v2" rel="noopener noreferrer"&gt;GHSA-mfwj-pjgx-22v2&lt;/a&gt;. The advisory reads: "Credit for this discovery goes to Habibullo Izzatilloyev. It was also reported by Santosh Kumar Puppala."&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Nautobot describes itself as a Network Source of Truth and Network Automation Platform. Its core models cover devices and racks, IP networks and addresses, circuits and cables, and it offers GraphQL alongside REST. Object permissions decide which of those records a user may see.&lt;/p&gt;

&lt;p&gt;GraphQL lets the client choose the path to the data. A check at the root of a query covers the objects that root returns. It says nothing about the objects the query reaches next, unless each step through a relation checks again.&lt;/p&gt;

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

&lt;p&gt;Nautobot is built on Django and exposes the models registered for GraphQL through graphene-django. Access is governed by &lt;code&gt;ObjectPermission&lt;/code&gt;. A permission grants actions on object types, and it can carry constraints, for example view devices at one location. There are no deny rules. The account in this test simply had no grant on Device.&lt;/p&gt;

&lt;p&gt;REST views, UI views and the root GraphQL resolvers filter their querysets through one method, &lt;code&gt;restrict(user, action)&lt;/code&gt;. For a user with no grant on the model, it returns an empty queryset.&lt;/p&gt;

&lt;p&gt;The request that matters here is one nested query, shown as I ran it:&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;POST /api/graphql/
&lt;/span&gt;&lt;span class="gp"&gt;{ location(id:"&amp;lt;LOC-A uuid&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"){ name devices { id name } } }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asks for a parent the account may view, and a relation the account has no grant for.&lt;/p&gt;

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

&lt;p&gt;The resolvers are generated in &lt;code&gt;nautobot/core/graphql/generators.py&lt;/code&gt;. The code below is from tag v3.1.4 (commit &lt;code&gt;94012e0&lt;/code&gt;). The two root resolvers restrict the queryset:&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;# nautobot/core/graphql/generators.py  (v3.1.4)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_single_item_resolver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resolver_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;single_resolver&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;info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;obj_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kwargs&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;id&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;obj_id&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;model&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queryset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;restrict&lt;/span&gt;&lt;span class="sh"&gt;"&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;queryset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restrict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&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;view&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;def&lt;/span&gt; &lt;span class="nf"&gt;generate_list_resolver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resolver_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_resolver&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;info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="bp"&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;model&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queryset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;restrict&lt;/span&gt;&lt;span class="sh"&gt;"&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;queryset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restrict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&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;view&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resolver behind list-style relations does not:&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;# nautobot/core/graphql/generators.py  (v3.1.4)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_filter_resolver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resolver_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;filterset_class&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;schema_type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filterset_class&lt;/span&gt;

    &lt;span class="nd"&gt;@gql_optimizer.resolver_hints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;field_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_filter&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;info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;field&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;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field_name&lt;/span&gt;&lt;span class="p"&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;filterset_class&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;kwargs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;field&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;matching_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_gql_filter_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cache_key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;field&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;matching_ids&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;nautobot/core/graphql/schema.py&lt;/code&gt; installs &lt;code&gt;generate_filter_resolver&lt;/code&gt; for many-to-many, reverse foreign-key and generic relation fields whose target model has a registered GraphQL type. It skips reverse one-to-ones. &lt;code&gt;Device.location&lt;/code&gt; has &lt;code&gt;related_name="devices"&lt;/code&gt;, so &lt;code&gt;location.devices&lt;/code&gt; goes through this resolver and returns &lt;code&gt;location.devices.all()&lt;/code&gt;, with no permission check on the devices.&lt;/p&gt;

&lt;p&gt;That is the path I tested, and it was not the only one. The fix's changelog lists these as well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;rel_*&lt;/code&gt; fields that traverse Relationship associations&lt;/li&gt;
&lt;li&gt;forward foreign keys, such as &lt;code&gt;racks { location }&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;reverse relations, including &lt;code&gt;*_set&lt;/code&gt; fields and reverse one-to-ones such as &lt;code&gt;module_bay { installed_module }&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;custom and property-backed accessors: a Device's &lt;code&gt;all_interfaces&lt;/code&gt;, cable and connection peers, dynamic group memberships, assigned tags, and the legacy &lt;code&gt;location&lt;/code&gt; accessors on &lt;code&gt;Prefix&lt;/code&gt; and &lt;code&gt;VLAN&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some of these were generated and some were hand-written, for example &lt;code&gt;resolve_dynamic_groups&lt;/code&gt; on the Device GraphQL type (class &lt;code&gt;DeviceType&lt;/code&gt; in &lt;code&gt;nautobot/dcim/graphql/types.py&lt;/code&gt;) at v3.1.4. So this was not one forgotten call. The advisory's description is the accurate one: permissions were enforced at the root of the query and not when traversing into related models.&lt;/p&gt;

&lt;p&gt;REST had the same kind of gap in a different place. Its &lt;code&gt;?depth&lt;/code&gt; parameter also traversed to related objects without enforcing permissions on them, and that was fixed in the same releases under a separate advisory, &lt;a href="https://github.com/nautobot/nautobot/security/advisories/GHSA-h8rv-c7c8-cvmx" rel="noopener noreferrer"&gt;GHSA-h8rv-c7c8-cvmx&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1a95rh6v9mi13sfzzwk5.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1a95rh6v9mi13sfzzwk5.gif" alt="Sequence diagram: REST and top-level GraphQL refuse Account A; the nested location.devices query returns DEV-A" 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;Nautobot asked whether the user could see an object at the root of the query, and did not ask again when the query stepped into a related model. The client chooses the steps.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;I ran &lt;code&gt;networktocode/nautobot:3.1.4&lt;/code&gt; locally in Docker, with synthetic data only. No hosted or third-party system was touched. Account A holds exactly one &lt;code&gt;ObjectPermission&lt;/code&gt;: &lt;code&gt;dcim | location | view&lt;/code&gt;. It has no device permission of any kind. The seed is one Location, &lt;code&gt;LOC-A&lt;/code&gt;, and one Device, &lt;code&gt;DEV-A&lt;/code&gt;, assigned to it. These are the recorded requests and responses, with the UUIDs replaced by placeholders:&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;&lt;span class="o"&gt;[&lt;/span&gt;1] negative control: REST device list &lt;span class="o"&gt;(&lt;/span&gt;Token A&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;GET /api/dcim/devices/?limit=50
&lt;/span&gt;&lt;span class="gp"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;403 &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"detail"&lt;/span&gt;:&lt;span class="s2"&gt;"You do not have permission to perform this action."&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;2] sanity check: top-level GraphQL &lt;span class="o"&gt;(&lt;/span&gt;Token A&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;POST /api/graphql/  {"query":"{ devices { id name } }"}
&lt;/span&gt;&lt;span class="gp"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"data"&lt;/span&gt;:&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"devices"&lt;/span&gt;:[]&lt;span class="o"&gt;}}&lt;/span&gt;
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;3] the bypass: nested GraphQL &lt;span class="o"&gt;(&lt;/span&gt;Token A&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;POST /api/graphql/  {"query":"{ location(id:\"&amp;lt;LOC-A uuid&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="o"&gt;){&lt;/span&gt; name devices &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;id &lt;/span&gt;name &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"}
&lt;/span&gt;&lt;span class="gp"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;{"&lt;/span&gt;data&lt;span class="s2"&gt;":{"&lt;/span&gt;location&lt;span class="s2"&gt;":{"&lt;/span&gt;name&lt;span class="s2"&gt;":"&lt;/span&gt;LOC-A&lt;span class="s2"&gt;","&lt;/span&gt;devices&lt;span class="s2"&gt;":[{"&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="s2"&gt;":"&lt;/span&gt;&amp;lt;DEV-A uuid&amp;gt;&lt;span class="s2"&gt;","&lt;/span&gt;name&lt;span class="s2"&gt;":"&lt;/span&gt;DEV-A&lt;span class="s2"&gt;"}]}}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proof is the split. In the same test run, the same token got a 403 from REST and an empty list from top-level GraphQL, and the nested query returned &lt;code&gt;DEV-A&lt;/code&gt;. An admin's REST query also returned &lt;code&gt;DEV-A&lt;/code&gt;, which confirms the device is real.&lt;/p&gt;

&lt;p&gt;From the source, the 403 and the empty list come from different places. REST's default permission class, &lt;code&gt;TokenPermissions&lt;/code&gt;, requires &lt;code&gt;dcim.view_device&lt;/code&gt; and refuses the request before the view restricts any queryset. The top-level GraphQL resolver calls &lt;code&gt;restrict()&lt;/code&gt;, which returns no rows for a user with no grant.&lt;/p&gt;

&lt;p&gt;Honest boundary: the recorded requests asked for &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;name&lt;/code&gt; on one seeded device, so that is all I can show. Serial numbers, IP addresses and interfaces were not part of them. I did not test a constrained device grant, the web UI or REST &lt;code&gt;?depth&lt;/code&gt;. The maintainers' new tests do cover constrained grants, for example a view grant on &lt;code&gt;Rack&lt;/code&gt; limited to one name.&lt;/p&gt;

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

&lt;p&gt;Shipped in &lt;strong&gt;3.2.0&lt;/strong&gt; (commit &lt;a href="https://github.com/nautobot/nautobot/commit/a05eb7d0c939dd4205aca839955e4b3ee5f32af4" rel="noopener noreferrer"&gt;&lt;code&gt;a05eb7d&lt;/code&gt;&lt;/a&gt;) and &lt;strong&gt;2.4.38&lt;/strong&gt; (commit &lt;a href="https://github.com/nautobot/nautobot/commit/52a17d8e4806b909d5de9f0a121c92c2bbc6d719" rel="noopener noreferrer"&gt;&lt;code&gt;52a17d8&lt;/code&gt;&lt;/a&gt;), both released 2026-07-27. My report suggested calling &lt;code&gt;restrict()&lt;/code&gt; in this resolver. That is not what shipped, and the maintainers' comment says why. The resolver as shipped in v3.2.0:&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;# nautobot/core/graphql/generators.py  (v3.2.0)
&lt;/span&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_filter&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;info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;field&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;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;related_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;

        &lt;span class="c1"&gt;# Object-permission enforcement: the related manager (`field`) is prefetched by the optimizer, so we
&lt;/span&gt;        &lt;span class="c1"&gt;# keep it intact and filter the loaded objects in Python against the user's permitted PKs, rather than
&lt;/span&gt;        &lt;span class="c1"&gt;# re-querying with `restrict()` (which would defeat the prefetch and cause N+1 queries).
&lt;/span&gt;        &lt;span class="c1"&gt;# `None` means unrestricted access, in which case no permission filtering is applied.
&lt;/span&gt;        &lt;span class="n"&gt;permitted_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_permitted_pks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;related_model&lt;/span&gt;&lt;span class="p"&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;filterset_class&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;kwargs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;objects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;field&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;permitted_ids&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;objects&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;objects&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;permitted_ids&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="n"&gt;matching_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_gql_filter_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cache_key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;field&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;matching_ids&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;permitted_ids&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;permitted_ids&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;get_permitted_pks&lt;/code&gt; in &lt;code&gt;nautobot/core/graphql/utils.py&lt;/code&gt; works out the set of primary keys a user may view for a model and caches it on the request, so the check costs at most one query per model per GraphQL request.&lt;/p&gt;

&lt;p&gt;The 3.2.0 commit is 932 additions and 79 deletions across 15 files, because it covers the other paths too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forward foreign keys and one-to-ones get &lt;code&gt;generate_fk_resolver&lt;/code&gt;. A related object the user may not view now comes back as &lt;code&gt;null&lt;/code&gt;, so the schema marks all forward foreign keys as nullable.&lt;/li&gt;
&lt;li&gt;Reverse one-to-ones get &lt;code&gt;generate_single_relation_resolver&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;generate_relationship_resolver&lt;/code&gt; does call &lt;code&gt;restrict()&lt;/code&gt;, because it already issues a fresh query per parent object.&lt;/li&gt;
&lt;li&gt;Hand-written and property-backed accessors are wrapped with two new helpers, &lt;code&gt;permission_safe_resolver&lt;/code&gt; and &lt;code&gt;permission_safe_attribute_resolver&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A new test, &lt;code&gt;test_all_related_object_fields_are_permission_enforced&lt;/code&gt;, scans the schema and fails on any field that returns a restrictable model through a resolver not marked as enforcing permissions.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A permission check at the root of a GraphQL query does not cover what the query reaches next.&lt;/strong&gt; Every resolver that returns persisted objects has to answer the question again, including default resolvers you never wrote and accessors built on model properties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make a missing check fail a test.&lt;/strong&gt; The most reusable part of this fix is the guard test. It walks the schema, so a new accessor that returns related objects without enforcing permissions gets caught before it ships.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for the query cost of the check.&lt;/strong&gt; A per-object &lt;code&gt;restrict()&lt;/code&gt; on a prefetched relation means one query per parent. Nautobot computes one set of permitted keys per model per request and filters in Python, which keeps the prefetch.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Disclosure timeline (UTC)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-23&lt;/strong&gt;: reproduced locally on v3.1.4 (synthetic data).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-06&lt;/strong&gt;: reported privately by email to the Nautobot security team.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-15&lt;/strong&gt;: the maintainers replied that another reporter had already identified the issue and that I would be credited as an additional reporter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-27&lt;/strong&gt;: v2.4.38 and v3.2.0 released, and advisory &lt;a href="https://github.com/nautobot/nautobot/security/advisories/GHSA-mfwj-pjgx-22v2" rel="noopener noreferrer"&gt;GHSA-mfwj-pjgx-22v2&lt;/a&gt; published by the maintainers.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;If you run Nautobot, upgrade. The fix first shipped in 2.4.38 and 3.2.0, and later releases carry further security fixes. On 2.4, take the latest 2.4 release. On 3.0 or 3.1 there is no fixed release on your line, so move to the latest 3.2 release. The advisory lists no workaround and notes that the weakness also exists in EOL v1.x. After upgrading, users without view permission on a related model will see &lt;code&gt;null&lt;/code&gt; on forward foreign keys and shorter lists on relations. Apps with custom GraphQL types should review the new helpers above.&lt;/p&gt;

&lt;p&gt;If you work with GraphQL generally, this is a quick test: take a user with no permission on a model, request those records directly, then request them again by traversing from something the user can see. If the two answers differ, your traversal isn't checking.&lt;/p&gt;

&lt;p&gt;Credit for this discovery goes to Habibullo Izzatilloyev. It was also reported by me. Comments and corrections welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Nautobot security advisory GHSA-mfwj-pjgx-22v2, "Object view permissions bypass via GraphQL traversal", published 2026-07-27. &lt;a href="https://github.com/nautobot/nautobot/security/advisories/GHSA-mfwj-pjgx-22v2" rel="noopener noreferrer"&gt;https://github.com/nautobot/nautobot/security/advisories/GHSA-mfwj-pjgx-22v2&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Nautobot security advisory GHSA-h8rv-c7c8-cvmx, 'Object view permissions bypass via REST API "depth"', published 2026-07-27. &lt;a href="https://github.com/nautobot/nautobot/security/advisories/GHSA-h8rv-c7c8-cvmx" rel="noopener noreferrer"&gt;https://github.com/nautobot/nautobot/security/advisories/GHSA-h8rv-c7c8-cvmx&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Fix commit for 3.2.0, &lt;code&gt;a05eb7d&lt;/code&gt;. &lt;a href="https://github.com/nautobot/nautobot/commit/a05eb7d0c939dd4205aca839955e4b3ee5f32af4" rel="noopener noreferrer"&gt;https://github.com/nautobot/nautobot/commit/a05eb7d0c939dd4205aca839955e4b3ee5f32af4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Fix commit for 2.4.38, &lt;code&gt;52a17d8&lt;/code&gt;. &lt;a href="https://github.com/nautobot/nautobot/commit/52a17d8e4806b909d5de9f0a121c92c2bbc6d719" rel="noopener noreferrer"&gt;https://github.com/nautobot/nautobot/commit/52a17d8e4806b909d5de9f0a121c92c2bbc6d719&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nautobot/core/graphql/generators.py&lt;/code&gt; at v3.1.4 (before the fix). &lt;a href="https://github.com/nautobot/nautobot/blob/v3.1.4/nautobot/core/graphql/generators.py" rel="noopener noreferrer"&gt;https://github.com/nautobot/nautobot/blob/v3.1.4/nautobot/core/graphql/generators.py&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nautobot/core/graphql/generators.py&lt;/code&gt; at v3.2.0 (after the fix). &lt;a href="https://github.com/nautobot/nautobot/blob/v3.2.0/nautobot/core/graphql/generators.py" rel="noopener noreferrer"&gt;https://github.com/nautobot/nautobot/blob/v3.2.0/nautobot/core/graphql/generators.py&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Nautobot release notes, v3.2.0 and v2.4.38. &lt;a href="https://github.com/nautobot/nautobot/releases/tag/v3.2.0" rel="noopener noreferrer"&gt;https://github.com/nautobot/nautobot/releases/tag/v3.2.0&lt;/a&gt; and &lt;a href="https://github.com/nautobot/nautobot/releases/tag/v2.4.38" rel="noopener noreferrer"&gt;https://github.com/nautobot/nautobot/releases/tag/v2.4.38&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MITRE CWE-200, Exposure of Sensitive Information to an Unauthorized Actor. &lt;a href="https://cwe.mitre.org/data/definitions/200.html" rel="noopener noreferrer"&gt;https://cwe.mitre.org/data/definitions/200.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MITRE CWE-285, Improper Authorization. &lt;a href="https://cwe.mitre.org/data/definitions/285.html" rel="noopener noreferrer"&gt;https://cwe.mitre.org/data/definitions/285.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq). 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>graphql</category>
      <category>python</category>
      <category>appsec</category>
    </item>
    <item>
      <title>Uppercase the path, skip the auth gate: an unauthenticated nginx bypass in CISA's Malcolm (GHSA-7j32-cf27-cp6h)</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Mon, 26 Oct 2026 15:56:49 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/uppercase-the-path-skip-the-auth-gate-an-unauthenticated-nginx-bypass-in-cisas-malcolm-2n0</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/uppercase-the-path-skip-the-auth-gate-an-unauthenticated-nginx-bypass-in-cisas-malcolm-2n0</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; Malcolm — the free network-traffic-analysis suite built by &lt;strong&gt;CISA&lt;/strong&gt; and &lt;strong&gt;Idaho National Laboratory&lt;/strong&gt; — shipped an nginx reverse-proxy config where one route's matcher was &lt;em&gt;case-insensitive&lt;/em&gt; but its redirect was &lt;em&gt;case-sensitive&lt;/em&gt;. Requesting that route in any non-lowercase casing (&lt;code&gt;/IDDASH2ARK/…&lt;/code&gt;) slipped past the redirect and fell through to an &lt;strong&gt;unauthenticated&lt;/strong&gt; &lt;code&gt;proxy_pass&lt;/code&gt;, in one of two proxied locations that never got the auth include.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; an unauthenticated network caller reaches the Arkime full-packet-capture backend, and because that same location forwards a client-supplied &lt;code&gt;X-Forwarded-User&lt;/code&gt; header un-overwritten — the exact header Arkime is configured to trust as the logged-in user — the caller arrives with a forged, auto-provisioned identity. Rated &lt;strong&gt;Moderate 6.5&lt;/strong&gt; (&lt;code&gt;CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed in v26.08.0&lt;/strong&gt;, advisory &lt;a href="https://github.com/cisagov/Malcolm/security/advisories/GHSA-7j32-cf27-cp6h" rel="noopener noreferrer"&gt;GHSA-7j32-cf27-cp6h&lt;/a&gt;, credited to me as reporter. &lt;a href="https://www.cve.org/CVERecord?id=CVE-2026-107336" rel="noopener noreferrer"&gt;CVE-2026-107336&lt;/a&gt; lists me as finder.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Malcolm is a free, full-packet-capture and network-traffic-analysis suite, developed by CISA in collaboration with Idaho National Laboratory for network security monitoring. Its front door is an analyst web UI on 443; behind that sits Arkime, the engine that stores and serves captured sessions and PCAP.&lt;/p&gt;

&lt;p&gt;For a tool whose entire job is to hold captured network traffic, the auth gate on the front proxy &lt;em&gt;is&lt;/em&gt; the security boundary. So when one route quietly falls off that gate, "unauthenticated" doesn't mean a marketing page is exposed — it means an anonymous caller is talking to the packet store.&lt;/p&gt;

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

&lt;p&gt;Malcolm's front nginx gates its proxied routes by &lt;code&gt;include&lt;/code&gt;-ing a runtime auth file (&lt;code&gt;nginx_auth_rt.conf&lt;/code&gt;, which in the shipped default resolves to HTTP basic auth). Most proxied locations include it; two did not. That include does two jobs at once: it demands credentials, and it overwrites &lt;code&gt;X-Forwarded-User&lt;/code&gt; with the authenticated user so a client can never forge it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/iddash2ark/&lt;/code&gt; route is different by design. The "Dashboards → Arkime shortcut" at &lt;code&gt;/iddash2ark/&lt;/code&gt; was only ever meant to hand you a &lt;strong&gt;302 redirect&lt;/strong&gt; to the real, gated &lt;code&gt;/arkime/sessions?expression=…&lt;/code&gt;. It didn't need its own auth, because a redirect leaks nothing. That assumption is where it goes wrong.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# nginx/nginx.conf  (v26.07.1)&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="n"&gt;/iddash2ark/&lt;/span&gt;&lt;span class="s"&gt;(.*)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                 &lt;span class="c1"&gt;# ~*  = CASE-INSENSITIVE match&lt;/span&gt;
    &lt;span class="kn"&gt;...&lt;/span&gt;
    &lt;span class="s"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^.*/iddash2ark/(.*)&lt;/span&gt; &lt;span class="nv"&gt;$forwarded_scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;//&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="n"&gt;/arkime/sessions?expression=&lt;/span&gt;&lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt; &lt;span class="s"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# case-SENSITIVE (no (?i) modifier)&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="nv"&gt;$arkime_protocol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;//arkime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# fall-through target: unauthenticated&lt;/span&gt;
    &lt;span class="kn"&gt;...&lt;/span&gt;
    &lt;span class="s"&gt;include&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/nginx_proxy_forward_headers.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# NOT the auth include&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two directives disagree about case. The &lt;code&gt;location ~*&lt;/code&gt; matcher is case-insensitive, so &lt;code&gt;/IDDASH2ARK/x&lt;/code&gt; &lt;strong&gt;enters the block&lt;/strong&gt;. But the &lt;code&gt;rewrite&lt;/code&gt; pattern &lt;code&gt;^.*/iddash2ark/(.*)&lt;/code&gt; has no &lt;code&gt;(?i)&lt;/code&gt; modifier, so it is case-sensitive and does &lt;strong&gt;not&lt;/strong&gt; match the uppercase path. No 302 is issued, and nginx falls through to the next thing in the block: a bare &lt;code&gt;proxy_pass&lt;/code&gt; to Arkime. This location includes only the header-forwarding file, not the auth file. The request reaches the backend with no credentials checked. (&lt;code&gt;/dashboards/app/extracted-files/&lt;/code&gt; had the same case mismatch and the same missing include; the maintainer fixed both together.)&lt;/p&gt;

&lt;p&gt;Then the confused deputy. The header file this location &lt;em&gt;does&lt;/em&gt; include sets &lt;code&gt;X-Forwarded-For/Host/Proto/...&lt;/code&gt; but never touches &lt;code&gt;X-Forwarded-User&lt;/code&gt;. On every gated route, the auth include overwrites that header with the authenticated user; here nothing overwrites it, so whatever the client sends survives. And Arkime is configured to trust it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# arkime/etc/config.ini  (v26.07.1)
&lt;/span&gt;&lt;span class="py"&gt;userAuthIps&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;::,0.0.0.0/0&lt;/span&gt;
&lt;span class="py"&gt;userNameHeader&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;x-forwarded-user&lt;/span&gt;
&lt;span class="py"&gt;userAutoCreateTmpl&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;{"userId": "${this["x-forwarded-user"]}", ... "enabled": true, ... "webEnabled": true, "headerAuthEnabled": true, ... "packetSearch": true, ...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;userNameHeader=x-forwarded-user&lt;/code&gt; means Arkime reads the logged-in username straight out of that header; &lt;code&gt;userAuthIps=::,0.0.0.0/0&lt;/code&gt; trusts every IPv4 peer (and nginx is the peer); and the auto-create template provisions that user on the fly with packet search enabled. So the attacker doesn't merely reach the backend unauthenticated — they get to choose who they arrive as.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyi9xapy07d1tjlxdok8o.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyi9xapy07d1tjlxdok8o.gif" alt="Diagram: the case-mismatch auth bypass in Malcolm, step by step" width="800" height="872"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The location matcher was case-insensitive, but the redirect meant to neutralize the request was case-sensitive — so changing one letter to uppercase slipped the request into a route that never had an auth gate, carrying a username the backend had been told to trust.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;I didn't need the full suite for this — the bug lives entirely in Malcolm's own nginx config. I stood up a minimal nginx from the &lt;strong&gt;verbatim, unmodified&lt;/strong&gt; shipped config fragments of tag &lt;code&gt;v26.07.1&lt;/code&gt;, in front of a tiny stub that just echoes the method, path, and headers it receives (no Arkime logic reimplemented, nothing weaponized). Everything local, synthetic, no live or third-party system touched. The run went over plain HTTP on localhost; this is the recorded output:&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;&lt;span class="o"&gt;[&lt;/span&gt;1] positive control: GET /arkime/  &lt;span class="o"&gt;(&lt;/span&gt;no creds&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;HTTP/1.1 401 Authorization Required

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;2] inverse control: GET /iddash2ark/x  &lt;span class="o"&gt;(&lt;/span&gt;exact lowercase, no creds&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;HTTP/1.1 302 Moved Temporarily
Location: http://127.0.0.1/arkime/sessions?expression=(x)

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;3] the bypass: GET /IDDASH2ARK/x  &lt;span class="o"&gt;(&lt;/span&gt;uppercase, no creds, X-Forwarded-User: forged-admin&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;HTTP/1.1 200 OK
ARKIME_STUB_HIT method=GET path=/IDDASH2ARK/x x-forwarded-user='forged-admin' x-forwarded-roles=None authorization=None
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last request reached the backend unauthenticated, with the forged header passed through un-overwritten.&lt;/p&gt;

&lt;p&gt;Mixed case (&lt;code&gt;/IdDaSh2Ark/y&lt;/code&gt;) behaves identically, and the raw request URI (path + query) is forwarded verbatim, so the caller controls exactly what Arkime receives.&lt;/p&gt;

&lt;p&gt;Honest boundary: I confirmed the bypass and the forged, un-overwritten identity reaching the backend. I did &lt;strong&gt;not&lt;/strong&gt; stand up a real Arkime instance to confirm which session/PCAP responses it serves under that prefix. That's why the score sits at &lt;strong&gt;Moderate 6.5&lt;/strong&gt; (C:L) and not higher — the foothold is proven, the depth of the data read isn't, and I'd rather under-claim than over-claim. (Before the PoC I had it at 7.5, assuming a full data read. I filed it at 6.5 because that read wasn't confirmed.)&lt;/p&gt;

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

&lt;p&gt;Shipped in &lt;strong&gt;v26.08.0&lt;/strong&gt; (2026-08-25), tracked in &lt;a href="https://github.com/cisagov/Malcolm/issues/1081" rel="noopener noreferrer"&gt;cisagov/Malcolm#1081&lt;/a&gt;. The maintainer's fix does three things. It makes the rewrite case-insensitive with the inline &lt;code&gt;(?i)&lt;/code&gt; modifier, so the matcher and the rewrite agree. It deletes the dead &lt;code&gt;proxy_pass&lt;/code&gt; and its forwarding headers, so the location can only ever redirect and nothing reaches Arkime through it. And it adds the auth include as a backstop. The location as shipped in v26.08.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="n"&gt;/iddash2ark/&lt;/span&gt;&lt;span class="s"&gt;(.*)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/nginx_auth_rt.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$forwarded_scheme&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$http_x_forwarded_proto&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'https')&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$forwarded_scheme&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;(?i)^.*/iddash2ark/(.*)&lt;/span&gt; &lt;span class="nv"&gt;$forwarded_scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;//&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="n"&gt;/arkime/sessions?expression=&lt;/span&gt;&lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt; &lt;span class="s"&gt;redirect&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;The same change went into &lt;code&gt;/dashboards/app/extracted-files/&lt;/code&gt; and into the read-only profile (&lt;code&gt;nginx_readonly.conf&lt;/code&gt;), where the &lt;code&gt;/iddash2ark/&lt;/code&gt; block was byte-identical. A week later the maintainer fixed a third location of the same shape, &lt;code&gt;/dashboards/app/netbox/&lt;/code&gt; (&lt;a href="https://github.com/cisagov/Malcolm/issues/1107" rel="noopener noreferrer"&gt;#1107&lt;/a&gt;).&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A case-insensitive &lt;code&gt;location ~*&lt;/code&gt; paired with a case-sensitive &lt;code&gt;rewrite&lt;/code&gt; is a bug, not a style choice.&lt;/strong&gt; If a matcher and the directive meant to act on the match disagree about case, requests can enter the block and skip the action — here, skip the redirect that was the only thing standing between an anonymous caller and an unauthenticated &lt;code&gt;proxy_pass&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-location auth is a checklist, and checklists have gaps.&lt;/strong&gt; Here two "harmless redirect" locations also carried a &lt;code&gt;proxy_pass&lt;/code&gt; and skipped the include. A redirect-only location shouldn't have a &lt;code&gt;proxy_pass&lt;/code&gt; at all, and a default-deny backstop — a server-level &lt;code&gt;auth_request&lt;/code&gt; that every location inherits — means one forgotten include isn't a total bypass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never honor a forwarded identity header on a path that isn't behind the auth that sets it.&lt;/strong&gt; &lt;code&gt;X-Forwarded-User&lt;/code&gt; is only trustworthy if something upstream guarantees it. The moment a route forwards it without overwriting it, it's attacker input — a textbook confused deputy.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-08-13&lt;/strong&gt; — found during a source review of Malcolm's nginx config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-08-17&lt;/strong&gt; — reproduced locally against the shipped v26.07.1 config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-08-19&lt;/strong&gt; — reported privately via GitHub's private vulnerability reporting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-08-20&lt;/strong&gt; — advisory &lt;a href="https://github.com/cisagov/Malcolm/security/advisories/GHSA-7j32-cf27-cp6h" rel="noopener noreferrer"&gt;GHSA-7j32-cf27-cp6h&lt;/a&gt; published by the maintainer, crediting me as reporter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-08-25&lt;/strong&gt; — fix released in &lt;strong&gt;v26.08.0&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-10-08&lt;/strong&gt; — &lt;a href="https://www.cve.org/CVERecord?id=CVE-2026-107336" rel="noopener noreferrer"&gt;CVE-2026-107336&lt;/a&gt; published.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is distinct from Malcolm's earlier nginx-RBAC advisories about URI normalization and percent-encoding, which have a different root cause. This one is the case-sensitivity mismatch on the &lt;code&gt;/iddash2ark/&lt;/code&gt; location plus an un-overwritten forwarded identity header.&lt;/p&gt;

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

&lt;p&gt;If you run nginx with per-location auth, this is a five-minute grep worth doing today: look for &lt;code&gt;~*&lt;/code&gt; matchers paired with case-sensitive rewrites, and for any &lt;code&gt;proxy_pass&lt;/code&gt; location missing your auth include. Two directives that disagree about case are enough to open a door.&lt;/p&gt;

&lt;p&gt;Reported by me. Comments and corrections welcome.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq). 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>nginx</category>
      <category>appsec</category>
    </item>
    <item>
      <title>The POST was guarded, the GET on the same URL was not: cross-tenant PII disclosure in CoopCycle (GET /api/stores/{id}/addresses)</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Wed, 02 Sep 2026 11:12:29 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/the-post-was-guarded-the-get-on-the-same-url-was-not-cross-tenant-pii-disclosure-in-coopcycle-2j5i</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/the-post-was-guarded-the-get-on-the-same-url-was-not-cross-tenant-pii-disclosure-in-coopcycle-2j5i</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; In &lt;code&gt;coopcycle-web&lt;/code&gt; — the open-source logistics and marketplace platform that worker-owned courier co-operatives self-host instead of renting a commercial delivery app — the operation &lt;code&gt;GET /api/stores/{id}/addresses&lt;/code&gt; was declared with &lt;strong&gt;no &lt;code&gt;security&lt;/code&gt; expression at all&lt;/strong&gt;. Every sibling operation on the same resource family had one. So did the &lt;strong&gt;&lt;code&gt;POST&lt;/code&gt; to that exact same URL&lt;/strong&gt;. The provider behind the &lt;code&gt;GET&lt;/code&gt; filtered on the path &lt;code&gt;{id}&lt;/code&gt; and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; Any authenticated account — and self-registration is open — could walk &lt;code&gt;{id}&lt;/code&gt; from 1 upward and read every store's delivery-address book: recipient &lt;code&gt;contactName&lt;/code&gt;, &lt;code&gt;streetAddress&lt;/code&gt;, &lt;code&gt;postalCode&lt;/code&gt;. On a shared instance hosting many co-ops, that is a platform-wide cross-tenant leak of the home addresses of people who ordered dinner. CWE-862 + CWE-639. I score it CVSS v3.1 &lt;strong&gt;6.5 (Medium)&lt;/strong&gt; — &lt;code&gt;AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N&lt;/code&gt;. That is &lt;em&gt;my&lt;/em&gt; score: there is no advisory and no vendor rating, and the load-bearing metric is &lt;code&gt;C:H&lt;/code&gt; for platform-wide recipient PII.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed:&lt;/strong&gt; commit &lt;strong&gt;&lt;code&gt;a65d9f9e&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;two days after I reported it&lt;/strong&gt;, tagged as &lt;strong&gt;v5.6.0&lt;/strong&gt; five minutes later. Reported by me, &lt;strong&gt;Santosh Kumar Puppala&lt;/strong&gt;, under coordinated disclosure. No advisory was published — this was a silent fix — and 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;CoopCycle is not a SaaS company. It is a licence and a shared codebase, available only to worker-owned businesses, and a single deployment routinely carries &lt;strong&gt;many stores&lt;/strong&gt; belonging to unrelated operators — the platform models &lt;code&gt;Store&lt;/code&gt; as a first-class tenant precisely because that is how it is used. Multi-tenancy here is not an enterprise feature bolted on for a big customer. It is the shape of the product.&lt;/p&gt;

&lt;p&gt;And the data behind this particular endpoint is about as personal as delivery software gets. A store's address book is not company data. It is a list of &lt;strong&gt;customers' homes&lt;/strong&gt; — name, contact name, street, postcode — accumulated across every delivery that store has ever made.&lt;/p&gt;

&lt;p&gt;The endpoint that returned it required nothing but a login. Registration is open to the public.&lt;/p&gt;

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

&lt;p&gt;CoopCycle is Symfony 6 with API Platform 3. In API Platform, authorization on a resource is declarative: you put a &lt;code&gt;security:&lt;/code&gt; expression on each operation, written in Symfony's expression language, and the framework evaluates it before the operation runs. If you omit the key, there is no check beyond whatever the firewall did — and CoopCycle's &lt;code&gt;api_platform.yaml&lt;/code&gt; sets no global default.&lt;/p&gt;

&lt;p&gt;The project uses this well. Here is &lt;code&gt;src/Entity/Store.php&lt;/code&gt; in v5.5.0 — every sibling sub-resource on &lt;code&gt;/stores/{id}/*&lt;/code&gt; carrying its guard:&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;uriTemplate&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'/stores/{id}/time_slots'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;security&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"is_granted('ROLE_DISPATCHER') or is_granted('ROLE_COURIER') or is_granted('edit', object)"&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;uriTemplate&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'/stores/{id}/payment_methods'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;security&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"is_granted('ROLE_DISPATCHER') or is_granted('ROLE_COURIER') or is_granted('edit', object)"&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;uriTemplate&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'/stores/{id}/addresses'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// &amp;lt;- the WRITE to the address book&lt;/span&gt;
    &lt;span class="n"&gt;security&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"is_granted('edit', object)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// &amp;lt;- is gated&lt;/span&gt;
    &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Address&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="n"&gt;processor&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StoreAddressProcessor&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those expressions resolve through &lt;code&gt;StoreVoter&lt;/code&gt;, which checks &lt;code&gt;$user-&amp;gt;ownsStore($subject)&lt;/code&gt;. The tenant boundary is real, it is enforced, and the developers clearly know where it goes. That is the negative control: this is not a single-tenant app where nobody thought about isolation.&lt;/p&gt;

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

&lt;p&gt;The collection read is not declared on &lt;code&gt;Store&lt;/code&gt;. It is declared on &lt;code&gt;Address&lt;/code&gt;, in a second &lt;code&gt;#[ApiResource]&lt;/code&gt; block in &lt;code&gt;src/Entity/Address.php&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="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ApiResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;uriTemplate&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'/stores/{id}/addresses'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'http://schema.org/Place'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;operations&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetCollection&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;          &lt;span class="c1"&gt;// &amp;lt;- no security: anything&lt;/span&gt;
    &lt;span class="n"&gt;uriVariables&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fromClass&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Store&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="n"&gt;fromProperty&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'addresses'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;normalizationContext&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'groups'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'address'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
    &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StoreAddressesProvider&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;new GetCollection()&lt;/code&gt;. No expression, no argument, nothing. And the provider it delegates to takes the store id straight off the path:&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getDropoffAddresses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$storeId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$qb&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;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Address&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createQueryBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$qb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Task&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="s1"&gt;'t'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Join&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;WITH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'a.id = t.address'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$qb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Delivery&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="s1"&gt;'d'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Join&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;WITH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'d.id = t.delivery AND d.store = :store'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$qb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;andWhere&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'t.type = :type'&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;setParameter&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="nv"&gt;$storeId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// &amp;lt;- the path {id}, and nothing about the caller&lt;/span&gt;
       &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'DROPOFF'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$qb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getQuery&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;getResult&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;Note that this hand-rolled QueryBuilder also sidesteps any Doctrine ownership extension the project might add later — the &lt;code&gt;?type=dropoff&lt;/code&gt; branch would keep leaking even if someone bolted a global filter onto the ORM.&lt;/p&gt;

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

&lt;p&gt;Now, why would a team this careful about &lt;code&gt;security:&lt;/code&gt; expressions leave one operation bare? Reading the fix answered it, and the answer is more interesting than "they forgot."&lt;/p&gt;

&lt;p&gt;The house idiom is &lt;code&gt;is_granted('edit', object)&lt;/code&gt;. On a sub-resource &lt;strong&gt;collection&lt;/strong&gt;, there &lt;em&gt;is no&lt;/em&gt; &lt;code&gt;object&lt;/code&gt; — API Platform has no single entity to hand the voter. This is not my inference about the framework; the codebase says so itself, in a comment I'll come back to in a moment that carries a link to API Platform's own subresources documentation. So the one idiom the whole codebase leans on is precisely the one unavailable on this shape of endpoint. The sibling &lt;code&gt;POST&lt;/code&gt; operates on a single store, so &lt;code&gt;is_granted('edit', object)&lt;/code&gt; works there and was used. The &lt;code&gt;GET&lt;/code&gt; returns a collection, so it doesn't, and nothing was used instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The endpoint that got no authorization check was the one endpoint where the project's standard authorization check was not available — the gap tracked the framework's limitation, not the developers' attention.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the shape worth remembering, because it generalises far past PHP. Wherever a framework's ergonomic guard covers 95% of your routes, the missing 5% is not randomly distributed. It is exactly the awkward shape — the sub-resource, the custom action, the bulk endpoint, the streaming response — and that is where you should look first.&lt;/p&gt;

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

&lt;p&gt;I confirmed this end to end on a local bring-up of the &lt;code&gt;v5.5.0&lt;/code&gt; tag: the repo's own &lt;code&gt;docker/php&lt;/code&gt; image, PostGIS 16, Redis, nginx, schema created with the project's own console commands and seeded with the project's own Alice fixture loader. Three synthetic accounts, all fictional data.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Store A&lt;/strong&gt; (&lt;code&gt;id=1&lt;/code&gt;), owned by &lt;code&gt;poc_storeA_owner&lt;/code&gt;. One address book entry, seeded with a marker: &lt;code&gt;contactName = "Synthetic Recipient A - IDOR-POC-MARKER-2026"&lt;/code&gt;, &lt;code&gt;streetAddress = "42 Rue Secret Store A Only"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store B&lt;/strong&gt; (&lt;code&gt;id=2&lt;/code&gt;), owned by &lt;code&gt;poc_storeB_owner&lt;/code&gt;. A different, non-overlapping entry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;poc_plain_customer&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;ROLE_USER&lt;/code&gt; only, no store, no dispatcher or courier role. The self-registered-customer equivalent.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[LEAK]     GET  /api/stores/1/addresses      (token = Store B's owner)   -&amp;gt; 200
           "contactName":"Synthetic Recipient A - IDOR-POC-MARKER-2026",
           "streetAddress":"42 Rue Secret Store A Only"          &amp;lt;- Store A's book

[LEAK]     GET  /api/stores/1/addresses      (token = plain customer)    -&amp;gt; 200
           identical body                                        &amp;lt;- confirms PR:L

[CONTROL]  GET  /api/stores/1/addresses      (token = Store A's owner)   -&amp;gt; 200
           same single record            &amp;lt;- the leaked row is genuinely A's data

[CONTROL]  GET  /api/stores/1/time_slots     (token = Store B's owner)   -&amp;gt; 403
[CONTROL]  POST /api/stores/1/addresses      (token = Store B's owner)   -&amp;gt; 403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two 403s are the whole argument. The same token, against the same store, on sibling operations — including a write to &lt;em&gt;the very URL that just leaked on read&lt;/em&gt; — is correctly denied. That kills the "couriers and dispatchers legitimately need this data" objection before anyone raises it: the operation was not open to couriers and dispatchers, it was open to &lt;strong&gt;everybody with a password&lt;/strong&gt;, and the project's own adjacent code says what the intended audience was.&lt;/p&gt;

&lt;p&gt;Everything above ran against a container on &lt;code&gt;127.0.0.1&lt;/code&gt;. Nothing was ever sent to a live co-op.&lt;/p&gt;

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

&lt;p&gt;Commit &lt;strong&gt;&lt;code&gt;a65d9f9e&lt;/code&gt;&lt;/strong&gt;, "Fix store addresses security", landed &lt;strong&gt;2026-07-21, two days after my email&lt;/strong&gt;, and &lt;code&gt;v5.6.0&lt;/code&gt; was tagged five minutes later. Three files changed. The operation declaration:&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;// before&lt;/span&gt;
&lt;span class="n"&gt;operations&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetCollection&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;

&lt;span class="c1"&gt;// after&lt;/span&gt;
&lt;span class="n"&gt;operations&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;security&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"is_granted('view', request)"&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;Not &lt;code&gt;is_granted('view', object)&lt;/code&gt; — &lt;code&gt;request&lt;/code&gt;. That works because &lt;code&gt;StoreVoter&lt;/code&gt; already carried an escape hatch for exactly this problem, with a comment naming the framework issue and linking API Platform's docs:&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;// Needed for /api/stores/{id}/deliveries endpoint&lt;/span&gt;
&lt;span class="c1"&gt;// https://api-platform.com/docs/v4.0/core/subresources/#security&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$subject&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;Store&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$subject&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;Request&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&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;$subject&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$subject&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;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Store&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&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;The workaround for the sub-resource limitation was already in the codebase, already used by &lt;code&gt;/stores/{id}/deliveries&lt;/code&gt;. This endpoint just never got it. The commit also adds a firewall entry so the token resolves on this path, and — the part I'd frame and hang on a wall — it fixes the &lt;strong&gt;test suite&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gherkin"&gt;&lt;code&gt;&lt;span class="c"&gt;# BEFORE — bob owns store "Acme" (id 1), and this scenario asserted 200 on store 2&lt;/span&gt;
&lt;span class="nf"&gt;When &lt;/span&gt;the user &lt;span class="s"&gt;"bob"&lt;/span&gt; sends a &lt;span class="s"&gt;"GET"&lt;/span&gt; request to &lt;span class="s"&gt;"/api/stores/2/addresses?type=dropoff"&lt;/span&gt;
&lt;span class="nf"&gt;Then &lt;/span&gt;the response status code should be 200

&lt;span class="c"&gt;# AFTER — bob reads his own store, and two new scenarios pin the denial&lt;/span&gt;
&lt;span class="nf"&gt;When &lt;/span&gt;the user &lt;span class="s"&gt;"bob"&lt;/span&gt; sends a &lt;span class="s"&gt;"GET"&lt;/span&gt; request to &lt;span class="s"&gt;"/api/stores/1/addresses?type=dropoff"&lt;/span&gt;
&lt;span class="nf"&gt;Then &lt;/span&gt;the response status code should be 200

&lt;span class="kn"&gt;Scenario&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; Not authorized to list another store's addresses with JWT
  &lt;span class="nf"&gt;When &lt;/span&gt;the user &lt;span class="s"&gt;"bob"&lt;/span&gt; sends a &lt;span class="s"&gt;"GET"&lt;/span&gt; request to &lt;span class="s"&gt;"/api/stores/2/addresses"&lt;/span&gt;
  &lt;span class="nf"&gt;Then &lt;/span&gt;the response status code should be 403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A green Behat suite had been asserting the cross-tenant read as &lt;em&gt;correct behaviour&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honesty note on evidence class:&lt;/strong&gt; I verified the fix from the public repository — the commit contents, and &lt;code&gt;git tag --contains&lt;/code&gt; putting it in &lt;code&gt;v5.6.0&lt;/code&gt; onward. I did &lt;strong&gt;not&lt;/strong&gt; rebuild a patched image and re-run the PoC against it, so this is "confirmed from the commit and the release" rather than a re-tested &lt;code&gt;NOT_REPRODUCED&lt;/code&gt; verdict. The weaker of the two, and worth labelling as such.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Diff the guarded operations against the exposed ones, and start with the odd one out.&lt;/strong&gt; In a declarative-authorization codebase, the audit is nearly mechanical: list every operation, list every &lt;code&gt;security:&lt;/code&gt; expression, subtract. On CoopCycle that diff was one line long. The same query works on DRF &lt;code&gt;permission_classes&lt;/code&gt;, on Spring &lt;code&gt;@PreAuthorize&lt;/code&gt;, on NestJS guards — anywhere the check is an annotation, because an annotation can be &lt;em&gt;absent&lt;/em&gt; and absence renders as nothing at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read/write asymmetry on the same URL is the highest-signal tell in authorization auditing.&lt;/strong&gt; &lt;code&gt;POST /stores/{id}/addresses&lt;/code&gt; gated, &lt;code&gt;GET /stores/{id}/addresses&lt;/code&gt; bare. When one verb on a path is protected and another is not, you do not have to argue about intent — the developers already told you what the boundary is, and one operation is on the wrong side of it. Whenever you find one, check the other verbs on the same path before you check anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A test that asserts the vulnerable behaviour is worse than no test.&lt;/strong&gt; The scenario here did not merely fail to catch the leak; it locked it in. Anyone who &lt;em&gt;had&lt;/em&gt; added the guard would have broken a passing test and, quite reasonably, assumed they'd got it wrong. If your suite has a scenario where one tenant successfully reads another tenant's data, check today whether that is a fixture convenience or a specification — and write the 403 case, because the 200 case will never fail for the right reason.&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-16&lt;/td&gt;
&lt;td&gt;Found during a source review of the &lt;code&gt;/stores/{id}/*&lt;/code&gt; operation family — spotted as a read/write asymmetry on one URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-18&lt;/td&gt;
&lt;td&gt;Live PoC confirmed on the &lt;code&gt;v5.5.0&lt;/code&gt; tag: cross-tenant read from two different accounts, plus 403 controls on the gated siblings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-19&lt;/td&gt;
&lt;td&gt;Reported privately by email to the maintainer, under coordinated disclosure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-21&lt;/td&gt;
&lt;td&gt;Fix commit &lt;code&gt;a65d9f9e&lt;/code&gt; lands — &lt;strong&gt;two days later&lt;/strong&gt; — and &lt;strong&gt;v5.6.0&lt;/strong&gt; is tagged five minutes after it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;No security advisory published; 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;Two days from a cold email to a tagged release, with regression tests written into the same commit. Silent fixes get criticised — and the absence of an advisory does mean self-hosting co-ops had no signal to upgrade — but the engineering response here was faster and more complete than plenty of funded vendors manage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you run CoopCycle: you want v5.6.0 or later.&lt;/strong&gt; Current at time of writing is v5.8.9.&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 maintain an API Platform, DRF or Spring codebase, here is a ten-minute job: grep for every route declaration, grep for every authorization annotation, and diff the two lists. Then look at whichever endpoints your framework makes &lt;em&gt;awkward&lt;/em&gt; to guard — the sub-resources, the collections, the custom actions. That is where the missing line will be, and it will not be a line you can see, because it isn't there.&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>symfony</category>
      <category>appsec</category>
    </item>
    <item>
      <title>The TODO shipped to npm: an unauthenticated route that could stop any city's AI workflow (Your Priorities, @yrpri/api &lt; 9.0.244)</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Wed, 02 Sep 2026 10:40:59 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/the-todo-shipped-to-npm-an-unauthenticated-route-that-could-stop-any-citys-ai-workflow-your-pkd</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/the-todo-shipped-to-npm-an-unauthenticated-route-that-could-stop-any-citys-ai-workflow-your-pkd</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; In Your Priorities — the Citizens Foundation's open-source participatory-democracy platform, the one municipalities self-host to run citizen deliberation — two Express routes in the AI-assistant controller were registered with &lt;strong&gt;no authorization middleware at all&lt;/strong&gt;, directly beneath a comment reading &lt;code&gt;//TODO: Add auth for below&lt;/code&gt;. The handler for &lt;code&gt;PUT /api/assistants/:groupId/:agentId/:runId/advanceOrStopWorkflow&lt;/code&gt; resolved the target run by bare primary key and never compared it to a caller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; With &lt;strong&gt;no session cookie and no token&lt;/strong&gt;, an anonymous HTTP client could advance or force-fail any group's AI-agent workflow run. &lt;code&gt;runId&lt;/code&gt; is a sequential integer, so the whole population is enumerable. CWE-862. I score it CVSS v3.1 &lt;strong&gt;6.5 (Medium)&lt;/strong&gt; — &lt;code&gt;AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L&lt;/code&gt;. That is &lt;em&gt;my&lt;/em&gt; score: no advisory was published, so no vendor rating exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed:&lt;/strong&gt; commit &lt;strong&gt;&lt;code&gt;ae4b4704&lt;/code&gt;&lt;/strong&gt;, shipped as &lt;strong&gt;&lt;code&gt;@yrpri/api&lt;/code&gt; 9.0.244&lt;/strong&gt; — &lt;strong&gt;the same day I reported it&lt;/strong&gt;. Reported by me, &lt;strong&gt;Santosh Kumar Puppala&lt;/strong&gt;, under coordinated disclosure. The maintainer credited me by name in the commit subject and in the release note. No CVE yet; one has been requested.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;Your Priorities is not a startup's side project. It is the deliberation engine behind Better Reykjavík and a long line of municipal and national participation programmes — the software a city stands up when it wants residents to file, debate and prioritise ideas, and then wants the results to carry political weight. Its newer AI-assistant layer runs agent workflows on behalf of groups: multi-step runs that process submissions, summarise, and advance through a state machine.&lt;/p&gt;

&lt;p&gt;So the asset here is not a row in a database. It is &lt;strong&gt;the integrity of a process a public body is going to act on&lt;/strong&gt;, and the availability of the machinery that produces it.&lt;/p&gt;

&lt;p&gt;An anonymous client could reach into that state machine and turn the crank.&lt;/p&gt;

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

&lt;p&gt;The backend is Node/TypeScript on Express, published to npm as &lt;code&gt;@yrpri/api&lt;/code&gt;. Authorization is not ambient — it is per-route middleware, &lt;code&gt;auth.can("...")&lt;/code&gt;, applied at registration time in each controller.&lt;/p&gt;

&lt;p&gt;The global middleware stack tells you immediately that there is no safety net. In &lt;code&gt;app.ts&lt;/code&gt; (around L952) the app mounts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;passport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;passport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of those are &lt;strong&gt;populate-only&lt;/strong&gt;. They hydrate &lt;code&gt;req.user&lt;/code&gt; when a valid session exists and do nothing at all when one doesn't. Neither rejects an anonymous request. Whether a route is protected is decided entirely by what that route asks for, and the assistants router is mounted at &lt;code&gt;app.ts:714&lt;/code&gt; with no gate on the path.&lt;/p&gt;

&lt;p&gt;Which is fine, because the controller mostly does ask. Sibling routes in the very same file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// assistantsController.ts — the house idiom, used correctly&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/:domainId/chat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;view domain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the read paths for the same agent feature check by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// getUpdatedWorkflow / getAgentConfigurationAnswers&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&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;That is the negative control. This is not a codebase that forgot authorization exists. The pattern is present, it is enforced, and the &lt;em&gt;reads&lt;/em&gt; on this exact feature are gated.&lt;/p&gt;

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

&lt;p&gt;Now the block that isn't. Around line 105 of &lt;code&gt;assistantsController.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// assistantsController.ts ~L105&lt;/span&gt;
&lt;span class="c1"&gt;// TODO: Add auth for below&lt;/span&gt;

&lt;span class="c1"&gt;// ... and then, at L143-144:&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/:groupId/:agentId/:runId/advanceOrStopWorkflow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;advanceOrStopWorkflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;auth.can(...)&lt;/code&gt;. No &lt;code&gt;if (!req.user)&lt;/code&gt;. The handler runs for anyone who can reach the port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// assistantsController.ts:231-251&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;advanceOrStopWorkflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wsClientId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wsClientId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;notificationManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;advanceWorkflowStepOrCompleteAgentRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wsClientId&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;And the manager it delegates to — &lt;code&gt;notificationAgentQueueManager.ts:211-300&lt;/code&gt; — contains the detail I find genuinely striking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agentRun&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;YpAgentProductRun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByPk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;agentRunId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;YpAgentProductBundle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;YpSubscription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// agentRun.YpAgentProductBundle.YpSubscription.user_id — fetched, never compared&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agentRun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;completedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentStepIndex&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agentRun&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that &lt;code&gt;include&lt;/code&gt; again. The query deliberately joins two levels down — bundle, then subscription — and pulls exactly one column: &lt;code&gt;user_id&lt;/code&gt;. The owner of the run. It is loaded into memory, sitting on the object, and then the function mutates the run without ever looking at it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The identity needed to make the decision was already in the result set. The line that compares it was the line that was never written — and a comment in the file says so out loud.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is what makes this one worth writing up rather than filing as another missing-middleware bug. There was no architectural gap to close. No refactor, no new query, no plumbing a user through three layers. The ownership data had already been fetched by someone who clearly intended to check it.&lt;/p&gt;

&lt;p&gt;The same &lt;code&gt;//TODO&lt;/code&gt; block also covered a sibling: &lt;code&gt;PUT /api/assistants/:groupId/:agentId/startWorkflowAgent&lt;/code&gt;, which enqueued a BullMQ agent-processing job for any supplied &lt;code&gt;agentId&lt;/code&gt;, unauthenticated. That one is an LLM-execution cost sink — anonymous callers making someone else's inference bill go up. I reported both together; one commit closed both.&lt;/p&gt;

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

&lt;p&gt;Confirmed end to end on &lt;strong&gt;2026-06-17&lt;/strong&gt; against the published &lt;code&gt;@yrpri/api&lt;/code&gt; &lt;strong&gt;v9.0.242&lt;/strong&gt; build, running on Node 22 with containerised PostgreSQL 13 and Redis 6.2 bound to &lt;code&gt;127.0.0.1&lt;/code&gt;. Synthetic rows only. Nothing was ever sent to a live deployment.&lt;/p&gt;

&lt;p&gt;Every request below carries &lt;strong&gt;no &lt;code&gt;Authorization&lt;/code&gt; header and no session cookie&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;[MUTATE]   PUT /api/assistants/1/1/1/advanceOrStopWorkflow   {"status":"failed"}    -&amp;gt; 200
           run 1 in DB:  running   -&amp;gt;   failed

[MUTATE]   PUT /api/assistants/1/1/3/advanceOrStopWorkflow   {"status":"advance"}   -&amp;gt; 200
           run 3 workflow.currentStepIndex:  0   -&amp;gt;   1

[CONTROL]  PUT /api/assistants/1/chat            (auth.can("view domain"))          -&amp;gt; 401
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 401 is the entire argument. Same app, same process, same controller file, same anonymous client — and the guarded sibling correctly refuses. The auth middleware is present, loaded and working. These routes simply never asked it anything.&lt;/p&gt;

&lt;p&gt;The two mutations were verified in Postgres, not inferred from the response body: a run belonging to a seeded subscription flipped to &lt;code&gt;failed&lt;/code&gt;, and a second run's state machine stepped forward, both at the request of a caller with no identity whatsoever.&lt;/p&gt;

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

&lt;p&gt;I emailed the maintainer on &lt;strong&gt;2026-07-10 at 16:48&lt;/strong&gt;. He replied at &lt;strong&gt;17:36&lt;/strong&gt; — forty-eight minutes later — confirming the issue and linking the commit that fixed it.&lt;/p&gt;

&lt;p&gt;Commit &lt;strong&gt;&lt;code&gt;ae4b4704&lt;/code&gt;&lt;/strong&gt; closes both routes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;advanceOrStopWorkflow&lt;/code&gt; gains an explicit &lt;strong&gt;ownership predicate&lt;/strong&gt; — the run is resolved within the caller's authorized scope instead of by bare primary key, so the &lt;code&gt;user_id&lt;/code&gt; that was already being fetched is finally the thing the decision turns on.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;startWorkflowAgent&lt;/code&gt;, the sibling in the same &lt;code&gt;//TODO&lt;/code&gt; block, was &lt;strong&gt;removed outright&lt;/strong&gt; rather than guarded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It shipped as &lt;strong&gt;&lt;code&gt;@yrpri/api&lt;/code&gt; 9.0.244&lt;/strong&gt; the same day. The release note reads, in full:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Fix for security related issues in agent workflows reported by Santosh Kumar Puppala.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Honesty note on evidence class:&lt;/strong&gt; I verified the fix from the maintainer's reply, the fix commit and the published npm artifact — reported→confirmed→released, all on 2026-07-10. I did &lt;strong&gt;not&lt;/strong&gt; rebuild a patched image and re-run the PoC against 9.0.244, so this is "confirmed from the commit and the release," not a re-tested &lt;code&gt;NOT_REPRODUCED&lt;/code&gt; verdict. The weaker of the two, and worth labelling as such. I am also describing the &lt;em&gt;shape&lt;/em&gt; of the patch rather than quoting a diff line by line.&lt;/p&gt;

&lt;p&gt;One thing this was &lt;strong&gt;not&lt;/strong&gt; is a silent fix. The commit subject names me and links my GitHub profile; the release note names me. The only thing missing is a GHSA — which is why self-hosters got no upgrade signal, and why the CVE is still being chased through MITRE's CNA of last resort.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Grep your own TODOs as an attack surface, not a backlog.&lt;/strong&gt; &lt;code&gt;TODO: Add auth&lt;/code&gt;, &lt;code&gt;FIXME: check perms&lt;/code&gt;, &lt;code&gt;XXX: temporary&lt;/code&gt; — these are not notes to the team. In a shipped artifact they are a signed confession, indexed and searchable, telling anyone who clones the repo exactly which line to look at first. This one had been published to npm. If you run one grep after reading this, make it &lt;code&gt;grep -rn "TODO.*auth\|FIXME.*perm" src/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A guarded read next to an unguarded write is the highest-signal pattern in authorization auditing.&lt;/strong&gt; Here &lt;code&gt;getUpdatedWorkflow&lt;/code&gt; returned 401 to anonymous callers while &lt;code&gt;advanceOrStopWorkflow&lt;/code&gt; — on the same feature, in the same file — returned 200 and changed the database. When the reads are protected and the mutations are not, you never have to argue about intent: the developers already documented where the boundary goes, and the state-changing path is on the wrong side of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch for ownership data that is fetched and then ignored.&lt;/strong&gt; That nested &lt;code&gt;include&lt;/code&gt; pulling &lt;code&gt;Subscription.user_id&lt;/code&gt; is a fingerprint. Somebody wrote a join whose only purpose is authorization and then didn't finish the thought. When you are reading unfamiliar code, a query that retrieves an owner id which never appears again on any code path is worth more of your attention than a hundred routes with no ownership concept at all — because it tells you the boundary was supposed to be there.&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-15&lt;/td&gt;
&lt;td&gt;Found during a source review of the assistants controller — spotted as a read/write authorization asymmetry inside a &lt;code&gt;//TODO&lt;/code&gt; block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-06-17&lt;/td&gt;
&lt;td&gt;Live PoC confirmed on &lt;code&gt;@yrpri/api&lt;/code&gt; v9.0.242: unauthenticated force-fail and unauthenticated workflow advance, with a 401 control on the guarded sibling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-10 16:48&lt;/td&gt;
&lt;td&gt;Reported privately by email to the maintainer, under coordinated disclosure (the repo had private vulnerability reporting disabled and no &lt;code&gt;SECURITY.md&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-10 17:36&lt;/td&gt;
&lt;td&gt;Maintainer replies — &lt;strong&gt;48 minutes&lt;/strong&gt; — confirming the issue and linking the fix commit &lt;code&gt;ae4b4704&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-10&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;&lt;code&gt;@yrpri/api&lt;/code&gt; 9.0.244&lt;/strong&gt; published, crediting the report by name in the release note&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;No security advisory published; 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;&lt;strong&gt;If you run Your Priorities: you want &lt;code&gt;@yrpri/api&lt;/code&gt; 9.0.244 or later.&lt;/strong&gt; Because there is no GHSA, no dependency scanner is going to tell you that.&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. Thanks to the Citizens Foundation maintainer for a same-day fix and for crediting the report publicly — that is not the norm, and it should be.&lt;/p&gt;

&lt;p&gt;If you maintain a Node service, here is a ten-minute job. List every &lt;code&gt;router.&amp;lt;verb&amp;gt;(...)&lt;/code&gt; call in your codebase. List every one that names a middleware. Diff the two. Then, for whatever is left, ask one more question of each handler: does it load an owner id it never compares? Both of those greps are shorter than you think, and both of them find real bugs.&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>node</category>
      <category>typescript</category>
      <category>appsec</category>
    </item>
    <item>
      <title>A SQL escape is not a shell escape: OS command injection in GOautodial goAPIv2</title>
      <dc:creator>Santosh Kumar Puppala</dc:creator>
      <pubDate>Wed, 02 Sep 2026 10:15:38 +0000</pubDate>
      <link>https://dev.to/santosh_kumarpuppala_96e/a-sql-escape-is-not-a-shell-escape-os-command-injection-in-goautodial-goapiv2-3gj4</link>
      <guid>https://dev.to/santosh_kumarpuppala_96e/a-sql-escape-is-not-a-shell-escape-os-command-injection-in-goautodial-goapiv2-3gj4</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What:&lt;/strong&gt; In GOautodial's &lt;code&gt;goAPIv2&lt;/code&gt; agent API, the &lt;code&gt;goPhone&lt;/code&gt; request parameter is escaped for SQL (&lt;code&gt;mysqli_real_escape_string&lt;/code&gt;) and then concatenated into an &lt;code&gt;exec()&lt;/code&gt; command string. Shell metacharacters survive the SQL escape, so a &lt;code&gt;$(...)&lt;/code&gt; payload runs on the host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; Any authenticated &lt;strong&gt;low-privilege agent&lt;/strong&gt; — a role with no legitimate shell access — gets &lt;strong&gt;arbitrary OS command execution&lt;/strong&gt; on the server running the API. CWE-78, CVSS 3.1 &lt;strong&gt;8.8 (High)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed in:&lt;/strong&gt; the installer-shipped &lt;code&gt;goAPIv2&lt;/code&gt; master branch (commit &lt;code&gt;0ab2584&lt;/code&gt;). Reported by me, &lt;strong&gt;Santosh Kumar Puppala&lt;/strong&gt; — CVE requested/pending.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;GOautodial is a widely deployed open-source call-center / predictive-dialer platform built on Asterisk. Its &lt;code&gt;goAPIv2&lt;/code&gt; component is the REST API that agent softphones talk to — it ships to &lt;code&gt;/var/www/html&lt;/code&gt; via the installer and is reachable by anyone with an agent login.&lt;/p&gt;

&lt;p&gt;Agents are the &lt;em&gt;low&lt;/em&gt; rung of the trust ladder. They log in, take calls, and hang up. They are absolutely not supposed to be able to run shell commands on the dialer host. This bug hands exactly that capability to any agent account, and on a real deployment the injected command runs through a &lt;code&gt;sudo&lt;/code&gt; wrapper — so the practical blast radius is worse than the already-High score implies.&lt;/p&gt;

&lt;p&gt;The reason this class of bug keeps shipping is that it hides behind a function whose name &lt;em&gt;sounds&lt;/em&gt; like sanitization. It escapes something. It just escapes it for the wrong language.&lt;/p&gt;

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

&lt;p&gt;The endpoint is the agent "log out" action:&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 /goAgent/goAPI.php?goAction=goLogoutUser&amp;amp;goPhone=8300&amp;amp;goSIPServer=asterisk&amp;amp;goUseWebRTC=0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reaching it needs only an ordinary active agent (&lt;code&gt;vicidial_users.user_level = 1&lt;/code&gt;, &lt;code&gt;active = 'Y'&lt;/code&gt;, &lt;code&gt;vdc_agent_api_access = 1&lt;/code&gt;). &lt;code&gt;goLogoutUser&lt;/code&gt; is on the API's no-campaign-required allowlist, so it's available to essentially every agent. As part of logging an agent out, the code checks the SIP registration for the agent's phone/extension — and that check is where &lt;code&gt;goPhone&lt;/code&gt; ends up.&lt;/p&gt;

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

&lt;p&gt;Here is the whole chain, four short hops from request parameter to shell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The value is escaped — for SQL.&lt;/strong&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;// goAgent/goAPI.php&lt;/span&gt;
&lt;span class="nv"&gt;$phone_login&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$astDB&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_GET&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'goPhone'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// goAgent/includes/MySQLiDB.php:931&lt;/span&gt;
&lt;span class="k"&gt;return&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;_mysqli&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;real_escape_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$str&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;mysqli_real_escape_string&lt;/code&gt; exists to neutralize SQL &lt;em&gt;quoting&lt;/em&gt; — quotes, backslashes, NUL. It does &lt;strong&gt;nothing&lt;/strong&gt; to shell metacharacters. Backtick, &lt;code&gt;$&lt;/code&gt;, &lt;code&gt;(&lt;/code&gt;, &lt;code&gt;)&lt;/code&gt;, &lt;code&gt;|&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt; all pass through untouched. As a shell defense, this call is decorative.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;2. The SQL-escaped value flows into a SIP check.&lt;/strong&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;// goAgent/goLogoutUser.php:54&lt;/span&gt;
&lt;span class="nf"&gt;check_sip_login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$kamDB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$phone_login&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$SIPserver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$use_webrtc&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;check_sip_login()&lt;/code&gt; is a SIP-registration lookup, not an operator command runner. Nobody reading this call site expects it to be a command sink — which is precisely why the missing validation went unnoticed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It's concatenated into an &lt;code&gt;exec()&lt;/code&gt; string.&lt;/strong&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;// goFunctions.php:460&lt;/span&gt;
&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/usr/share/goautodial/goautodialc.pl "sudo /usr/sbin/asterisk -rx \"sip show peer '&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$exten&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'\""'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&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;$exten&lt;/code&gt; is the request's &lt;code&gt;goPhone&lt;/code&gt;. PHP's &lt;code&gt;exec()&lt;/code&gt; runs its argument through &lt;code&gt;/bin/sh -c&lt;/code&gt;, so any command substitution inside &lt;code&gt;$exten&lt;/code&gt; is expanded &lt;strong&gt;by the shell, at the PHP layer&lt;/strong&gt;, before the Perl helper is ever invoked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "aha":&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The value had been escaped — the developer just escaped it for the wrong language.&lt;/strong&gt; A SQL escape stops a SQL injection; it does not stop a shell from reading &lt;code&gt;$(...)&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;I reproduced this live end-to-end against the unmodified &lt;code&gt;goAPIv2&lt;/code&gt; source (HEAD &lt;code&gt;640a31f&lt;/code&gt;) in a local PHP 8.2 + Apache + MariaDB stack, authenticated as a low-privilege agent. Everything here is a &lt;strong&gt;benign marker&lt;/strong&gt; — no destructive payload, just proof that the shell ran my command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Control — a benign extension behaves normally:&lt;/strong&gt;&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;?...&amp;amp;goAction=goLogoutUser&amp;amp;goPhone=8300&amp;amp;goSIPServer=asterisk&amp;amp;goUseWebRTC=0
→ wrapper called with: sip show peer 8300
→ /tmp/goauto_pwn : absent   (correct — nothing extra ran)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Injection — a command substitution in &lt;code&gt;goPhone&lt;/code&gt;:&lt;/strong&gt;&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="nv"&gt;goPhone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8300&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;touch&lt;/span&gt; /tmp/goauto_pwn&lt;span class="si"&gt;)&lt;/span&gt;
→ /tmp/goauto_pwn : &lt;span class="nt"&gt;-rw-r--r--&lt;/span&gt; 1 www-data www-data 0 ... /tmp/goauto_pwn   &lt;span class="o"&gt;(&lt;/span&gt;created&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;goPhone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8300&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/tmp/goauto_out&lt;span class="si"&gt;)&lt;/span&gt;
→ /tmp/goauto_out : &lt;span class="nv"&gt;uid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;33&lt;span class="o"&gt;(&lt;/span&gt;www-data&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;gid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;33&lt;span class="o"&gt;(&lt;/span&gt;www-data&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;groups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;33&lt;span class="o"&gt;(&lt;/span&gt;www-data&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The injected commands ran as &lt;code&gt;www-data&lt;/code&gt;, the Apache/PHP user. The wrapper's own call log still showed only &lt;code&gt;sip show peer 8300&lt;/code&gt;, which confirms the substitution expanded at PHP's &lt;code&gt;exec()&lt;/code&gt;/&lt;code&gt;/bin/sh -c&lt;/code&gt; layer — independent of anything the Perl helper does. On a production host that helper runs under &lt;code&gt;sudo&lt;/code&gt;, so effective privilege climbs from there.&lt;/p&gt;

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

&lt;p&gt;Never build a shell string from request-derived input. Pass the extension as a &lt;em&gt;separate, validated argument&lt;/em&gt;, and constrain it to the format it's actually allowed to be:&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;// escape for the shell, not for SQL — and validate the format first&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^\d{1,10}$/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$exten&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* reject */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'/usr/share/goautodial/goautodialc.pl '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;escapeshellarg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'sudo /usr/sbin/asterisk -rx "sip show peer '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$exten&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'"'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better still, use an &lt;code&gt;exec&lt;/code&gt; form that takes an argv array so no shell is involved at all. The upstream fix landed on the installer-shipped &lt;code&gt;goAPIv2&lt;/code&gt; master branch (commit &lt;code&gt;0ab2584&lt;/code&gt;).&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Escaped" is not a property of a string — it's a property of a string &lt;em&gt;for a specific sink&lt;/em&gt;.&lt;/strong&gt; A value that's safe for a SQL driver is not thereby safe for a shell, an HTML page, a &lt;code&gt;Runtime.exec&lt;/code&gt;, or an LDAP filter. Track &lt;em&gt;which&lt;/em&gt; escape a value carries, and re-escape at every new sink.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit by data flow, not by function name.&lt;/strong&gt; &lt;code&gt;check_sip_login()&lt;/code&gt; sounds harmless. The vulnerability was three hops downstream in a helper nobody associates with user input. Grep for &lt;code&gt;exec&lt;/code&gt;/&lt;code&gt;shell_exec&lt;/code&gt;/&lt;code&gt;system&lt;/code&gt;/&lt;code&gt;passthru&lt;/code&gt; and walk &lt;em&gt;backwards&lt;/em&gt; to the request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low-privilege reachability is what turns a code smell into an 8.8.&lt;/strong&gt; The same sink behind an admin-only screen is a minor hardening note. Behind an ordinary agent login, it's remote code execution for your least-trusted authenticated 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-07-01&lt;/strong&gt; — Found during a source audit of &lt;code&gt;goautodial/goAPIv2&lt;/code&gt;; source→sink traced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-06&lt;/strong&gt; — Reproduced live end-to-end in a local Docker stack (benign markers), verdict CONFIRMED.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reported&lt;/strong&gt; to the maintainers via coordinated disclosure; credit agreed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed&lt;/strong&gt; on the installer-shipped &lt;code&gt;goAPIv2&lt;/code&gt; master branch (commit &lt;code&gt;0ab2584&lt;/code&gt;). CVE requested/pending.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Reported and written by &lt;strong&gt;Santosh Kumar Puppala&lt;/strong&gt; — 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;/p&gt;

&lt;p&gt;If you run GOautodial, update to a build that includes commit &lt;code&gt;0ab2584&lt;/code&gt; and audit your other &lt;code&gt;exec()&lt;/code&gt; call sites for request-derived arguments while you're in there. Found this useful? Follow along — I publish one of these walkthroughs every few days.&lt;/p&gt;

</description>
      <category>security</category>
      <category>cve</category>
      <category>php</category>
      <category>appsec</category>
    </item>
    <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>
  </channel>
</rss>
