Overview

a public link bug in invoiceshelf

June 17, 2026
4 min read

TL;DR

I got CVE-2026-55383 in InvoiceShelf.

This one sat behind public customer document links.

At first glance those links looked isolated enough: each token was supposed to belong to one specific document flow, like an invoice, estimate, or payment.

The problem was that the backend trusted the token’s numeric mailable_id too much and its mailable_type not enough.

So a token issued for one document type could be replayed against another public customer endpoint as soon as the numeric IDs happened to collide.

And on the JSON endpoints, expired tokens still kept working.

The bug

The core mistake was simple.

InvoiceShelf resolved an EmailLog from the public token, then fetched the target model with a plain Model::find($emailLog->mailable_id).

That means the token lookup stopped at “here is an ID” instead of finishing the job and checking “does this token actually belong to this document type?”

The advisory phrased the vulnerable pattern pretty directly:

// JSON handler shape described in the advisory
Invoice::find($emailLog->mailable_id)

And the missing checks were just as important:

$emailLog->mailable_type
$emailLog->isExpired()

So this was not some complicated state machine bug. The token was resolved, the numeric ID was trusted, and the stronger binding checks were missing from the JSON path.

So if a payment token pointed to mailable_id = 42, and another company had an invoice with id = 42, the payment token could be replayed against the invoice endpoint and return the other company’s invoice data.

That is the part I liked here. It was not some giant auth bypass. It was a type confusion problem hiding inside a public-link flow that already looked familiar and boring.

Why it mattered

The advisory confirmed impact across all three public customer document models:

  • invoices
  • estimates
  • payments

And the affected routes were not just one odd corner.

The bug hit all six public customer document routes:

GET /customer/invoices/{email_log:token}
GET /customer/invoices/view/{email_log:token}
GET /customer/estimates/{email_log:token}
GET /customer/estimates/view/{email_log:token}
GET /customer/payments/{email_log:token}
GET /customer/payments/view/{email_log:token}

So once a token existed, the real question became whether another document in another company had the same numeric ID.

If it did, the boundary was gone.

What the attacker gets

The public advisory describes two concrete outcomes.

First, the obvious one: cross-company disclosure.

A token issued for one record type could disclose another company’s data from a different record type. The example in the advisory showed invoice JSON coming back with fields like:

  • invoice_number
  • customer.email
  • company.slug
  • line items
  • totals

Second, one of the view routes also caused state mutation.

Replaying a mismatched token against the invoice view route could flip an invoice from SENT to VIEWED, set the viewed flag, and even trigger a notification if viewed notifications were enabled.

So this was not just read-only confusion. In at least part of the flow, it also changed state across company boundaries.

The expiry bug made it worse

There was a second issue in the same public-link path.

The PDF/view handlers enforced EmailLog::isExpired(), but the JSON handlers did not.

So an expired token was still accepted indefinitely on the JSON endpoints.

That matters because it stretches the attack window from “while the link is still valid” to “basically whenever that old token still exists in somebody’s inbox or email history.”

And once you combine that with the type confusion bug, old public tokens become reusable bearer tokens against matching document IDs with no real time limit on the JSON side.

The advisory also gave two very clean request examples:

GET /customer/invoices/T
GET /customer/invoices/view/T?pdf=1

The first one showed the cross-type JSON disclosure. The second one showed that the view route could also mutate invoice state.

Root cause in one line

The public token flow bound access to mailable_id, but did not strongly bind it to the expected mailable_type, and the JSON handlers skipped expiry enforcement entirely.

That is the whole bug.

Severity and versions

The public advisory lists:

  • CVE: CVE-2026-55383
  • Severity: High
  • Weakness: CWE-285
  • Affected: versions before 2.4.0
  • Patched: 2.4.0

The advisory was published on June 12, 2026.

Why I liked it

I like bugs like this because they sit in a place people usually stop questioning.

Once an app has a “public link with token” flow, it is easy to assume the token itself already solved the access problem.

But token-based access is only as strong as the object binding behind it.

Here, the token was real. The lookup was real. The boundary still fell apart because the backend treated “same numeric ID” as good enough.

That is a very normal bug, and still a very reportable one.

Reference

Thanks for reading this blog post all the way to the end