Skip to content

Latest commit

 

History

History
550 lines (434 loc) · 136 KB

CHANGELOG.md

File metadata and controls

550 lines (434 loc) · 136 KB

Changelog

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.


[Unreleased]

Thank you

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.

Security Vulnerability Summary

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

Security fixes

  • Unauthenticated access to the internal Layout controller discloses server paths via PHP error backtraces (CWE-200 / CWE-862): Layout extends MX_Controller rather than Admin_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 (with display_errors on) 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's AllowEncodedSlashes Off blocks it), so the demonstrated impact is scoped to information disclosure. Fix: a _remap() on Layout now show_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, but Mdl_Sessions::auth() only reached the bcrypt password_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 way Sessions' own new-password POST already does, since Sessions is a Base_Controller and cannot call ensure_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() and send_quote() endpoints accepted fully attacker-controlled to_email, from_email, from_name, cc, and bcc parameters, 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_from setting, or the associated user email as fallback); user-supplied to_email/from_email/from_name are 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_week setting (JavaScript context injection) (GHSA-x3r7-qm3m-hc48): the first_day_of_week setting was echoed directly into a JavaScript string literal in the datepicker initialization in head.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 new encode_for_javascript_string() helper escapes backslashes, quotes, line terminators, and forward slashes before the value is embedded in the script, and Settings::index() now validates first_day_of_week against the valid 0–6 range server-side, rejecting and logging anything else. Thanks to @Suraj-Siddharudh for responsible disclosure. #1751

  • Stored XSS via unescaped currency_symbol on public guest invoice/quote pages (CWE-79) (GHSA-x4g3-fp78-5gjx): a duplicate report of the same format_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-point htmlsc() applied in number_helper.php's format_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-Options header set elsewhere in the app for Guest_Controller/Base_Controller responses, 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-Options is now applied directly in Guest_Controller/Base_Controller rather 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: read invoice_balance, check it, then insert into ip_payments. Two callbacks arriving concurrently for the same invoice, each carrying a distinct gateway reference (distinct payment_intent / capture_id, so the existing payment_external_id dedup 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 driving invoice_balance negative. Fix: Mdl_Payments::record_external_payment() collapses the guard and the insert into one atomic operation — a single conditional UPDATE on ip_invoice_amounts that only succeeds while invoice_balance > 0, serialized by InnoDB on that row, followed by an INSERT IGNORE so a reference that still raced past a wider balance is dropped by the unique payment_external_id index rather than erroring. The 044_1.7.3.sql migration adding that index is also repaired in place to make it UNIQUE (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() read invoice_balance and Mdl_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 new PaymentCallbackLock library: 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 on ip_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 PENDING status 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 both COMPLETED and PENDING statuses 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: only COMPLETED captures are now recorded as settled payments through record_external_payment(). PENDING captures are logged to merchant_responses for 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 of uploads/customer_files/, uploads/archive/, uploads/import/, and uploads/temp/ relied on .htaccess files containing Deny 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: explicit location ^~ /uploads/<subdir>/ deny blocks added to resources/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 of YYYY-MM-DD_<name> pattern) to prevent enumeration. Recommendation: existing nginx deployments should upgrade this configuration block and consider moving uploads/ 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): Layout is a helper other controllers call directly but extends MX_Controller with public methods, so /layout/layout/render answered 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): The Users::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 an is_primary_administrator() authorization guard consistent with form() and change_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 ungrouped filter_or_where() condition that, when combined with the guest controller's client-scoping where_in(), allows the OR to bind more tightly than the AND, leaking invoices from other clients. The SQL evaluates as WHERE status=4 OR (balance='0.00' AND client_id IN (...)) instead of the intended WHERE (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 in group_start()/group_end(), but those run immediately against the query builder while filter_where()/filter_or_where() are deferred by MY_Model::__call() and only applied later by run_filters() — so the group closed empty and the vulnerability was not actually fixed. #1717 corrects this using filter_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_log key column (CWE-694): five different counters (per-account login lockout, login-IP throttle, password-reset IP and email throttles, cron-key throttle) share ip_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 example login_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))> and password_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 in User_Clients::create(), User_Clients::delete() and Users::delete_user_client(). A secondary administrator could grant or revoke any user's client assignments, and — by posting user_all_clients for their own account — bulk-assign every client in the system to themselves, gaining read access to every invoice, quote and payment. Fix: a shared Mdl_User_Clients::can_manage_user_clients() check (primary administrator or the account owner) now guards all three endpoints, including the user_id the model saves from POST data; the assignment being deleted must belong to the user in the URL; only the primary administrator may set user_all_clients; and the id must be a plain integer (a value such as 2.9 would otherwise be authorized as user 2 but stored as user 3). Independently found and reported again by @Suraj-Siddharudh via the User_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): Validator interpolated 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 with htmlsc() 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 earlier currency_symbol fix 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 with json_encode() using JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT in guest/views/gateways/paypal.php and stripe.php. Thanks to @T0x1cG for responsible disclosure. (GHSA-mp5h-3jcr-f7hm)

  • Self password change no longer reads a user_auth_version that does not exist (follow-up to GHSA-hw8f-c8c2-g4fj): the self password change in Users::change_password() read $updated_user->user_auth_version, a column this branch does not have, so it raised an undefined-property warning and stored 0 in 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_key setting 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_point settings 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() and format_quantity() now escape both separators with htmlsc(), and the filename in the invoice/quote footer is passed through htmlspecialchars(). 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_Controller re-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() in ip_security_helper.php), and User_Controller compares 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 a user_auth_version property that does not exist on this branch (#1766), and Mdl_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 with unserialize(), which cannot read PHP's session file format, so it deleted nothing; it now reads the configured session path and the top-level user_id entry 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-Options straight from the raw X_FRAME_OPTIONS value, so an invalid setting (ALLOWALL, a typo) produced an invalid header that browsers ignore, and DENY still sent a CSP of frame-ancestors 'self'. One helper, security_frame_headers(), now accepts only SAMEORIGIN or DENY (anything else falls back to SAMEORIGIN) 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 with unserialize(), 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-level user_id entry, 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() called captureOrder() 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 the ip_merchant_responses row 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_id alone, 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_samesite is 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 through sanitize_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 the finally block then dereferenced $session and $invoice, which were never assigned, attempted an insert with a NULL invoice_id into 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 the try, and only writes the merchant response when an invoice was resolved; PaypalLib now 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 PENDING capture treated as settled payment (GHSA-w6fv-2f29-rc6g): Thanks to @santhoshdodo2721 for responsible disclosure. A follow-up records the capture id in the PENDING audit 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_log key column (CWE-694): five different counters (per-account login lockout, login-IP throttle, password-reset IP and email throttles, cron-key throttle) share ip_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 example login_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))> and password_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 with json_encode() using JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT in guest/views/gateways/paypal.php and stripe.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). The str_replace() call was therefore a no-op for both: a first_day_of_week value 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's prep/v180 branch; 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>.xml and the file world-readable, so anyone able to reach storage/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 added sanitizeXmlText() but left item_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 through DOMNode::nodeValue was 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 was null on the assumption that CodeIgniter had already validated and consumed it. For a URI in csrf_exclude_uris the 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_theme setting (CWE-79, leading to CWE-116 / CWE-20 / CWE-693): system_theme was persisted by Settings::index() with no server-side allowlist — unlike first_day_of_week, which already has a guard in the same loop — and echoed unescaped by _theme_asset() into a double-quoted href="..." 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 of xss_clean()'s tag- or keyword-gated filters, so the global input filter did not stop it either. Fix: Settings::index() now validates system_theme against 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

Bug fixes

  • Empty SESS_SAVE_PATH no longer breaks session startup — fixed in code, not just packaging. 1.7.3 shipped ipconfig.php.example with the line commented out, but any install that still had SESS_SAVE_PATH= in its ipconfig.php (an upgrade, or a copy made before 1.7.3) stayed broken: env() returns a set-but-empty value as "", so application/config/config.php set $config['sess_save_path'] = "", CodeIgniter's Session_files_driver ran ini_set('session.save_path', '') — overriding php.ini / php-fpm.d / the vhost — and session startup failed (mkdir('')), so login broke and the manual installer stalled on .../setup/language. config.php now collapses an empty or whitespace-only SESS_SAVE_PATH to sys_get_temp_dir(), exactly matching an unset one; an explicit path is unchanged.

  • PayPal PENDING capture never wrote its audit row: the PENDING branch added with the fix that stopped recording pending captures as settled payments read $capture_data and $capture_id, which are only defined inside the COMPLETED branch. 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). The str_replace() call was therefore a no-op for both: a first_day_of_week value 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's prep/v180 branch; no external reporter. #1760

  • Custom public invoice/quote templates in CUSTOM_TEMPLATES_FOLDER returned HTTP 500 on the guest view: CUSTOM_TEMPLATES_FOLDER plus the CUSTOM_INVOICE_TEMPLATES_PUBLIC / CUSTOM_QUOTE_TEMPLATES_PUBLIC allowlists (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 in application/helpers/template_helper.php: get_validated_template_path() validated the resolved file against APPPATH . 'views/' only, so a template that exists solely in CUSTOM_TEMPLATES_FOLDER always failed that check and hit show_error(..., 500), even though render_template_view() already gives the custom folder precedence; and render_template_view() included the custom file from inside a plain function, where $this is undefined, so a template copied from the built-in InvoicePlane_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, matching render_template_view()'s precedence, and the include is bound to the controller instance via Closure::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.00 balance, so it matched the balance = 0.00 condition 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 href attributes without HTML encoding.

  • Client assignment refuses a non-integer user id (follow-up to GHSA-5rqm-w9p8-gp7w): Users/Ajax::save_user_client() authorised user_id=2.5 as 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_Log now 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_logo with formaction. Thanks to @mattmumford-git for responsible disclosure. (GHSA-qx8h-35wf-cgqf, #1792)


[1.7.3] - 2026-08-29

Thank you

Thanks to @0xMoError-22 and @Suraj-Siddharudh for responsibly disclosing the issues resolved in this release.

Security Vulnerability Summary

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

Security fixes

  • #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 edit user_id=1, and user_email is included in PRIMARY_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 in Users::form() also let a secondary administrator rewrite the primary administrator's user_type to 2 (guest / read-only) — destroying the root account's privilege, revoking its sessions, and locking the legitimate owner out. The user_id=1 isolation guard added for the email fix already closes this vector at the controller. This release hardens it further: the Mdl_Users::save() data layer now uses PRIMARY_ADMIN_LOCKED_FIELDS to strip privilege- and identity-bearing fields (user_type, user_active, user_psalt, user_email, user_password) from any write targeting user_id=1 that does not originate from the primary administrator's own session, and the scattered user_id == 1 checks in Users::form(), Users::delete() and Users::change_password() are consolidated behind a single Mdl_Users::is_primary_administrator() predicate so future mutation paths cannot forget the boundary. Thanks to @0xMoError-22 for the responsible disclosure.

Documentation / configuration

  • Empty SESS_SAVE_PATH breaks session startup and stalls the installer — clarified in ipconfig.php and the docs: the env() helper treats a set-but-empty variable as a real value, so an SESS_SAVE_PATH= line (as previously shipped in ipconfig.php.example) makes InvoicePlane force PHP's session.save_path to an empty string, overriding any value from php.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. The files session driver logs mkdir(): Invalid path and Configured save path '' is not a directory. ipconfig.php.example now ships the line commented out (so the sys_get_temp_dir() fallback applies) with guidance to set an explicit absolute path outside the web root, and a warning against /tmp on systemd distros where PrivateTmp=true and systemd-tmpfiles wipe it. Existing installs: edit ipconfig.php and either set SESS_SAVE_PATH to a real writable directory or remove the empty line. README.md and .github/docs/CONTAINER_DEPLOYMENT.md updated to match.

[1.7.2] - 2026-07-17

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.

Thank you

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).

Security Vulnerability Summary

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.

Vulnerability Severity Security Advisory (GHSA) Reported By Fixed In
RCE via template filesystem scan Critical SECURITY_ADVISORY_RCE_FIX.md · Remote Code Execution via Writable Templates Directory in InvoicePlane v1.7.1 @Vijay-raghav7 #1505, #1506
Broken auth: password-reset tokens never expired Critical Improper Password Reset Token Expiration @ali-iltizar #1514
Arbitrary file deletion via path traversal High SECURITY_ADVISORY_ARBITRARY_FILE_DELETION.md · [CVE-2026-40298]: Authenticated Arbitrary File Deletion via Path Traversal in "Invoice Logo" Setting, [CVE-2026-39978]: Authenticated path traversal in logo removal allows arbitrary file deletion outside uploads @ali-iltizar, @iiihaiii #1512, #1510
Weak PRNG in password-reset tokens High Predictable Password Reset Token via md5(time()) Enables Account Takeover @tikket1 #1494
Password-reset email flood via session-only rate limits Medium Password Reset Rate Limiting Fully Bypassed by Sending Requests Without a Session Cookie @Char0n1507, @ashrexon #1658
SQL/DDL injection in tax rate decimal places High SQL Injection via Unsanitized Tax Rate Decimal Places Field in Settings, Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection') in invoiceplane/invoiceplane, Authenticated SQL Injection via Unsanitized tax_rate_decimal_places in InvoicePlane General Settings, SQL Injection (DDL Injection) in Settings Module Leading to Schema Manipulation @tikket1, @udaypali, @FelipeSilvany, @akgul7990 #1481, #1488
Configuration injection in DB setup wizard High Configuration Injection in Setup Module Leading to Environment Manipulation (db_hostname Injection) @akgul7990 #1513
Unauthenticated database upgrade via setup wizard High Unauthenticated Setup Upgrade Allows Database Migration Execution After Installation, Unauthenticated Invocation of Database Upgrade Operations Through an Incompletely Locked Setup Wizard @FelipeSilvany #1659
IDOR + CSRF on guest quote approve/reject High Guest Quote Approval/Reject Horizontal Privilege Escalation, Guest user IDOR: Quote approve/reject missing client_id scoping, Security advisory — InvoicePlane: guest-portal IDOR allows any client to approve/reject another client's quote, IDOR in Guest Quote Approval Allows Cross-Client Status Manipulation @HuajiHD, @lighthousekeeper1212, @Santoshkumarpuppala, @cyabell #1471, #1482, #1487
Auth bypass in guest invoice/payment endpoints Medium Improper Access Control in Guest Payment Flow Allows Access to Non-Public Invoices @FelipeSilvany #1517, #1537
Setup wizard accessible post-installation Medium Unauthenticated Setup Reconfiguration, Unauthenticated Setup Wizard Re-entry Allows Configuration Overwrite in InvoicePlane 1.7.1, Unauthenticated Re-execution of Installation Wizard After Setup Allows Overwrite of Database Configuration, Denial of Service, and Potential Data Compromise @HuajiHD, @iiihaiii, @kitu232 #1491, #1511, #1518
SSRF via PDF footer content Medium SSRF via admin-stored PDF footer HTML @radoi-teodor #1492
Open redirect via HTTP_REFERER Medium Remote Code Execution via Writable Templates Directory in InvoicePlane v1.7.1 @Vijay-raghav7 #1505
Payment gateway API credentials in plaintext Medium Sensitive Data Exposure via HTML Source Code (Stripe & PayPal API Keys) @ali-iltizar #1515
Duplicate payment processing (Stripe callback replay) Low–Medium Stripe Callback Replay @HuajiHD #1496
Email template preview XSS Low–Medium Stored XSS via Email Templates in InvoicePlane <= 1.7.1 @Vijay-raghav7 #1486, #1500, #1516
Stored XSS via client email in invoice/quote mailer Medium Stored Cross-Site Scripting (XSS) via Client Email in Invoice and Quote Mailer Forms @capt-bl4ck0ut #1635
EXIF metadata in uploaded images Low Sensitive Information Disclosure via Unstripped EXIF Metadata in Attachments @Vijay-raghav7 #1507
SQL injection via string concatenation in the Ajax having() clause High SQL Injection Vulnerability in User Ajax name_query via String Concatenation in having() Clause @PLpaPLpa #1630
Arbitrary local file read via mPDF file:// in the Sales-by-Year report High Arbitrary local file read via mPDF file:// in the Sales-by-Year report (unescaped client VAT-ID) @tonghuaroot #1615
Unauthenticated PayPal capture payment forgery (no currency/amount validation) High Unauthenticated payment forgery: PayPal capture endpoint does not validate currency or amount against the invoice @tonghuaroot #1616
Path traversal risk High Path Traversal Risk @venkatesh2003631 #1560
Sensitive financial data exposure via unauthenticated public invoice High Sensitive Financial Data Exposure via Unauthenticated Public Invoice @chakrapani150 #1556
Authentication bypass via MD5 magic-hash type juggling High Authentication Bypass via MD5 Magic Hash Type Juggling @geo-chen #1555
Guest attachment access bypass — missing authorization on Get controller High Guest Attachment Access Bypass — Missing Authorization on Get Controller @QiaoNPC #1545
Disabled users can still authenticate High Disabled Users Can Still Authenticate @mattmumford-git #1621
Sensitive SUMEX invoice XML disclosure via web-accessible temp directory High Sensitive SUMEX Invoice XML Disclosure via Web-Accessible Temporary Directory, Partial Path Traversal and Patient PII Exposure via Predictable Temp XML File in Sumex.php @mattmumford-git, @Char0n1507 #1620
Sensitive import CSV disclosure via web-accessible import directory High Sensitive Import CSV Disclosure via Web-Accessible Import Directory @mattmumford-git #1623
Authorization bypass through user-controlled key (IDOR) High Authorization Bypass Through User-Controlled Key in InvoicePlane/InvoicePlane @0raN9ewww, @sharma19d #1626
Missing CSRF protection on state-changing delete / logo actions High CSRF via GET-Based Logo Removal in Settings, Missing CSRF Protection on State-Changing delete Actions @mattmumford-git, @venkatesh2003631 #1559, #1561, #1622, #1624
Second-order SQL injection via the unvalidated custom_field_table field Medium Second-order SQL injection through the unvalidated custom_field_table field in the Custom Fields module, Authenticated Stored Blind SQL Injection via Custom Field Table Identifier @5ud0er, @capt-bl4ck0ut #1573, #1632
Unauthenticated GET-based quote approval/rejection (CSRF via link prefetching) Medium Unauthenticated GET-based quote approval/rejection enables CSRF-equivalent attack via email link prefetching @de3erve-hunter #1487
Stored XSS via CSV import (payment method name) Medium Stored XSS in InvoicePlane via CSV import: unescaped payment method name bypasses input filter and executes in administrator sessions @EvidentObscurity #1574
Invoice PDF passwords stored in plaintext Medium Invoice PDF Passwords Stored Plaintext @chakrapani150 #1557
Stored XSS in online payment settings Medium Stored Cross-Site Scripting (XSS) in Online Payment Settings @ali-iltizar #1516
Vulnerability report (InvoicePlane_001) Medium InvoicePlane_001 Vulnerability Report @FORIMOC #1547
Non-constant-time cron_key comparison in the recurring-invoice cron endpoint Low Non-constant-time cron_key comparison in recurring invoice cron endpoint (CWE-208) @alanturing881 #1583
PHPMailer debug output in AJAX responses Low–Medium — Internal audit #1495
XSS session hijack via cookie_httponly=false High — Internal audit #1567
Clickjacking — X-Frame-Options not always sent Medium — Internal audit #1567
Session fixation — SESS_REGENERATE_DESTROY defaulted false Medium — Internal audit #1567
Log injection via password-reset token/email Low — Internal audit #1567
Missing Referrer-Policy header Low — Internal audit #1567
Session auth check used loose comparison (user_type/required_key) Medium Loose Type Comparison in Core Authentication Check (Defense-in-Depth) @Char0n1507 #1640
Log injection via unsanitized cron_key in Cron::recur() Medium Log Injection via Unsanitized User Input in Cron Key Error Logging @Char0n1507 #1639
IDOR: admins could change other users' passwords Medium IDOR: Horizontal Privilege Escalation via Password Change Without Authorization Check, Horizontal Privilege Escalation — Any Authenticated Admin Can Change Any Other User's Password @Char0n1507 #1638, #1658
CSRF on seven delete()-style endpoints missing token validation Medium Missing CSRF Token Validation on Multiple Delete Endpoints @Char0n1507, @vicrypter #1637
CSRF bypass in Recurring::stop() via GET request Medium CSRF: Recurring Invoice State Change via GET Request Without CSRF Protection @Char0n1507 #1636
Guest invoice/quote access keys and CRON authentication key generated with non-cryptographic PRNG Medium Guest invoice/quote access keys and the CRON authentication key are generated with a non-cryptographic PRNG @alham-rizvi #1651
Password reset token compared with non-constant-time operator Medium Password reset token is compared with a non-constant-time operator instead of hash_equals() @alham-rizvi #1651
Sensitive user fields exposed through email-template placeholders Medium Unbounded Template Tag Reflection Exposes Password Hashes and Reset Tokens via Email @Char0n1507 #1660
SSRF via unescaped report PDF date parameters Medium Server-Side Request Forgery (SSRF) via HTML Injection in Report PDF Generation @Char0n1507 #1661
SSRF via unrestricted SUMEX_URL scheme, plus residual XML control-char/attribute injection and PII exposure in Sumex.php Medium Path Traversal in generate_invoice_sumex() allows authenticated arbitrary file write via invoice_number, XML Attribute Injection via Unsanitized invoice_number in setAttribute in Sumex.php, XML Control Character Injection via nodeValue on Untrusted User Fields in Sumex.php, SSRF via unconstrained SUMEX_URL curl in Sumex::pdf(), SSRF Secundary finding (duplicate of the above, same file) @Char0n1507, @yeshwanthmoota #1663
Missing CSRF protection on bulk recalculate_all_invoices() / recalculate_all_quotes() endpoints Medium — Internal audit #1664
Unescaped logo filename in PDF invoice header (invoice_logo_pdf()) Low — Internal audit #1664
Log injection via unsanitized checkout_session_id in the Stripe guest payment callback Low — Internal audit #1664
Permanent account lockout: failed-login counter can never pass 10, so the 12-hour auto-unlock is unreachable dead code High Account lockout is permanent: the 12-hour auto-unlock is unreachable dead code @polybjorn #1668
Password-reset token expiry not enforced on the password-change POST (incomplete fix of GHSA-5r28-6rw3-25c2) High Incomplete fix for password reset token expiration: expiry not enforced on password change @baozongwi #1670, #1671
CSRF: generate_pdf marks a draft invoice/quote sent and assigns its number via GET Medium InvoicePlane GET /quotes/generate_pdf/:id marks draft quotes as sent and assigns quote numbers without CSRF protection, InvoicePlane GET /invoices/generate_pdf/:id marks draft invoices as sent and assigns invoice numbers without CSRF protection @ashrexon #1674
Privilege revocation failure: downgraded administrator retains administrative access via stale session High Privilege Revocation Failure: Downgraded Administrator Retains Administrative Access (Persistent Privilege Escalation) @0xMoError-22 #1681
PayPal order_id path injection: unvalidated order_id interpolated into the PayPal API request path in captureOrder()/showOrderDetails() Low Limited SSRF / Path Traversal in PayPal Gateway @Char0n1507 #1684
Recurring-invoice cron race condition (TOCTOU): concurrent cron runs could double-generate the same recurring invoice and duplicate the customer email Medium Race Condition in Recurring Invoice Cron @Char0n1507 #1684
No rate limiting on wrong cron_key attempts against the unauthenticated cron endpoint Medium Cron Key Weak PRNG and No Rate Limiting @Char0n1507 #1684
Login IP-based rate limiting was session-scoped, so omitting the session cookie reset the counter on every request High Session-Stored IP Rate Limiting Bypass @Char0n1507, @yeshwanthmoota #1684
Sessions::authenticate() was a public method, directly routable via a bare GET request (/sessions/sessions/authenticate/<email>/<password>), bypassing the login form, CSRF, and POST entirely High Unauthenticated Session Creation via Public Sessions::authenticate() Endpoint Leading to Full Admin Access, Unauthenticated Session Creation via Sessions::authenticate() Endpoint (duplicate) @Char0n1507, @yeshwanthmoota #1684
Upload::create_dir() was a public method with the same directly-routable shape as the authenticate() finding above — reachable at /upload/upload/create_dir/<path>/<chmod> with an attacker-controlled path and permission bits passed straight to mkdir() High — Internal audit (found while checking for other instances of GHSA-x2m4-962x-cphw's bug class) #1684
Stored XSS via unescaped client name in the quote-status HTML email (email_quote_status()), triggerable by a guest approving/rejecting their own quote; same function also forced display_errors/E_ALL on every call (CWE-209 info leak) Medium Stored XSS via Unescaped Client Name in Quote Status HTML Email @Char0n1507 #1684
Stored XSS in invoice/quote HTML emails: parse_template() (used by email_invoice()/email_quote(), the app's primary send path) substituted ~40 fields — client name/address/email, user company, custom field values — into the HTML email body with no escaping Medium — Internal audit (found while checking for other instances of GHSA-prmc-vrjx-5hxc's bug class) #1684

Security fixes

Ordered by severity, then CVSS, then PR number.

  • #1505, #1506 — RCE: replace directory_map() template whitelist with static constants + ipconfig.php allowlist; fix five open-redirect instances; add security_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_email with htmlsc() in the invoice/quote mailer views (to_email value attribute) — a follow-up sink missed by the earlier sweep — plus valid_email validation on client_email in Mdl_Clients as 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 when DISABLE_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_id index 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, default X-Frame-Options: SAMEORIGIN, session-fixation fix (SESS_REGENERATE_DESTROY=true), password-reset log-injection sanitization, and a Referrer-Policy header.
  • #1536 — Review follow-ups: entrypoint.sh config-injection guard, XSS trait fix, logo URL escaping, custom-template allowlisting, Mdl_reports cast 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 public uploads/ tree, instead of uploads/temp/. Thanks to @mattmumford-git and @Char0n1507 for responsible disclosure. #1620
  • Import CSV disclosure: added a Deny from all .htaccess to uploads/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 the user_type/required_key session check in Admin_Controller/Guest_Controller, removing a PHP type-juggling risk in the app's primary authorization checkpoint.
  • #1639 — Log injection: sanitize cron_key before logging in Cron::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 public ip_users columns, 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_id path injection, recurring-invoice cron race, public Sessions::authenticate() / Upload::create_dir(), cron-key brute force and session-scoped login rate limit, stored XSS in the quote-status email: order_id is 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() and create_dir() are private; wrong cron_key attempts 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 any SUMEX_URL that isn't https:// and restricts the cURL request/redirect protocols to HTTPS with certificate verification on, closing an admin-configuration SSRF into file://, internal hosts, or cloud metadata endpoints. XML control characters are stripped from free-text fields, and invoice_number / user_subscribernumber are restricted to a strict character allowlist before reaching setAttribute(), 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() and recalculate_all_quotes() were GET-reachable with no CSRF guard, unlike every other state-changing bulk action; both now require ensure_valid_post_request(), matching the pattern already used by delete() in the same controllers.
  • #1664 — Stored XSS (PDF): invoice_logo_pdf() interpolated the logo filename into <img src="..."> unescaped, unlike its sibling invoice_logo(); now wrapped in html_escape().
  • #1664 — Log injection: the Stripe guest callback logged the raw, unauthenticated checkout_session_id without sanitize_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's user_type and user_active status from the authoritative ip_users record 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 sets user_type from 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's json_encode() calls for the email-template compose modal now use JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT, matching the existing pattern in email_templates/views/form.php; Upload::delete_file() now uses the shared validate_file_in_directory() helper instead of an ad-hoc realpath() check; country_helper.php validates the $cldr locale segment against an allowlist pattern before using it in an include path; and the password-reset request no longer issues a token to a deactivated account (mirroring the login-time user_active check) 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, so log_count could never pass 10 - the unlock branch in _login_log_check(), gated on log_count > 10, was therefore unreachable dead code and locked-out accounts stayed locked forever, recoverable only via a manual database DELETE. Separately, that unlock branch measured elapsed time with DateInterval::$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 at log_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 the user_passwordreset_token_expiry column 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 the 043_1.7.2.sql migration (#1670). The expiry parse is further hardened to strict DateTime::createFromFormat('!Y-m-d H:i:s', …) with an anchored canonical-format guard (rejecting values such as 25:99:99 or 2026-8-10 9:05:07), the password-change POST now validates its CSRF token and uses the shared get_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> and quotes/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 new verify_get_csrf_token() compares an _ip_csrf query-string token against the CSRF cookie with hash_equals() (the query-string counterpart of the existing POST-only verify_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.

Added

  • 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), and generate_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_MINUTES env var (default 15) and SEC_STRIP_EXIF_FROM_IMAGES env var (default false).
  • Session env vars SESS_SAVE_PATH, SESS_TABLE_NAME, SESS_COOKIE_NAME, and SESS_REGENERATE_DESTROY, all documented in ipconfig.php.example. SESS_SAVE_PATH may 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, and CUSTOM_QUOTE_TEMPLATES_PUBLIC env vars. Built-in templates always appear in the selector; custom templates appear only if explicitly allowlisted. CUSTOM_TEMPLATES_FOLDER locates the corresponding .php file 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.php allowlist. This helps administrators copy selected legacy template names into CUSTOM_INVOICE_TEMPLATES_PDF, CUSTOM_INVOICE_TEMPLATES_PUBLIC, CUSTOM_QUOTE_TEMPLATES_PDF, or CUSTOM_QUOTE_TEMPLATES_PUBLIC after upgrading.
  • Referrer-Policy: strict-origin-when-cross-origin header sent on every admin response.

Changed

  • 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 actual bool result of the send instead of always returning true. Any integration that treated the return value as always-truthy must handle false as a delivery failure.

Fixed

  • #1426 — Remittance slip / QR-code remittance text (#1140).
  • #1449 — quote_templates/public showing 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 guest Get controller.
  • #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 with realpath(), which returns false for 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_name and sess_cookie_name incorrectly read from the SESS_DRIVER env var instead of their own SESS_TABLE_NAME / SESS_COOKIE_NAME variables, 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 to mb_strlen() / mb_substr() on raw binary ciphertext — this file's original byte-safe fix (noted above) was reverted by an automated Laravel Pint mb_str_functions rewrite during a routine composer update && pint pass. 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 triggering openssl_decrypt(): IV passed is … bytes long …, truncating — which broke SMTP authentication for affected users. Reverted to byte-safe strlen() / substr() and disabled mb_str_functions in pint.json so 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 unrelated substr() call in Mdl_templates.php's log-message truncation to mb_substr(); that one operates on a legitimate text string, not binary data, so it is not a security issue and was left as-is.)

Removed

  • 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).

Performance

  • #1483 — Batch settings save: reduce DB queries from ~70 to 3 (transaction-wrapped).
  • #1503 — Add database indexes on frequently queried columns.

Infrastructure & CI

  • #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: read in the phpunit.yml, quickstart.yml, and setup.yml workflows (CWE-272).

[1.7.0] - 2024

Added

  • PHP 8.2+ compatibility (minimum PHP 8.1 required; PHP 7.x no longer supported).
  • Updated all Composer and Yarn dependencies.

Security

  • 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.

Fixed

  • #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_einvoicing fields.
  • #1380 — Dependency: qs package 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.

Removed

  • PHP 7.x compatibility.
  • Deprecated library dependencies.

[1.6.4] and earlier

For changes in version 1.6.4 and earlier, please see the git commit history.


Security Disclosure

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.