DEV Community

Santosh Kumar Puppala
Santosh Kumar Puppala

Posted on

The guard checked the URL, not the record it returned (OpenFn Lightning, GHSA-vf9q-phg3-hqj6)

TL;DR

  • What: A cross-project authorization bug in OpenFn Lightning — 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 any other project in the same instance.
  • Impact: 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 Critical.
  • Fixed in: v2.17.0 (2026-07-23). Advisory GHSA-vf9q-phg3-hqj6 (CVE requested/pending). Found through coordinated disclosure — I'm credited as a finder on the advisory alongside @lukegranto23.

Why you should care

Almost every multi-tenant app has this exact shape somewhere: a URL that carries two 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.

It reads as safe in review because there is 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.

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.

The setup

Lightning is an Elixir/Phoenix workflow-automation platform. Work is organized into projects; a project has members with roles (viewer, editor, admin). When a workflow executes it produces a run — 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:

GET /projects/<project_id>/runs/<run_id>
Enter fullscreen mode Exit fullscreen mode

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

The bug (source → sink)

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

defp handle_project_scope(socket, params) do
  project_id = params["project_id"]
  project    = Projects.get_project(project_id)
  can?       = Permissions.can?(ProjectUsers, :access_project, current_user, project)
  # Checks: is current_user a member of the project_id from the URL?
  # Does NOT check: does the run at params["id"] belong to this project?
end
Enter fullscreen mode Exit fullscreen mode

The membership check is role-agnostic — a :viewer passes just fine:

def can?(_actor, :access_project, user, project) do
  Projects.member_of?(project, user)   # user_id + project_id; any role passes
end
Enter fullscreen mode Exit fullscreen mode

diagram

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

defp get_query(id, preloads) do
  from(r in Run,
    where: r.id == ^id,      # <-- the whole WHERE clause
    preload: ^preloads
  )
end
Enter fullscreen mode Exit fullscreen mode

There is no where: ... and project_id == ^project_id. Any valid run UUID comes back regardless of which project owns it.

3. The URL project is then quietly overwritten by the run's own project. After the async fetch resolves:

def handle_async(:run, {:ok, updated_run}, socket) do
  socket =
    socket
    |> assign(:run, updated_run)
    |> assign(:project, updated_run.workflow.project)  # now Project A, not the URL's project
  ...
end
Enter fullscreen mode Exit fullscreen mode

So the page finishes rendering in the foreign project's context — and streams its log lines straight to the browser over the LiveView socket:

push_event(socket, "logs-#{run.id}", %{logs: log_lines})
Enter fullscreen mode Exit fullscreen mode

The "aha"

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.

Proof of concept (benign)

Two accounts on a build of the shipped tag, roles kept deliberately minimal:

  • Account A owns Project A, which has a run. I seeded one log line with a harmless marker: PROJECT-A-PII-LEAK-MARKER beneficiary=John_Doe nid=12345.
  • Account B is a viewer of a different project (Project C) and has no membership in Project A.

Account B requests its own project in the path, and Project A's run UUID as the record:

GET /projects/<Project_C_id>/runs/<Project_A_run_uuid>   ->  HTTP 200, RunLive mounts
Enter fullscreen mode Exit fullscreen mode

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

The control confirms the check does work — on the wrong noun:

GET /projects/<Project_A_id>/runs/<Project_A_run_uuid>   ->  302 redirect
Enter fullscreen mode Exit fullscreen mode

Naming Project A in the path (which B isn't a member of) is correctly refused. The disclosure works because 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.

The fix

Lightning already had the correctly-scoped query — the cancel path used it. Runs.get_for_project/2 joins work-order → workflow and filters on the project:

def get_for_project(run_id, project_id) do
  from(r in Run,
    join: wo in assoc(r, :work_order),
    join: wf in assoc(wo, :workflow),
    where: r.id == ^run_id and wf.project_id == ^project_id   # <-- the missing filter
  )
  |> Repo.one()
end
Enter fullscreen mode Exit fullscreen mode

The fix (shipped in v2.17.0) 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 run_viewer_live.ex, which shared the exact unscoped fetch.

Takeaways

  • Authorize the object you return, not the identifier you were handed. A membership check on a URL parameter proves nothing about a record fetched by a different parameter. Scope the query that produces the data.
  • A guarded sibling is a spec you already wrote. When one path fetches with a project_id 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.
  • "There is a check" is not "the check covers this." 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.

Disclosure timeline

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

Credit / CTA

Found and reported under coordinated disclosure by Santosh Kumar Puppala and @lukegranto23 — 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 different id — that's where this class lives.

Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq & Veridor). GitHub: @Santoshkumarpuppala

Top comments (0)