← All advisories
CVE-2026-55104Medium · CVSS 4.8· CWE-79

Stored XSS in InvoicePlane via CSV Import

Vendor
InvoicePlane
Product
InvoicePlane
Status
Published · Jun 16 2026
Researchers
eo420
CVSS Vector
CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:L/I:L/A:N
Published
Jun 17 2026

Summary

InvoicePlane protects against XSS with two layers. First, an input filter named filter_input() (in application/core/XSS_Protection_Trait.php, mixed into Admin_Controller and Guest_Controller) runs xss_clean() then strip_tags() on every POST field. Second, views encode output with htmlsc() and _htmlsc(). CSV import defeats both. Import reads cell values straight from an uploaded file and writes them to database without passing through filter_input(), so HTML and JavaScript stay intact, and a few views then print those values with a bare echo, skipping htmlsc(). Result is stored cross-site scripting (CWE-79).

An authenticated administrator who imports a crafted CSV stores JavaScript that executes in an administrator browser when an invoice opens in edit mode. Because import exists to ingest data from other systems, accountants, and clients, payload content is effectively attacker controlled even when a trusted administrator runs import. Injected script runs inside a privileged, authenticated session, so it can read CSRF tokens present in DOM and drive same-origin admin actions, which defeats HttpOnly cookie protection. Verified executing on commit d3724169 at admin invoice editor sink.

Technical Details: Root Cause

Input filter only covers POST

filter_input() reads only POST input, so any value that doesn't arrive as a POST field is never sanitized:

// application/core/XSS_Protection_Trait.php  (filter_input)
$input = $this->input->post();          // only POST is read
foreach ($input as $key => $value) {
    ...
    $cleaned_value = $this->security->xss_clean($value);
    $cleaned_value = strip_tags($cleaned_value);   // tag stripping
    $_POST[$key] = $cleaned_value;
}

CSV import writes raw cell values

Import never uses POST. It reads cells with fgetcsv() and inserts them directly. In application/modules/import/models/Mdl_import.php, import_payments() creates an unknown payment method straight from a CSV cell:

// import_payments()  (around line 371)
$this->db->insert('ip_payment_methods', ['payment_method_name' => $data[$key]]);

import_invoice_items() stores a tax rate name the same way:

// import_invoice_items()  (around line 294)
$this->db->insert('ip_tax_rates', [
    'tax_rate_name'    => $data[$key],
    'tax_rate_percent' => $data[$key],
]);

No strip_tags(), xss_clean(), or encoder runs on this path. Creating a payment method through normal admin form goes through POST and so gets cleaned, which is why this gap is easy to miss.

Output not encoded at several views

Model Mdl_Payments exposes payment_method_name on every payment row through a left join (application/modules/payments/models/Mdl_payments.php, default_join()). That value prints unescaped here:

// application/modules/invoices/views/view.php:566  (also view_sumex.php:600)
<option ... value="<?php echo $payment_method->payment_method_id; ?>">
    <?php echo $payment_method->payment_method_name; ?>
</option>

This dropdown is built from every payment method, so payload fires whenever an admin opens any invoice for editing. Same field also prints raw in two client-portal views, application/modules/guest/views/payments_index.php:38 and application/modules/guest/views/invoices_view.php:201 (the second one through a $global_taxes string built from raw invoice_tax_rate_name). Note: client-portal payments page currently returns HTTP 500 from an unrelated duplicate-join bug, so delivery to a client through that page wasn't demonstrated live, though encoding gap is present in source.

Same field is encoded elsewhere, so this is an inconsistency

Most code does encode this column. For example application/modules/payments/views/partial_payments_table.php:32 wraps payment_method_name in _htmlsc(), and PDF template application/views/invoice_templates/pdf/InvoicePlane.php:166 encodes it too. A few views just forget, and that's where stored XSS lands.

Confirmed behaviour

Importing this CSV cell stores it byte for byte, no tag stripping:

</option></select><img src=x onerror=console.log('IPXSS-PWNED:'+document.domain)>

Opening /index.php/invoices/view/1 as an administrator renders it unescaped inside an option element, breaks out of select, and fires onerror (browser console logs IPXSS-PWNED:localhost). For contrast, same stored value at /payments comes back HTML encoded (<img ...>), which confirms missing output encoding is proximate cause. Entering an identical payload through admin payment-method form instead stores it neutralized as [removed]..., which confirms filter_input() is bypassed control.