All notable changes to InvoicePlane will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Full write-ups for individual vulnerabilities live in the published
GitHub Security Advisories
and in .github/security/. This changelog records what changed; the advisories
record why and how.
Thanks to @hariprakash6969-create, @santhoshdodo2721, @nirtem, @d3do-23, @capivara-research, @Suraj-Siddharudh, @Ishbarna, @Taxanehh, @Shad0w35, @dreamer1eh, @iamharshitgupta, @medamineelhatimi, @T0x1cG, @fr1d4yy, @sharma19d, @mkax0, @crypto-nidh, @RekhanshRajput, @0x0Axlw, @cray4367, and @krishnan-tech for responsibly disclosing the issues resolved or in progress in this release.
| Vulnerability | Severity | Security Advisory (GHSA) | Reported By | Fixed In |
|---|---|---|---|---|
| Unauthenticated access to the internal Layout controller discloses server paths via PHP error backtraces (CWE-200 / CWE-862) | — | GHSA-8cpm-3w3g-97hr | @krishnan-tech | #1809 |
| Login response-time oracle reveals account existence despite uniform error message (CWE-208 / CWE-204) | — | GHSA-9452-64v7-9g2p | @krishnan-tech | #1808 |
| CSRF on logout — plain GET with no token destroys the session (CWE-352) | Low | GHSA-wj32-69hp-233w | @krishnan-tech | #1807 |
| Admin-gated mail relay: arbitrary recipient and sender spoofing via invoice/quote mailer (CWE-288) | — | GHSA-qw48-r3gw-gc45 | @krishnan-tech | #1815 |
| Payment-amount validation race (TOCTOU, CWE-362 / CWE-367) | — | GHSA-c2qp-26qj-6gf6 | @hariprakash6969-create | #1756 |
An empty cron_key setting let the recurring-invoice cron run without a key (follow-up to the cron key hardening, CWE-306) |
— | GHSA-28fh-2656-wc63 | @Char0n1507 | #1790 |
PayPal PENDING capture treated as settled payment |
— | GHSA-w6fv-2f29-rc6g | @santhoshdodo2721 | #1724, #1772 |
| The client and user pickers returned a second JSON document listing every active client or user for an empty query (follow-up to the user search fix) | — | GHSA-g7f2-mj3r-xx88 | @PLpaPLpa | #1791 |
| Unauthenticated disclosure of sensitive financial data via static-file exposure (CWE-538 / CWE-284 / CWE-22 / CWE-668) | — | GHSA-qq8q-gf24-576m | @nirtem, @sharma19d, @mkax0 | #1716, #1783 |
The Layout helper controller was reachable by URL and rendered views without a login (sibling of the public controller methods fix) |
— | GHSA-x2m4-962x-cphw | @Char0n1507, @yeshwanthmoota | #1797 |
Object-level authorization missing in Users::delete() (CWE-639 / CWE-269) |
— | GHSA-6r23-8rf2-78h3 | @capivara-research, @sharma19d | #1713 |
| The "Remove logo" buttons did nothing after logo removal became a POST (nested form, follow-up to the CSRF fix) | — | GHSA-qx8h-35wf-cgqf | @mattmumford-git | #1792 |
Object-level authorization missing in Users/Ajax::save_user_client() (CWE-862 / CWE-269) |
— | — | @capivara-research | #1714 |
Staged import CSV files stayed in uploads/import when the import form was submitted with nothing selected (follow-up to the upload exposure fix) |
— | GHSA-qq8q-gf24-576m | @nirtem, @sharma19d, @mkax0 | #1798 |
Stored XSS via unescaped currency_symbol on public guest invoice/quote pages (CWE-79) |
— | GHSA-x4g3-fp78-5gjx | @crypto-nidh | #1744 |
| Negative item quantity/price accepted in invoice save, enabling total manipulation | — | GHSA-jvcw-w8pr-j92c | @RekhanshRajput | #1758, #1788 |
| Stripe callback replaced a specific refusal message (duplicate, already paid, mismatch) with the generic one | — | — | — | #1773 |
| A password reset token without an expiry never expired, and a pending reset link survived a password change (follow-up to the reset token expiry fixes) | — | GHSA-fwj7-c84x-jvjq, GHSA-5r28-6rw3-25c2 | @baozongwi, @ali-iltizar | #1793 |
Clickjacking on guest quote-approval pages in Docker deployments (X-Frame-Options not applied to Guest_Controller/Base_Controller) |
— | GHSA-858w-pp5p-wphm | @sharma19d | #1723 |
| User-client assignment endpoints could not load their model (follow-up to the user-client authorization fix) | — | GHSA-5rqm-w9p8-gp7w | @Shad0w35, @dreamer1eh, @Suraj-Siddharudh | #1794 |
| SQL operator precedence vulnerability in guest invoice listing (CWE-639 / CWE-862) | Medium | GHSA-w5r4-8w63-c5h2 | @d3do-23, @sharma19d | #1717 |
Stored XSS via first_day_of_week setting (JavaScript context injection) |
— | GHSA-x3r7-qm3m-hc48 | @Suraj-Siddharudh | #1751 |
| A fractional user id passed the self check of the client assignment endpoint and was stored for the next user (follow-up to the user-client authorization fix) | — | GHSA-5rqm-w9p8-gp7w | @Shad0w35, @dreamer1eh, @Suraj-Siddharudh | #1795 |
| PayPal capture not re-verified against the invoice before funds move; Stripe callback crash on uninitialized state | — | GHSA-m2c5-pmxf-qfh5, GHSA-r382-4chp-xj3p | @Ishbarna | #1749 |
Pager printed an unescaped base URL into href attributes |
— | — | — | #1774 |
| Login / password-reset-token rate-limit counter forgery via shared key column (CWE-694) | — | GHSA-r59m-wgg6-h4pv | @Taxanehh | #1748, #1765 |
| Account lockout bypassed through another spelling of the address, e.g. a trailing space (follow-up to the lockout fixes) | — | GHSA-cjwm-qx8w-hrq2, GHSA-hcxg-3465-jc8m | @polybjorn, @Char0n1507, @yeshwanthmoota | #1796 |
| Missing object-level authorization on user-client assignment endpoints (CWE-639 / CWE-862) | — | GHSA-5rqm-w9p8-gp7w | @Shad0w35, @dreamer1eh, @Suraj-Siddharudh | #1747 |
| Sessions not invalidated after a password change (CWE-613) | — | GHSA-hg6g-m4fp-7cj3, GHSA-hw8f-c8c2-g4fj | @iamharshitgupta, @mkax0, @0x0Axlw | #1746, #1764, #1766, #1768, #1789 |
| Stored XSS via custom field label in validation error alerts (CWE-79 / CWE-116) | — | GHSA-xvpx-fwvm-346g | @Shad0w35 | #1745 |
log_message('warning') entries were silently dropped by the CodeIgniter logger, including security events |
— | — | — | #1782 |
Stored XSS via unescaped currency_symbol — 15 remaining view sinks (CWE-79 / CWE-116) |
— | GHSA-gpv9-p6gj-238h | @Shad0w35, @medamineelhatimi | #1744 |
| Stored XSS in guest payment pages via unescaped payment-gateway settings (CWE-79) | — | GHSA-mp5h-3jcr-f7hm | @T0x1cG, @Suraj-Siddharudh | #1743 |
SSRF via thousands_separator / decimal_point settings and the mPDF footer filename (CWE-918) |
— | GHSA-x9qq-6v8r-pfcf, GHSA-ph5g-ffvw-r5vq | @fr1d4yy | #1742 |
| Gateway-callback payment recording race — concurrent distinct Stripe/PayPal callbacks could double-credit an invoice (TOCTOU, CWE-362 / CWE-367) | — | — | — | #1705 |
| Guests could list their own draft invoices in the paid listing | — | — | — | #1769 |
| Predictable temporary SUMEX XML filename and permissions — follow-up to the 1.7.2 fix (CWE-73 / CWE-200 / CWE-548) | — | GHSA-65qw-2ppj-gjvm, GHSA-583r-4pw9-6rc9 | @Char0n1507, @mattmumford-git | #1767 |
Remaining unsanitised SUMEX XML fields; & truncated by nodeValue — follow-up to the 1.7.2 fix (CWE-91) |
— | GHSA-j7f7-999x-v4gx | @Char0n1507, @yeshwanthmoota | #1770 |
| The update check and the news panel read from the GitHub Releases API; their requests are bounded and non-list answers fall back | — | — | @sabinich | #1777, #1787 |
CSRF check trusted a tokenless POST to a csrf_exclude_uris URI (follow-up to the invoice-delete fix, #1694) |
— | GHSA-mhvh-4j3w-7pvj | @venkatesh2003631 | #1784 |
Stored XSS via unescaped system_theme setting, reaching the unauthenticated login page (CWE-79 / CWE-116 / CWE-20 / CWE-693) |
High | GHSA-hmqm-59ff-gmcw | @cray4367 | #1805 |
-
Unauthenticated access to the internal
Layoutcontroller discloses server paths via PHP error backtraces (CWE-200 / CWE-862):LayoutextendsMX_Controllerrather thanAdmin_Controller/Guest_Controller, so it inherited no authentication gate, and its public methods (render,load_view,buffer,set) — meant to be called only internally as$this->layout->render()by already-authenticated controllers — were directly HTTP-routable to anonymous clients. Reached unauthenticated, the internal view loader ran with helpers/libraries never autoloaded in that context, raising PHP errors that (withdisplay_errorson) disclosed the server's absolute install path and internal file layout to an anonymous attacker, e.g.GET /layout/load_view/dashboard. Path-traversal/LFI toward other files was tested and ruled out (Apache'sAllowEncodedSlashes Offblocks it), so the demonstrated impact is scoped to information disclosure. Fix: a_remap()onLayoutnowshow_404()s any direct HTTP route to the controller; the normal internal$this->layout->render()/buffer()/set()/load_view()usage is unaffected, since that never goes through HTTP routing. Thanks to @krishnan-tech for responsible disclosure. #1809 -
Login response-time oracle reveals account existence despite a uniform error message (CWE-208 / CWE-204):
Sessions::login()deliberately returns the same generic "credentials incorrect" message for every failed login, butMdl_Sessions::auth()only reached the bcryptpassword_verify()call when a row for the submitted email existed, returning immediately otherwise. This produced a measurable, reproducible response-time difference (~2x in testing) that revealed whether an email was a registered account, narrowing targets for password-reset abuse, phishing, and credential stuffing. Fix:auth()now runs a bcrypt comparison against a fixed decoy hash when no account exists, so the non-existent-email path takes about as long as the real one. Thanks to @krishnan-tech for responsible disclosure. #1808 -
CSRF on logout — a plain GET with no token destroys the session (CWE-352):
Sessions::logout()destroyed the session on a plain GET with no CSRF check. Because the session cookie can carry on a top-level cross-site navigation (a link click,window.location, a redirect), an attacker page could force an authenticated victim's session to end simply by getting them to navigate to/sessions/logout. Fix: logout is now a state-changing action like any other in this app — it requires a POST carrying a valid_csrf_field()token (checked the same waySessions' own new-password POST already does, sinceSessionsis aBase_Controllerand cannot callensure_valid_post_request()), and the logout link in the navbar/guest layout is a small same-origin form rather than a plain<a href>. Thanks to @krishnan-tech for responsible disclosure. #1807 -
Admin-gated mail relay: arbitrary recipient and sender spoofing via invoice/quote mailer (CWE-288): The
send_invoice()andsend_quote()endpoints accepted fully attacker-controlledto_email,from_email,from_name,cc, andbccparameters, validated only for RFC syntax. An authenticated admin could send branded invoice/quote PDFs from arbitrary sender identities to arbitrary recipient addresses, enabling phishing and data exfiltration. Fix: recipient is now bound to the invoice/quote's client email; the From address is pinned to the configured organization email (smtp_mail_fromsetting, or the associated user email as fallback); user-suppliedto_email/from_email/from_nameare ignored. CC/BCC remain optional but syntax-validated. Form views updated to show read-only recipient/sender info instead of editable fields. Thanks to @krishnan-tech for responsible disclosure. #1815 -
Stored XSS via
first_day_of_weeksetting (JavaScript context injection) (GHSA-x3r7-qm3m-hc48): thefirst_day_of_weeksetting was echoed directly into a JavaScript string literal in the datepicker initialization inhead.php, with only HTML escaping applied — which does not protect a JavaScript string context. An authenticated administrator could set the value to a payload that breaks out of the string literal and executes arbitrary JavaScript in the browser of every authenticated user who loads a page with a datepicker. Fix: a newencode_for_javascript_string()helper escapes backslashes, quotes, line terminators, and forward slashes before the value is embedded in the script, andSettings::index()now validatesfirst_day_of_weekagainst the valid0–6range server-side, rejecting and logging anything else. Thanks to @Suraj-Siddharudh for responsible disclosure. #1751 -
Stored XSS via unescaped
currency_symbolon public guest invoice/quote pages (CWE-79) (GHSA-x4g3-fp78-5gjx): a duplicate report of the sameformat_currency()escaping gap closed by GHSA-gpv9-p6gj-238h, scoped specifically to the unauthenticated guest invoice/quote pages. Already fixed by the same single-choke-pointhtmlsc()applied innumber_helper.php'sformat_currency(). Thanks to @medamineelhatimi, @Shad0w35, @crypto-nidh for responsible disclosure. #1744 -
Negative item quantity/price accepted in invoice save, enabling total manipulation (GHSA-jvcw-w8pr-j92c): invoice/quote item quantities and prices were accepted without a non-negative check, letting an authenticated user drive an invoice's total — and the balance owed — negative or arbitrarily low by entering negative values. Fix: item quantity and price are now validated as non-negative before save. Thanks to @RekhanshRajput for responsible disclosure. #1758. A follow-up makes the invoice and quote save stop after a validation error response, so a refused invoice number, a nameless item or a failed custom-field save no longer lets the rest of the save run in autocommit mode (#1788)
-
Clickjacking on guest quote-approval pages in Docker deployments (CWE-1021) (GHSA-858w-pp5p-wphm): the bundled Docker nginx configuration did not carry forward the
X-Frame-Optionsheader set elsewhere in the app forGuest_Controller/Base_Controllerresponses, so a Docker deployment's guest-facing quote-approval page could be framed by an attacker's page and tricked into a clickjacked approval. Fix:X-Frame-Optionsis now applied directly inGuest_Controller/Base_Controllerrather than relying solely on the reverse-proxy config. Thanks to @sharma19d for responsible disclosure. #1723 -
Gateway-callback payment recording race (TOCTOU, CWE-362 / CWE-367). The guest Stripe (
callback) and PayPal (paypal_capture_payment) endpoints recorded an online payment in three separate, non-atomic steps: readinvoice_balance, check it, then insert intoip_payments. Two callbacks arriving concurrently for the same invoice, each carrying a distinct gateway reference (distinctpayment_intent/capture_id, so the existingpayment_external_iddedup matches neither — this is the concurrent variant of the replay issue behind GHSA-6cpc-hr8h-xgr2), could both pass the balance check before either committed, double-crediting the invoice and drivinginvoice_balancenegative. Fix:Mdl_Payments::record_external_payment()collapses the guard and the insert into one atomic operation — a single conditionalUPDATEonip_invoice_amountsthat only succeeds whileinvoice_balance > 0, serialized by InnoDB on that row, followed by anINSERT IGNOREso a reference that still raced past a wider balance is dropped by the uniquepayment_external_idindex rather than erroring. The044_1.7.3.sqlmigration adding that index is also repaired in place to make itUNIQUE(it shipped non-unique), reconciling any duplicate rows recorded during that window without deleting history. #1705 -
Payment-amount validation race (TOCTOU, CWE-362 / CWE-367).
Mdl_Payments::validate_payment_amount()readinvoice_balanceandMdl_Payments::save()inserted the payment row as separate, non-atomic steps. Concurrent payment submissions from distinct authenticated admin sessions for the same invoice could each read the same stale balance and pass validation before either committed, driving the invoice into an overpaid (negative-balance) state. Both admin payment entry points (Payments::form(),Ajax::add()) now serialize on the invoice with a newPaymentCallbackLocklibrary: a connection-scoped MySQL advisory lock, so a losing submission blocks until the winner commits, then re-validates against the now-current balance. Note on scope: this is distinct from gateway replay deduplication (the unique index onip_payments.payment_external_id, shipped in 1.7.3). That index does not exist on installations still running the released 1.7.2; this fix does not add it retroactively — it only closes the admin-side balance race. Reported by @hariprakash6969-create. #1756 -
PayPal PENDING capture status treated as settled payment (unconfirmed funds): PayPal can return a
PENDINGstatus for captures that have not yet settled, indicating that the transaction is in progress but funds have not yet reached the merchant account. The PayPal controller accepted bothCOMPLETEDandPENDINGstatuses as valid payment settlement indicators, causing PENDING transactions to be recorded as paid invoices and reducing the customer's outstanding balance before PayPal confirmed settlement. This created reconciliation issues and potential double-charging if a PENDING transaction subsequently failed. Fix: onlyCOMPLETEDcaptures are now recorded as settled payments throughrecord_external_payment().PENDINGcaptures are logged tomerchant_responsesfor tracking and audit purposes, but do NOT update the invoice balance or mark the invoice as paid. The customer is notified that their payment is pending settlement, awaiting PayPal webhook confirmation. Recommendation: existing deployments should review any invoices paid via PayPal in the last 90 days and verify that PENDING transactions have not been incorrectly marked as settled. Thanks to @santhoshdodo2721 for responsible disclosure. -
Unauthenticated disclosure of sensitive financial data via static-file exposure (CWE-538, CWE-284, CWE-22, CWE-668): nginx deployments using the project's bundled configuration did not deny direct access to the
/uploads/subdirectories. The at-rest protection ofuploads/customer_files/,uploads/archive/,uploads/import/, anduploads/temp/relied on.htaccessfiles containingDeny from all, which nginx does not honor. An unauthenticated attacker could directly download raw import CSV files (client names, addresses, payment amounts), archived invoice PDFs (enumerable by date + invoice number), and temporary files, bypassing all application-level access control. Fix: explicitlocation ^~ /uploads/<subdir>/deny blocks added toresources/docker/nginx/invoiceplane.conf; import CSV files are now deleted immediately after processing (not persisted to disk); archived PDF filenames are randomized (32-hex-character token instead ofYYYY-MM-DD_<name>pattern) to prevent enumeration. Recommendation: existing nginx deployments should upgrade this configuration block and consider movinguploads/outside the web root (served only through authenticated PHP endpoints). (GHSA-qq8q-gf24-576m) Thanks to @nirtem, @sharma19d, and @mkax0 for responsible disclosure. #1716 -
The Layout controller is no longer routable (follow-up to GHSA-x2m4-962x-cphw):
Layoutis a helper other controllers call directly but extendsMX_Controllerwith public methods, so/layout/layout/renderanswered 200 to a visitor without a session._remap()now answers 404; the direct calls are unaffected. Thanks to @Char0n1507 and @yeshwanthmoota for responsible disclosure. (GHSA-x2m4-962x-cphw, #1797) -
Object-level authorization missing in
Users::delete()— peer admins could delete other admins (CWE-639 / CWE-269): TheUsers::delete()endpoint validated that the target user was not the primary administrator, but did not check whether the acting user had permission to delete another user's account. A secondary administrator (user_type=1,user_id != 1) could delete other administrator accounts. The fix adds anis_primary_administrator()authorization guard consistent withform()andchange_password(), allowing only the primary admin to delete other users. Sessions are invalidated on delete for immediate privilege revocation. (GHSA-6r23-8rf2-78h3) Thanks to @capivara-research and @sharma19d for the responsible disclosure. #1713 -
Object-level authorization missing in
Users/Ajax::save_user_client()— peer admins could assign clients to any user (CWE-862 / CWE-269): The AJAX endpoint that assigns clients to users lacked authorization checks, allowing any authenticated user to modify another user's client assignments without permission. A secondary administrator could reassign another user's clients, disrupting data isolation. The fix adds the same authorization pattern as the main Users controller: only the primary admin may assign clients to other users; regular users may only modify their own client list. Thanks to @capivara-research for the responsible disclosure. #1714 -
Import CSV files are deleted on every submit (follow-up to GHSA-qq8q-gf24-576m): the cleanup ran only when at least one file was ticked, so submitting the import form with nothing selected left the uploaded client, invoice and payment CSVs in
uploads/import. The cleanup now always runs. Thanks to @nirtem, @sharma19d and @mkax0 for responsible disclosure. (GHSA-qq8q-gf24-576m, #1798) -
SQL operator precedence vulnerability in guest invoice listing — ungrouped OR filters escape client scoping (GHSA-w5r4-8w63-c5h2, CWE-639 / CWE-862): The
Mdl_invoices::is_paid()method uses an ungroupedfilter_or_where()condition that, when combined with the guest controller's client-scopingwhere_in(), allows the OR to bind more tightly than the AND, leaking invoices from other clients. The SQL evaluates asWHERE status=4 OR (balance='0.00' AND client_id IN (...))instead of the intendedWHERE (status=4 OR balance='0.00') AND client_id IN (...). Guest users can enumerate invoices from other clients by exploiting the zero-balance or paid-status conditions. The initial fix in #1715 wrapped both conditions ingroup_start()/group_end(), but those run immediately against the query builder whilefilter_where()/filter_or_where()are deferred byMY_Model::__call()and only applied later byrun_filters()— so the group closed empty and the vulnerability was not actually fixed. #1717 corrects this usingfilter_group_start()/filter_group_end(), which defers the grouping into the same queue as the two conditions. CVSS 5.3 (Medium — Unauthorized Information Disclosure). Thanks to @d3do-23 and @sharma19d for responsible disclosure. -
Login rate-limit counter forgery via the shared
ip_login_logkey column (CWE-694): five different counters (per-account login lockout, login-IP throttle, password-reset IP and email throttles, cron-key throttle) shareip_login_log.login_name. The four throttles use namespaced<prefix>:<sha256>keys, but the per-account login lockout — and the password-reset token counter — wrote the raw, unvalidated value the client submitted, so an unauthenticated attacker could submit another counter's key (for examplelogin_ip:<sha256(ip)>) as an email address or reset token and deliberately fail, locking out a chosen IP, blocking password recovery for an account, or tripping the cron throttle. Fix: both counters now use namespaced keys of the same shape (login_account:<sha256(lower(email))>andpassword_reset_token:<sha256(token)>), so submitted input can no longer address another counter. Counters keyed the old way are migrated on first use: the row is renamed to its namespaced key and keeps its count and timestamp, so an active lockout survives the upgrade (if both a legacy and a namespaced row exist, the higher count is kept). A password reset also clears such a not-yet-migrated row, so the owner is not locked out after choosing a new password (#1765). Thanks to @Taxanehh for responsible disclosure. (GHSA-r59m-wgg6-h4pv) -
Reset tokens fail closed (follow-up to GHSA-fwj7-c84x-jvjq and GHSA-5r28-6rw3-25c2): a token row without a stored expiry was accepted forever, and a reset link requested before a password change stayed usable after it. A missing expiry is now treated like a malformed one, and changing a password clears the pending token and its expiry. Thanks to @baozongwi and @ali-iltizar for responsible disclosure. (GHSA-fwj7-c84x-jvjq, GHSA-5r28-6rw3-25c2, #1793)
-
Missing object-level authorization on the user-client assignment endpoints (CWE-639 / CWE-862): after
Users/Ajax::save_user_client()received its primary-administrator-or-self check, the same write semantics remained unguarded inUser_Clients::create(),User_Clients::delete()andUsers::delete_user_client(). A secondary administrator could grant or revoke any user's client assignments, and — by postinguser_all_clientsfor their own account — bulk-assign every client in the system to themselves, gaining read access to every invoice, quote and payment. Fix: a sharedMdl_User_Clients::can_manage_user_clients()check (primary administrator or the account owner) now guards all three endpoints, including theuser_idthe model saves from POST data; the assignment being deleted must belong to the user in the URL; only the primary administrator may setuser_all_clients; and the id must be a plain integer (a value such as2.9would otherwise be authorized as user 2 but stored as user 3). Independently found and reported again by @Suraj-Siddharudh via theUser_clients::create()/delete()routes. Thanks to @Shad0w35, @dreamer1eh, and @Suraj-Siddharudh for responsible disclosure. (GHSA-5rqm-w9p8-gp7w) -
Stored XSS via custom field label in validation error alerts (CWE-79 / CWE-116):
Validatorinterpolated the custom field label and error message into the flash alert HTML without escaping, so a label containing markup executed when a validation error was displayed. Fix: both are escaped withhtmlsc()before being formatted into the message. Thanks to @Shad0w35 for responsible disclosure. (GHSA-xvpx-fwvm-346g) -
Stored XSS via unescaped
currency_symbol— 15 remaining view sinks (CWE-79 / CWE-116): the earliercurrency_symbolfix escaped the value in the formatting helpers but 15 view templates (invoice and quote item lists, discount inputs, the product and task forms) still echoed the setting raw, so a stored payload executed in administrator and guest sessions. Fix: every remaining sink escapes the value on output. Thanks to @Shad0w35 and @medamineelhatimi for responsible disclosure. (GHSA-gpv9-p6gj-238h) -
Stored XSS in guest payment pages via unescaped payment-gateway settings (CWE-79): PayPal and Stripe settings (client id, currency, API key) were echoed bare into single-quoted JavaScript string literals on the guest payment pages. Breaking out of a JS string needs no HTML metacharacter, so the
xss_clean()input filter did not stop it, and the script ran for unauthenticated customers paying an invoice. Fix: every value is emitted withjson_encode()usingJSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOTinguest/views/gateways/paypal.phpandstripe.php. Thanks to @T0x1cG for responsible disclosure. (GHSA-mp5h-3jcr-f7hm) -
Self password change no longer reads a
user_auth_versionthat does not exist (follow-up to GHSA-hw8f-c8c2-g4fj): the self password change inUsers::change_password()read$updated_user->user_auth_version, a column this branch does not have, so it raised an undefined-property warning and stored0in the session, and a comment described a check that is not there. The read is removed; the session stays bound to the new password through its credential fingerprint. Thanks to @mkax0, @iamharshitgupta and @0x0Axlw for responsible disclosure. (GHSA-hg6g-m4fp-7cj3, GHSA-hw8f-c8c2-g4fj, #1766) -
Recurring-invoice cron refused when no cron key is configured (follow-up to GHSA-28fh-2656-wc63): with a blank
cron_keysetting a request without a key compared an empty string with an empty string and was accepted, running the job unauthenticated and bypassing the failed-attempt limiter. An empty configured key is now treated as a wrong key. Thanks to @Char0n1507 for responsible disclosure. (GHSA-28fh-2656-wc63, #1790) -
SSRF via
thousands_separator/decimal_pointsettings and the mPDF footer filename (CWE-918): the number-format settings and the document filename were concatenated unescaped into HTML that mPDF renders, and mPDF fetches remote resources referenced in that HTML — so a user able to write settings (or a crafted filename) could make the server issue requests to arbitrary hosts. Fix:format_currency(),format_amount()andformat_quantity()now escape both separators withhtmlsc(), and the filename in the invoice/quote footer is passed throughhtmlspecialchars(). Thanks to @fr1d4yy for responsible disclosure. (GHSA-x9qq-6v8r-pfcf, GHSA-ph5g-ffvw-r5vq) -
Recurring-invoice cron no longer burns an invoice number when it loses the claim (follow-up to GHSA-hvvh-fjx5-9j55): the cron drew the next number from the invoice group before it claimed the recurring invoice, so a run that lost the claim to a concurrent run consumed a number without creating an invoice. The number is now drawn after the claim, inside the same transaction, with the group row locked. Thanks to @Char0n1507 for responsible disclosure. (GHSA-hvvh-fjx5-9j55)
-
Sessions not invalidated after a password change (CWE-613): changing or resetting a password only rewrote the bcrypt hash;
User_Controllerre-validated the account's role and active state on each request but nothing tied a session to the password it was created with, so a session opened with the old (possibly compromised) password stayed authorized until it expired — ten days by default. Fix:Mdl_Sessions::auth()stores an HMAC fingerprint of the password hash in the session (session_credential_fingerprint()inip_security_helper.php), andUser_Controllercompares it with the current hash on every request and destroys the session on mismatch. A user changing their own password keeps their current session; every other session of that account ends on its next request. Sessions that predate this fix are bound to the current password on their first request. Two follow-ups: a self password change no longer reads auser_auth_versionproperty that does not exist on this branch (#1766), andMdl_sessions::invalidate_user_sessions(), the helper meant to end a user's other sessions, read the session path from a property that does not exist and parsed the session files withunserialize(), which cannot read PHP's session file format, so it deleted nothing; it now reads the configured session path and the top-leveluser_identry directly, and only touches files named like this application's sessions (#1768). Thanks to @mkax0, @iamharshitgupta and @0x0Axlw for responsible disclosure. (GHSA-hg6g-m4fp-7cj3, GHSA-hw8f-c8c2-g4fj) -
Clickjacking headers are validated before they are sent (follow-up to GHSA-858w-pp5p-wphm): the admin, guest and base controllers each sent
X-Frame-Optionsstraight from the rawX_FRAME_OPTIONSvalue, so an invalid setting (ALLOWALL, a typo) produced an invalid header that browsers ignore, andDENYstill sent a CSP offrame-ancestors 'self'. One helper,security_frame_headers(), now accepts onlySAMEORIGINorDENY(anything else falls back toSAMEORIGIN) and derives the matching CSP; all three controllers use it, so admin pages now carry the CSP too. Thanks to @sharma19d for responsible disclosure. (GHSA-858w-pp5p-wphm) -
invalidate_user_sessions()deleted nothing, and now only touches real session files (follow-up to GHSA-hw8f-c8c2-g4fj): the helper meant to end a user's other sessions read the session path from a property that does not exist and parsed the session files withunserialize(), which cannot read PHP's session file format, so no session was ever removed. It now reads the configured session path, only considers files named like this application's sessions (the save path defaults to the shared system temp directory) and reads the top-leveluser_identry, so text inside another value cannot pass for the owner. Thanks to @mkax0, @iamharshitgupta and @0x0Axlw for responsible disclosure. (GHSA-hg6g-m4fp-7cj3, GHSA-hw8f-c8c2-g4fj, #1768) -
A copy of the session that changes its own password stayed valid (follow-up to GHSA-hw8f-c8c2-g4fj): the session performing a self password change was re-bound to the new credentials but kept its session id, so a stolen cookie of that same session still worked. The session id is now regenerated and the old session destroyed, so the owner stays logged in and the copy does not. Thanks to @mkax0, @iamharshitgupta and @0x0Axlw for responsible disclosure. (GHSA-hg6g-m4fp-7cj3, GHSA-hw8f-c8c2-g4fj, #1789)
-
PayPal capture moved funds before the local invoice checks, and always logged success (CWE-391 / CWE-754 / CWE-841):
Paypal::paypal_capture_payment()calledcaptureOrder()first and only afterwards checked the invoice (still public, still unpaid, currency, amount, remaining balance). If the invoice changed between order creation and capture — balance raised or lowered, paid another way, cancelled — the customer had already been charged but no payment was recorded, a cancelled invoice ended in an uncaught exception (HTTP 500), and theip_merchant_responsesrow was written as successful regardless. Fix: the order is now read back from PayPal and verified against the current invoice (public, unpaid, same currency, amount equal to the balance) before anything is captured; if the check fails nothing is captured and the customer is told to retry. The uncaught post-capture exception is gone, and the merchant response is only logged as successful when the capture was actually recorded — otherwise it is flagged "captured at PayPal, NOT recorded locally" and an error is logged for manual reconciliation. Thanks to @Ishbarna for responsible disclosure. (GHSA-m2c5-pmxf-qfh5) -
The balance check only counts the amount of a payment that belongs to the invoice (follow-up to GHSA-c2qp-26qj-6gf6): when a payment is edited, its old amount is added back to the invoice balance before the new amount is checked. The payment was looked up by the posted
payment_idalone, so naming a payment of a different invoice added that invoice's amount to this one and let an overpayment pass the "cannot exceed the balance" check. The lookup is now limited to the posted invoice, and an unknown id adds nothing. Thanks to @hariprakash6969-create for responsible disclosure. (GHSA-c2qp-26qj-6gf6) -
Session cookie pinned to
SameSite=Lax, and document numbers whitelisted in PDF and XML filenames (follow-ups to GHSA-858w-pp5p-wphm and GHSA-54xh-8h4r-4f3r):sess_samesiteis now set explicitly instead of relying on the library default, so a cross-site framed page cannot ride on the visitor's session. Invoice, quote and Sumex PDF filenames, and the invoice XML filename, now go throughsanitize_document_number_for_filename()instead of a denylist of\and/, which let drive letters and other path characters through. Thanks to @sharma19d, @Char0n1507 and @yeshwanthmoota for responsible disclosure. (GHSA-858w-pp5p-wphm, GHSA-54xh-8h4r-4f3r) -
Unauthenticated requests to the payment-gateway controllers trigger outbound API calls; Stripe callback crashes on uninitialized state:
guest/gateways/stripe/callback/<id>is reachable without authentication. For any invalid id the Stripe call threw, and thefinallyblock then dereferenced$sessionand$invoice, which were never assigned, attempted an insert with a NULLinvoice_idinto a NOT NULL column, and ended in an HTTP 500 without the intended redirect — at the cost of one Stripe API call per request. Separately, every request that reached the PayPal controller made a live OAuth token request from the library constructor. Fix: the Stripe callback rejects ids that are not a well-formed Checkout Session id before any API call, initialises its state before thetry, and only writes the merchant response when an invoice was resolved;PaypalLibnow authorizes lazily, on the first real API call. Thanks to @Ishbarna for responsible disclosure. (GHSA-r382-4chp-xj3p) -
Payment-amount validation race (TOCTOU, CWE-362 / CWE-367) (GHSA-c2qp-26qj-6gf6): Thanks to @hariprakash6969-create for responsible disclosure. #1756
-
PayPal
PENDINGcapture treated as settled payment (GHSA-w6fv-2f29-rc6g): Thanks to @santhoshdodo2721 for responsible disclosure. A follow-up records the capture id in thePENDINGaudit row's reference, which is what PayPal reports when the capture settles or is denied, so the row can be reconciled later (#1772). #1724 -
Client and user pickers stop after an empty query (follow-up to GHSA-g7f2-mj3r-xx88):
name_query()answered an empty query with[]and then ran the search with a bare wildcard, appending a second JSON document that listed every active client or user. Both methods now return after the empty response. Thanks to @PLpaPLpa for responsible disclosure. (GHSA-g7f2-mj3r-xx88, #1791) -
SQL injection via string concatenation in the Ajax
having()clause (GHSA-g7f2-mj3r-xx88): Thanks to @PLpaPLpa for responsible disclosure. #1630 -
Authorization bypass through user-controlled key (IDOR) (GHSA-h4xh-4jwc-485r): Thanks to @0raN9ewww, @sharma19d for responsible disclosure. #1626
-
Disabled users can still authenticate (GHSA-jw57-6692-jh2q): Thanks to @mattmumford-git for responsible disclosure. #1621
-
User-client assignment works again (follow-up to GHSA-5rqm-w9p8-gp7w): assigning clients to a user, removing an assignment and creating a user with clients selected loaded the model from the wrong module and failed with "Unable to locate the model", so the authorization checks never ran. All five loads now use
user_clients/mdl_user_clients. Thanks to @Shad0w35, @dreamer1eh and @Suraj-Siddharudh for responsible disclosure. (GHSA-5rqm-w9p8-gp7w, #1794) -
Missing CSRF protection on state-changing delete / logo actions (GHSA-qx8h-35wf-cgqf): Thanks to @mattmumford-git for responsible disclosure. #1559, #1561, #1622, #1624
-
Sensitive import CSV disclosure via web-accessible import directory (GHSA-wv2c-c285-9hrq): Thanks to @mattmumford-git for responsible disclosure. #1623
-
Login rate-limit counter forgery via the shared
ip_login_logkey column (CWE-694): five different counters (per-account login lockout, login-IP throttle, password-reset IP and email throttles, cron-key throttle) shareip_login_log.login_name. The four throttles use namespaced<prefix>:<sha256>keys, but the per-account login lockout — and the password-reset token counter — wrote the raw, unvalidated value the client submitted, so an unauthenticated attacker could submit another counter's key (for examplelogin_ip:<sha256(ip)>) as an email address or reset token and deliberately fail, locking out a chosen IP, blocking password recovery for an account, or tripping the cron throttle. Fix: both counters now use namespaced keys of the same shape (login_account:<sha256(lower(email))>andpassword_reset_token:<sha256(token)>), so submitted input can no longer address another counter. Counters keyed the old way are not carried over, so active login throttles reset once on upgrade. Thanks to @Taxanehh for responsible disclosure. (GHSA-r59m-wgg6-h4pv) -
Stored XSS in guest payment pages via unescaped payment-gateway settings (CWE-79): PayPal and Stripe settings (client id, currency, API key) were echoed bare into single-quoted JavaScript string literals on the guest payment pages. Breaking out of a JS string needs no HTML metacharacter, so the
xss_clean()input filter did not stop it, and the script ran for unauthenticated customers paying an invoice. Fix: every value is emitted withjson_encode()usingJSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOTinguest/views/gateways/paypal.phpandstripe.php. Thanks to @T0x1cG and @Suraj-Siddharudh for responsible disclosure. (GHSA-mp5h-3jcr-f7hm) -
encode_for_javascript_string()did not actually escape JS line terminators: the fix for GHSA-x3r7-qm3m-hc48 (#1751) added replacement entries for U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR), but each entry's replacement value was the same raw character as its search key (both characters render as blank whitespace in an editor, so the mistake wasn't visible). Thestr_replace()call was therefore a no-op for both: afirst_day_of_weekvalue containing either character passed straight through unescaped. Modern JS engines (ES2019+) now permit these characters unescaped inside a string literal, so this is a correctness/robustness bug rather than an active XSS bypass, but it defeats the documented intent of the helper. Replacement values are now the unambiguous escape"\x20"instead of a literal character that can be visually confused with the one it's meant to replace. Found while porting the fix to the fork'sprep/v180branch; no external reporter. -
Temporary SUMEX XML now has an unpredictable name and owner-only permissions (CWE-73 / CWE-200 / CWE-548): the 1.7.2 fix moved the file out of
uploads/temp/, but the name was still<invoice number>.xmland the file world-readable, so anyone able to reachstorage/temp/could guess it. The name now carries 16 random hex characters and the file is created owner-only from the start (a failed write or permission change is reported and no path is handed out). Thanks to @Char0n1507 and @mattmumford-git for responsible disclosure. (GHSA-65qw-2ppj-gjvm, GHSA-583r-4pw9-6rc9, #1767) -
Remaining SUMEX XML fields sanitised;
&no longer truncates names (CWE-91): the 1.7.2 fix addedsanitizeXmlText()but leftitem_name, the company GLN and RCC, the case number, the insured number and the patient AVS number unsanitised, so a control character in any of them still produced a document that is not well-formed; and text assigned throughDOMNode::nodeValuewas cut at an&. Those fields are sanitised and text is now added as text nodes. Thanks to @Char0n1507 and @yeshwanthmoota for responsible disclosure. (GHSA-j7f7-999x-v4gx, #1770) -
verify_csrf_token()trusted CodeIgniter's CSRF check even for URIs it never checked: the fix for the "unable to delete an invoice" error (#1694) accepted a POST whose submitted token wasnullon the assumption that CodeIgniter had already validated and consumed it. For a URI incsrf_exclude_uristhe framework returns early and never touches the token, so a tokenless POST would have been accepted there. The shortcut now applies only to URIs that are not CSRF-excluded; excluded URIs go through the explicit token comparison. Thanks to @venkatesh2003631 for responsible disclosure. (GHSA-mhvh-4j3w-7pvj, #1784) -
Stored XSS via unescaped
system_themesetting (CWE-79, leading to CWE-116 / CWE-20 / CWE-693):system_themewas persisted bySettings::index()with no server-side allowlist — unlikefirst_day_of_week, which already has a guard in the same loop — and echoed unescaped by_theme_asset()into a double-quotedhref="..."attribute on 18 views, including the unauthenticated login page, so a value planted by any administrator (not just the primary one) executed in the browser of every visitor, logged in or not. An attribute-breakout payload (x" onerror="..." x=") contains no<and matches none ofxss_clean()'s tag- or keyword-gated filters, so the global input filter did not stop it either. Fix:Settings::index()now validatessystem_themeagainst the same theme list its own<select>is built from, and_theme_asset()escapes the value on output as defense-in-depth. Thanks to @cray4367 for responsible disclosure. #1805
-
Empty
SESS_SAVE_PATHno longer breaks session startup — fixed in code, not just packaging. 1.7.3 shippedipconfig.php.examplewith the line commented out, but any install that still hadSESS_SAVE_PATH=in itsipconfig.php(an upgrade, or a copy made before 1.7.3) stayed broken:env()returns a set-but-empty value as"", soapplication/config/config.phpset$config['sess_save_path'] = "", CodeIgniter'sSession_files_driverranini_set('session.save_path', '')— overridingphp.ini/php-fpm.d/ the vhost — and session startup failed (mkdir('')), so login broke and the manual installer stalled on.../setup/language.config.phpnow collapses an empty or whitespace-onlySESS_SAVE_PATHtosys_get_temp_dir(), exactly matching an unset one; an explicit path is unchanged. -
PayPal
PENDINGcapture never wrote its audit row: thePENDINGbranch added with the fix that stopped recording pending captures as settled payments read$capture_dataand$capture_id, which are only defined inside theCOMPLETEDbranch. The invoice id was always empty, so the "pending – awaiting settlement" merchant-response row was never written and PHP raised undefined-variable warnings. The branch now reads the capture itself. No payment is recorded for a pending capture, as before. Thanks to @santhoshdodo2721 for responsible disclosure. -
encode_for_javascript_string()did not actually escape JS line terminators: the fix for GHSA-x3r7-qm3m-hc48 (#1751) added replacement entries for U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR), but each entry's replacement value was the same raw character as its search key (both characters render as blank whitespace in an editor, so the mistake wasn't visible). Thestr_replace()call was therefore a no-op for both: afirst_day_of_weekvalue containing either character passed straight through unescaped. Modern JS engines (ES2019+) now permit these characters unescaped inside a string literal, so this is a correctness/robustness bug rather than an active XSS bypass, but it defeats the documented intent of the helper. Replacement values are now the unambiguous escape"\x20"instead of a literal character that can be visually confused with the one it's meant to replace. Found while porting the fix to the fork'sprep/v180branch; no external reporter. #1760 -
Custom public invoice/quote templates in
CUSTOM_TEMPLATES_FOLDERreturned HTTP 500 on the guest view:CUSTOM_TEMPLATES_FOLDERplus theCUSTOM_INVOICE_TEMPLATES_PUBLIC/CUSTOM_QUOTE_TEMPLATES_PUBLICallowlists (added for the 1.7.2 RCE hardening) already worked for PDF templates, but a template selected for the guest-facing public view crashed instead of rendering. Two bugs inapplication/helpers/template_helper.php:get_validated_template_path()validated the resolved file againstAPPPATH . 'views/'only, so a template that exists solely inCUSTOM_TEMPLATES_FOLDERalways failed that check and hitshow_error(..., 500), even thoughrender_template_view()already gives the custom folder precedence; andrender_template_view()included the custom file from inside a plain function, where$thisis undefined, so a template copied from the built-inInvoicePlane_Web.php(the natural starting point for a custom one) crashed on its$this->layout->load_view(...)call. The path check now looks in the custom folder first, matchingrender_template_view()'s precedence, and the include is bound to the controller instance viaClosure::bind(). Thanks to @torreirow for reporting and diagnosing both root causes. #1780, #1781 -
Archived invoice PDFs findable again (#1783): the random archive names introduced for GHSA-qq8q-gf24-576m left no way to match a copy to its invoice, so guests got a new archive file on every view and the archive page filter found nothing. Names are now
<token>_<invoice name>.pdf. -
Update check and news now read from the GitHub Releases API (#1777, #1787): the settings page asked the InvoicePlane IDS servers for the latest version and the news. It now reads the latest release and the release list from GitHub, builds the news with DOM APIs instead of HTML strings, and keeps the IDS hosts as a fallback. Every request is bounded by one shared 8 second timeout so a stalled request reaches its fallback, and a legacy news answer that is not a list moves on to the next host or shows the failed state. Thanks to @sabinich for the change.
-
Guests could list their own draft invoices in the paid listing (#1769): a draft has a
0.00balance, so it matched thebalance = 0.00condition of the paid listing, and the guest-visibility restriction was only applied in some of the listings. Guests never see drafts or cancelled invoices in the detail view; every listing is now scoped the same way. -
Stripe callback keeps the refusal message (#1773): the specific message set for a duplicate payment intent, an invoice that is already paid, or a currency or amount mismatch was overwritten with the generic "payment failed" text, so the guest never learned why the payment was refused.
-
Lockout counter shared between spellings of an address (follow-up to GHSA-cjwm-qx8w-hrq2 and GHSA-hcxg-3465-jc8m): the failed-login counter was keyed on the submitted email text while the database matches emails case- and trailing-space-insensitively, so a locked-out account could be reached through another spelling with a fresh counter. The submitted address is resolved to the stored spelling before the counter is read. Thanks to @polybjorn, @Char0n1507 and @yeshwanthmoota for responsible disclosure. (GHSA-cjwm-qx8w-hrq2, GHSA-hcxg-3465-jc8m, #1796)
-
Pager URLs are encoded (#1774): the base URL of the list pager was printed into
hrefattributes without HTML encoding. -
Client assignment refuses a non-integer user id (follow-up to GHSA-5rqm-w9p8-gp7w):
Users/Ajax::save_user_client()authoriseduser_id=2.5as the acting user 2 through an(int)cast, and the INT column stored it as user 3, so a peer administrator could assign clients to another account. Only digit-only ids are accepted now. Thanks to @Shad0w35, @dreamer1eh and @Suraj-Siddharudh for responsible disclosure. (GHSA-5rqm-w9p8-gp7w, #1795) -
Warnings reach the log (#1782): CodeIgniter 3's logger has no WARNING level, so
log_message('warning', ...)was discarded, including blocked SVG uploads and the login, password-reset and cron rate-limit events.MY_Lognow writes them at the production threshold. -
"Remove logo" works again (follow-up to GHSA-qx8h-35wf-cgqf): the POST buttons were wrapped in their own form inside the settings form; browsers drop a nested form, so the click saved the settings and the logo stayed. The buttons now post the enclosing form to
remove_logowithformaction. Thanks to @mattmumford-git for responsible disclosure. (GHSA-qx8h-35wf-cgqf, #1792)
Thanks to @0xMoError-22 and @Suraj-Siddharudh for responsibly disclosing the issues resolved in this release.
| Vulnerability | Severity | Security Advisory (GHSA) | Reported By | Fixed In |
|---|---|---|---|---|
Horizontal privilege escalation via email takeover in Users::form() |
High | Incomplete Authorization Remediation in Users::form() Enables Primary Administrator Account Takeover | @0xMoError-22 | #1689 |
Primary-administrator role downgrade via user_type (privilege destruction, CWE-269) |
High | Same root cause as GHSA-77hm-22wp-96wp | @0xMoError-22 | #1697 |
Stored XSS via first_day_of_week setting (JavaScript context injection) |
— | GHSA-x3r7-qm3m-hc48 | @Suraj-Siddharudh | #1751 |
- #1689 — Horizontal privilege escalation via email takeover: PR #1638 fixed password-change authorization (IDOR), but left the email field unprotected. A secondary administrator (
user_type=1,user_id != 1) could edit the primary administrator's email address through the user form, then use password recovery to take over the account.Users::form()now validates that only the primary administrator can edituser_id=1, anduser_emailis included inPRIMARY_ADMIN_LOCKED_FIELDS(defense-in-depth). Thanks to @0xMoError-22 for the responsible disclosure. - #1697 — Primary-administrator role downgrade via
user_type(privilege destruction, CWE-269): the same missing object-level authorization inUsers::form()also let a secondary administrator rewrite the primary administrator'suser_typeto2(guest / read-only) — destroying the root account's privilege, revoking its sessions, and locking the legitimate owner out. Theuser_id=1isolation guard added for the email fix already closes this vector at the controller. This release hardens it further: theMdl_Users::save()data layer now usesPRIMARY_ADMIN_LOCKED_FIELDSto strip privilege- and identity-bearing fields (user_type,user_active,user_psalt,user_email,user_password) from any write targetinguser_id=1that does not originate from the primary administrator's own session, and the scattereduser_id == 1checks inUsers::form(),Users::delete()andUsers::change_password()are consolidated behind a singleMdl_Users::is_primary_administrator()predicate so future mutation paths cannot forget the boundary. Thanks to @0xMoError-22 for the responsible disclosure.
- Empty
SESS_SAVE_PATHbreaks session startup and stalls the installer — clarified inipconfig.phpand the docs: theenv()helper treats a set-but-empty variable as a real value, so anSESS_SAVE_PATH=line (as previously shipped inipconfig.php.example) makes InvoicePlane force PHP'ssession.save_pathto an empty string, overriding any value fromphp.ini/php-fpm.d/ the vhost. No session can then be written: login fails and manual installs get stuck on.../setup/language(setup progress is stored in the session), often with no log output. Thefilessession driver logsmkdir(): Invalid pathandConfigured save path '' is not a directory.ipconfig.php.examplenow ships the line commented out (so thesys_get_temp_dir()fallback applies) with guidance to set an explicit absolute path outside the web root, and a warning against/tmpon systemd distros wherePrivateTmp=trueandsystemd-tmpfileswipe it. Existing installs: editipconfig.phpand either setSESS_SAVE_PATHto a real writable directory or remove the empty line.README.mdand.github/docs/CONTAINER_DEPLOYMENT.mdupdated to match.
InvoicePlane 1.7.2 is a security-focused release. It resolves every vulnerability responsibly disclosed against v1.7.0 / v1.7.1 and hardens the application, session handling, and container tooling throughout. If you run v1.7.0 or v1.7.1, upgrade immediately — this release fixes a critical (CVSSv3 9.9) Remote Code Execution vulnerability.
Huge thanks to the security researchers who responsibly disclosed these issues through private GitHub Security Advisories. Without your reports this release would not have been possible:
@0raN9ewww, @5ud0er, @akgul7990, @alanturing881, @alham-rizvi, @ali-iltizar, @ashrexon, @baozongwi, @capt-bl4ck0ut, @chakrapani150, @Char0n1507, @Chittu13, @crypto-nidh, @cyabell, @de3erve-hunter, @EvidentObscurity, @FelipeSilvany, @FORIMOC, @geo-chen, @HuajiHD, @iiihaiii, @kitu232, @lighthousekeeper1212, @mattmumford-git, @0xMoError-22, @PLpaPLpa, @polybjorn, @QiaoNPC, @radoi-teodor, @RekhanshRajput, @Santoshkumarpuppala, @sharma19d, @snajafov, @tikket1, @tonghuaroot, @udaypali, @venkatesh2003631, @Vijay-raghav7, @yeshwanthmoota.
And thank you to the contributors whose code shipped in this release: @drewangell (Stripe/PayPal, Advanced Credit Cards & Venmo), @mpldr (Docker application container, configurable QR-code size), @PatrickGTR (quote public-template fix), and @Kapmeister (Cryptor binary-safety fix, #1680).
Each GHSA link is the canonical record of the report and its reporter, including its full severity and CVSS breakdown — use them to trace and credit disclosures. PR cells left as
—are filled from the advisory once published. Every fix here is complete in RC1, but the advisories stay in draft until 1.7.2 is finalized, so the GHSA links 404 for external readers until then. Please do not disclose details of any issue publicly before its advisory is published.
Ordered by severity, then CVSS, then PR number.
- #1505, #1506 — RCE: replace
directory_map()template whitelist with static constants +ipconfig.phpallowlist; fix five open-redirect instances; addsecurity_helper.php. See CUSTOM_TEMPLATES.md for the resulting custom-template usage guide. - #1500, #1516 — Stored XSS: comprehensive, application-wide output escaping (32 findings across 17 view files in 4 modules).
- #1635 — Stored XSS (mailer): escape
client_emailwithhtmlsc()in the invoice/quote mailer views (to_emailvalue attribute) — a follow-up sink missed by the earlier sweep — plusvalid_emailvalidation onclient_emailinMdl_Clientsas defense-in-depth. - #1471, #1482, #1487 — IDOR + CSRF: guest quote approve/reject and payment gateways now enforce client scoping and require POST.
- #1494 — Weak PRNG: replace password-reset token generation with
random_bytes(32)(256-bit entropy). - #1658 — Password-reset DoS: move password-reset IP and email rate-limit counters from PHP session storage into persistent database-backed counters, preventing bypass by omitting or rotating the session cookie. Thanks to @Char0n1507 and @ashrexon for responsible disclosure.
- #1481, #1488 — SQL/DDL injection: harden tax-rate decimal-places setting (
TaxRateDecimalPlacesProcessor, transaction wrap, unit tests) (#1479). - #1513 — Config injection: block newline injection in the database setup wizard.
- #1659 — Setup upgrade lock: the HTTP setup controller now rejects access when
SETUP_COMPLETED=true, preventing unauthenticated post-installation access to database upgrade operations whenDISABLE_SETUP=false. Thanks to @FelipeSilvany for responsible disclosure. - #1512, #1510 — Arbitrary file deletion: path-traversal guards for logo deletion;
validate_db_filename(), symlink protection, attachment-name XSS prevention. - #1515 — Credential exposure: Stripe/PayPal API keys no longer rendered into HTML source. Thanks to @ali-iltizar for responsible disclosure.
- #1517, #1537 — Auth bypass: guest invoice/payment endpoints gain a
guest_visible()filter (no draft-invoice access). - #1491, #1511, #1518 — Setup wizard: locked after install (
SETUP_COMPLETED), with an admin security-warning system. - #1492 — SSRF: sanitize PDF footer content before mPDF rendering.
- #1486, #1499 — Email preview XSS: render previews as sanitized/plain text instead of raw HTML.
- #1496 — Payment replay: unique
payment_external_idindex prevents duplicate Stripe/PayPal processing. - #1495 — Info leak: route PHPMailer SMTP debug to the CI log via
sanitize_for_logging()(no longer breaks AJAX). - #1507 — EXIF: optional metadata stripping from uploads (
SEC_STRIP_EXIF_FROM_IMAGES, off by default). - #1567 — Session & infrastructure hardening:
cookie_httponly=true, defaultX-Frame-Options: SAMEORIGIN, session-fixation fix (SESS_REGENERATE_DESTROY=true), password-reset log-injection sanitization, and aReferrer-Policyheader. - #1536 — Review follow-ups:
entrypoint.shconfig-injection guard, XSS trait fix, logo URL escaping, custom-template allowlisting,Mdl_reportscast fix. - Auth bypass (deactivated accounts):
Mdl_Sessions::auth()now rejects a login for a deactivated user (user_active != 1) even with the correct password. - SUMEX XML disclosure: SUMEX invoice XML is written to
storage/temp/, outside the publicuploads/tree, instead ofuploads/temp/. Thanks to @mattmumford-git and @Char0n1507 for responsible disclosure. #1620 - Import CSV disclosure: added a
Deny from all.htaccesstouploads/import/, blocking web access to in-progress CSV imports. - Object-level authorization (IDOR): added
can_user_access()/can_user_manage()model checks for clients, projects, user-clients, invoices, and quotes; wired into/clients/view/{id}and/projects/view/{id}. - CSRF hardening: explicit POST + CSRF-token validation on the delete endpoints across 12 modules (defense-in-depth against forged state-changing requests).
- #1640 — Auth checkpoint hardening: use strict (
!==) comparison for theuser_type/required_keysession check inAdmin_Controller/Guest_Controller, removing a PHP type-juggling risk in the app's primary authorization checkpoint. - #1639 — Log injection: sanitize
cron_keybefore logging inCron::recur(). - #1638, #1658 — IDOR: restrict
Users::change_password()so only the account owner or the primary administrator (user_id=1) can change a user's password. - #1637 — CSRF: add POST + CSRF-token validation to seven delete-style endpoints that relied only on
Base_Controller's URL-substring check. - #1636 — CSRF: add POST + CSRF-token validation to
Recurring::stop(), previously reachable via a bare GET request. - #1651 — Token hardening: generate guest invoice/quote access keys and the CRON authentication key with a CSPRNG, and compare password-reset tokens with
hash_equals(). Thanks to @alham-rizvi for responsible disclosure. - #1660 — Email template data exposure:
parse_template()now substitutes only documented invoice, quote, client, user, SUMEX, and custom-field tags; unknown placeholders render empty instead of reflecting arbitrary properties from the selected database row. Invoice and quote queries also select explicit publicip_userscolumns, excluding password hashes, salts, and password-reset tokens. Thanks to @Char0n1507 for the responsible disclosure. - #1661 — SSRF: report PDFs now pass normalized date labels to their templates and escape those labels before mPDF rendering, preventing injected HTML resources in date fields from triggering server-side HTTP requests. Thanks to @Char0n1507 for the responsible disclosure.
- #1684 — PayPal
order_idpath injection, recurring-invoice cron race, publicSessions::authenticate()/Upload::create_dir(), cron-key brute force and session-scoped login rate limit, stored XSS in the quote-status email:order_idis validated before it is placed in a PayPal API path; the recurring cron claims each invoice with a conditional update so concurrent runs cannot double-generate it;authenticate()andcreate_dir()areprivate; wrongcron_keyattempts and login attempts are rate limited from the database instead of the session; the client name and URL in the quote-status email are escaped. Thanks to @Char0n1507 and @yeshwanthmoota for responsible disclosure. (GHSA-rf9w-hm5h-4gr2, GHSA-hvvh-fjx5-9j55, GHSA-28fh-2656-wc63, GHSA-hcxg-3465-jc8m, GHSA-x2m4-962x-cphw, GHSA-prmc-vrjx-5hxc) - #1663 — SSRF + XML injection + PII exposure:
Sumex::pdf()now rejects anySUMEX_URLthat isn'thttps://and restricts the cURL request/redirect protocols to HTTPS with certificate verification on, closing an admin-configuration SSRF intofile://, internal hosts, or cloud metadata endpoints. XML control characters are stripped from free-text fields, andinvoice_number/user_subscribernumberare restricted to a strict character allowlist before reachingsetAttribute(),nodeValue, or the ESR coding-line builder.storage/(which holds the temporary SUMEX XML and its patient PII) now has explicit web-access-deny rules for both Apache and nginx. Thanks to @Char0n1507 and @yeshwanthmoota for the responsible disclosure. - #1664 — CSRF:
recalculate_all_invoices()andrecalculate_all_quotes()were GET-reachable with no CSRF guard, unlike every other state-changing bulk action; both now requireensure_valid_post_request(), matching the pattern already used bydelete()in the same controllers. - #1664 — Stored XSS (PDF):
invoice_logo_pdf()interpolated the logo filename into<img src="...">unescaped, unlike its siblinginvoice_logo(); now wrapped inhtml_escape(). - #1664 — Log injection: the Stripe guest callback logged the raw, unauthenticated
checkout_session_idwithoutsanitize_for_logging(), and a second error-log call only stripped the literal<br>string, not CRLF; both call sites now sanitize consistently. - #1681 — Privilege revocation failure:
User_Controller::__construct()now re-validates the acting user'suser_typeanduser_activestatus from the authoritativeip_usersrecord on every request, destroying the session if the role no longer matches the required value or the account is deactivated.Mdl_sessions::invalidate_user_sessions()destroys all active sessions for a user when their role or active status is changed, forcing immediate revocation of stale sessions.Users::form()prevents users from escalating their own role and only explicitly setsuser_typefrom POST in authorized scenarios (new user creation, admin editing another user with role change). Previously, a downgraded administrator retained administrative access through a stale session and could restore their own role via POST, achieving persistent privilege escalation. - #1664 — Hardening (defense-in-depth):
Mailer.php'sjson_encode()calls for the email-template compose modal now useJSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT, matching the existing pattern inemail_templates/views/form.php;Upload::delete_file()now uses the sharedvalidate_file_in_directory()helper instead of an ad-hocrealpath()check;country_helper.phpvalidates the$cldrlocale segment against an allowlist pattern before using it in anincludepath; and the password-reset request no longer issues a token to a deactivated account (mirroring the login-timeuser_activecheck) while preserving the identical response used to prevent email enumeration. - #1668 — Permanent account lockout:
Sessions::authenticate()'s per-account lockout guard (log_count < 10) also gated the failure-recording call, solog_countcould never pass 10 - the unlock branch in_login_log_check(), gated onlog_count > 10, was therefore unreachable dead code and locked-out accounts stayed locked forever, recoverable only via a manual databaseDELETE. Separately, that unlock branch measured elapsed time withDateInterval::$h, which is only the 0-23 hour component of the difference, not the total elapsed hours, so even a reachable check would misfire past the first day._login_log_check()now unlocks atlog_count >= 10(matching the actual, unchanged 10-attempt threshold) using the same Unix-timestamp-based window check already used for password-reset rate limiting (_login_log_is_within_window()), so the 12-hour lockout now actually expires. Thanks to @polybjorn for the responsible disclosure. - #1670, #1671 — Password-reset token expiry bypass (incomplete-fix follow-up): the token-expiry check added for GHSA-5r28-6rw3-25c2 ran only on the reset-link (GET) flow, so the password-change POST (
btn_new_password) could still set a new password with an expired-but-stored token; the released schema also shipped without theuser_passwordreset_token_expirycolumn the controller reads and writes._reject_expired_password_reset_token()now enforces the expiry on both the GET and POST flows, and the column is added in the043_1.7.2.sqlmigration (#1670). The expiry parse is further hardened to strictDateTime::createFromFormat('!Y-m-d H:i:s', …)with an anchored canonical-format guard (rejecting values such as25:99:99or2026-8-10 9:05:07), the password-change POST now validates its CSRF token and uses the sharedget_safe_referer()helper instead of raw$_SERVER['HTTP_REFERER'], and unknown / expired / malformed tokens all return one generic message so the response never reveals which check failed (#1671). Thanks to @ali-iltizar, @baozongwi for the responsible disclosure. - #1674 — CSRF (state-changing GET on PDF generation): with the "mark as sent when generating the PDF" option enabled (
mark_invoices_sent_pdf/mark_quotes_sent_pdf),invoices/generate_pdf/<id>andquotes/generate_pdf/<id>changed document state on a plain authenticated GET before streaming the file — assigning the next official invoice/quote number, moving the document from draft to sent, updating its due date, and (where the read-only option is set) locking it. Any page an administrator merely visited could therefore fire these changes with a hidden<img src=".../invoices/generate_pdf/ID">, consuming official numbers and pushing drafts to sent with no action by the admin. The state changes now run only when the request carries a valid same-origin CSRF token: a newverify_get_csrf_token()compares an_ip_csrfquery-string token against the CSRF cookie withhash_equals()(the query-string counterpart of the existing POST-onlyverify_csrf_token()), and the in-app "generate PDF" links attach the token through a new_csrf_query()helper. A forged cross-site request can't read the cookie, so it can't supply a matching token and the side effect is skipped — while the PDF, a read-only operation, still streams either way, leaving every legitimate download link and the auto-mark-sent workflow working exactly as before. Thanks to @ashrexon (Yash Shendge) for the responsible disclosure.
CLAUDE.md— quick-start guide for AI coding agents and new contributors: CI3 mental model, non-negotiable security rules, key helper functions, code style, testing commands, and common pitfalls.- Security helpers, each a single source of truth for one concern:
sanitize_for_logging()(log-injection prevention, CWE-117),validate_safe_filename()/validate_file_in_directory()(path-traversal prevention, CWE-22),get_safe_referer()(open-redirect-safe referer resolution, CWE-601),verify_csrf_token()(timing-safe CSRF verification), andgenerate_secure_token()/generate_password_reset_token()(CSPRNG-based tokens). validate_template_name()— 7-layer template validation (empty check, path traversal, type, scope, static whitelist, character set, logging).- Database-backed rate limiting for password-reset requests — per IP (
PASSWORD_RESET_IP_MAX_ATTEMPTS,PASSWORD_RESET_IP_WINDOW_MINUTES) and per email (PASSWORD_RESET_EMAIL_MAX_ATTEMPTS,PASSWORD_RESET_EMAIL_WINDOW_HOURS) — so limits survive missing or rotated session cookies. PASSWORD_RESET_TOKEN_EXPIRY_MINUTESenv var (default15) andSEC_STRIP_EXIF_FROM_IMAGESenv var (defaultfalse).- Session env vars
SESS_SAVE_PATH,SESS_TABLE_NAME,SESS_COOKIE_NAME, andSESS_REGENERATE_DESTROY, all documented inipconfig.php.example.SESS_SAVE_PATHmay point outside the document root for additional security; it still defaults to PHP's system temp directory. - Allowlist-based custom templates: the template selector is built from the built-in whitelist plus the names explicitly listed in the
CUSTOM_INVOICE_TEMPLATES_PDF,CUSTOM_INVOICE_TEMPLATES_PUBLIC,CUSTOM_QUOTE_TEMPLATES_PDF, andCUSTOM_QUOTE_TEMPLATES_PUBLICenv vars. Built-in templates always appear in the selector; custom templates appear only if explicitly allowlisted.CUSTOM_TEMPLATES_FOLDERlocates the corresponding.phpfile on disk at render time and is never scanned to populate the selector. Names containing spaces or hyphens must be quoted, e.g.CUSTOM_INVOICE_TEMPLATES_PDF="Corporate - Modern,My Template". - Settings page warning for saved custom invoice and quote template names that are not yet present in the matching
ipconfig.phpallowlist. This helps administrators copy selected legacy template names intoCUSTOM_INVOICE_TEMPLATES_PDF,CUSTOM_INVOICE_TEMPLATES_PUBLIC,CUSTOM_QUOTE_TEMPLATES_PDF, orCUSTOM_QUOTE_TEMPLATES_PUBLICafter upgrading. Referrer-Policy: strict-origin-when-cross-originheader sent on every admin response.
- Re-introduced Stripe & PayPal, adding PayPal Advanced Credit Cards and Venmo support (#1289, #1288).
- The QR-code size on invoices is now configurable (#1489, #1376).
- The email-template live preview now displays the raw template source as plain text instead of rendering it as HTML, eliminating a DOM-based XSS vector.
- XSS input sanitisation now covers deeply nested POST arrays, with full field-path logging.
- Breaking:
phpmail_send()now returns the actualboolresult of the send instead of always returningtrue. Any integration that treated the return value as always-truthy must handlefalseas a delivery failure.
- #1426 — Remittance slip / QR-code remittance text (#1140).
- #1449 —
quote_templates/publicshowing the client on both header and footer. - #1451 — QR-code generation for batch invoice processing.
- #1465 — Simplify redundant conditionals in email template tags.
- #1473 — Undefined-array-key error for mPDF footer names on PHP 8.3+ (#1180).
- #1478 — Add the missing
attachment()method to the guestGetcontroller. - #1677 — Stripe zero-decimal currencies: Checkout Sessions now convert invoice balances using the configured currency's minor unit instead of multiplying every amount by 100. Callback validation, recorded payments, and merchant-response details use the same conversion, preventing JPY, KRW, CLP, and other zero-decimal invoices from charging customers 100 times the invoice total. Thanks to @snajafov for reporting the issue.
- #1665 —
Upload::delete_file()no longer reports a spurious "delete failed" error for a file that was already removed.validate_file_in_directory()resolves paths withrealpath(), which returnsfalsefor a nonexistent path, so the already-deleted case is now handled before that check runs, and the file's metadata is still cleared correctly. - Session config bug:
sess_table_nameandsess_cookie_nameincorrectly read from theSESS_DRIVERenv var instead of their ownSESS_TABLE_NAME/SESS_COOKIE_NAMEvariables, making it impossible to rename the session cookie or table without also changing the driver name. not_configured.php: added the missing<?php _csrf_field(); ?>to a<form method="post">that violated the project's CSRF rule.- #1680 — Cryptor binary-safety regression (SMTP auth failures):
decryptString()had silently regressed back tomb_strlen()/mb_substr()on raw binary ciphertext — this file's original byte-safe fix (noted above) was reverted by an automated Laravel Pintmb_str_functionsrewrite during a routinecomposer update && pintpass.mb_*functions count characters under the internal encoding, not bytes, so they could consume more bytes than requested when random ciphertext happened to resemble multi-byte UTF-8, producing an oversized IV and triggeringopenssl_decrypt(): IV passed is … bytes long …, truncating— which broke SMTP authentication for affected users. Reverted to byte-safestrlen()/substr()and disabledmb_str_functionsinpint.jsonso this can't be silently reintroduced by a future code-style pass. Thanks to @Kapmeister for reporting the issue. (Note: the same Pint pass also changed an unrelatedsubstr()call inMdl_templates.php's log-message truncation tomb_substr(); that one operates on a legitimate text string, not binary data, so it is not a security issue and was left as-is.)
- SVG logo upload support — SVGs can carry embedded JavaScript (an XSS vector). Convert existing logos to PNG, JPG, or GIF (see SVG_FILES.md).
- Dynamic filesystem scanning for template discovery — replaced with static allowlist constants (RCE prevention).
- #1483 — Batch settings save: reduce DB queries from ~70 to 3 (transaction-wrapped).
- #1503 — Add database indexes on frequently queried columns.
- #1466 — Move the setup-php-composer composite action into
.github/actions/. - #1490 — Add a Laravel Pint code-style check.
- #1509 — Add the Docker application container (combined web server + PHP).
- #1529 — Document the arbitrary file-deletion vulnerability for CVE allocation.
- Declared explicit
permissions: contents: readin thephpunit.yml,quickstart.yml, andsetup.ymlworkflows (CWE-272).
- PHP 8.2+ compatibility (minimum PHP 8.1 required; PHP 7.x no longer supported).
- Updated all Composer and Yarn dependencies.
- Critical — Local File Inclusion (LFI) in PDF template handling (#1433): invoice and quote template parameters are now validated before use, blocking directory traversal through template selection, with security logging added for template operations.
- Critical — Cross-Site Scripting (XSS): quote/invoice numbers, tax-rate names, payment-method names, custom-field labels, client addresses, SUMEX observations, and quote notes are now escaped in all templates and views; email templates use proper HTML escaping throughout.
- High — Log poisoning in the file-upload controller: file names are sanitized before logging to prevent control-character injection.
- High — SVG logo files blocked: SVGs can contain embedded JavaScript; convert to PNG, JPG, or GIF instead.
- #1388, #1387 — Unsafe jQuery plugin vulnerabilities.
- #1389 — Missing workflow permissions in GitHub Actions.
- #1383 — File access vulnerabilities across controllers.
- #1381 — Version checking and logging for
client_einvoicingfields. - #1380 — Dependency:
qspackage bump. - #1377 — QR code image width reduced to 100 px.
- #1375 — Email address verification now accepts both comma and semicolon separators.
- #1373 — Removed deprecated library dependencies.
- #1367, #1368 — Various bug fixes.
- PHP 7.x compatibility.
- Deprecated library dependencies.
For changes in version 1.6.4 and earlier, please see the git commit history.
If you discover a security vulnerability in InvoicePlane, please report it privately by opening a GitHub Security Advisory before disclosing it publicly. See SECURITY.md for the full policy.