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.
