CSRF 403 errors across AJAX filters, modal data loading, and record deletions

Hi everyone,

I’m running InvoicePlane v1.7.2 on PHP 8.3 with CSRF enabled in ipconfig.php :

  • CSRF_PROTECTION=true
  • CSRF_REGENERATE=false

Throughout normal use, multiple core operations fail with 403 Forbidden (“The action you have requested is not allowed” ):

  1. AJAX Filtering & Lookups: Dynamic table filtering and autocomplete/AJAX calls (filter/ajax/* , invoices/ajax/* , quotes/ajax/* ).
  2. Deletions across modules: Deleting draft invoices, quotes, clients, or payments from list view modals (invoices/delete/* , quotes/delete/* , payments/delete/* , clients/delete/* ).

Currently, the application only functions without throwing 403 errors if I either disable CSRF completely via CSRF_PROTECTION=false , or add a wide list of exceptions to config.php :

PHP$config['csrf_exclude_uris'] = array( 'filter/ajax/.*', 'invoices/ajax/.*', 'quotes/ajax/.*', 'invoices/delete', 'invoices/delete/.*', 'quotes/delete', 'quotes/delete/.*', 'payments/delete', 'payments/delete/.*', 'clients/delete', 'clients/delete/.*', );

Since the templates already include <?php _csrf_field(); ?> , it appears CodeIgniter’s CSRF token in the cookie frequently falls out of sync with AJAX calls and modal payloads.

Is there a standard global fix or core patch to keep CSRF enabled across all these modules without having to maintain an extensive whitelist in csrf_exclude_uris ?

Thanks!

1 Like

Hey,

You have something similar as issue #1694 and it’s resolved in PR fix(security): trust CodeIgniter's CSRF check only when it actually ran for the URI by nielsdrost7 · Pull Request #1784 · InvoicePlane/InvoicePlane · GitHub and a tiny bit in PR fix(csrf) by nielsdrost7 · Pull Request #1696 · InvoicePlane/InvoicePlane · GitHub

It’s available in the develop branch of the repository

Hello,

Thanks men for your help you put me on the right path.

If you are running InvoicePlane v1.7.2 on PHP 8.1/8.2/8.3 with CSRF_PROTECTION=true and run into 403 Forbidden errors across deletions, AJAX filters, or creation modals, along with tax display issues after disabling legacy calculation, here is the full resolution path based on PRs #1696, #1784, and #1818.

Problem 1: Record Deletions Triggering 403 Errors (Issue #1694)

Cause:

In Admin_Controller , the custom validation method re-verified the submitted CSRF token. Because CodeIgniter’s core security engine consumes or rotates the token on incoming POST requests before the controller action runs, the secondary check failed and blocked the deletion.

Fix:

Open application/core/Admin_Controller.php and update ensure_valid_post_request :slight_smile:

protected function ensure_valid_post_request(string $redirect_url): bool
{
    if ($this->input->method(true) !== 'POST') {
        $this->session->set_flashdata('alert_error', trans('invalid_request'));
        redirect($redirect_url);

        return false;
    }

    // Trust CodeIgniter's framework check if CSRF protection is active
    if ($this->config->item('csrf_protection')) {
        return true;
    }

    if (!function_exists('verify_csrf_token')) {
        $this->load->helper('security');
    }

    if (!verify_csrf_token()) {
        $this->session->set_flashdata('alert_error', trans('invalid_request'));
        redirect($redirect_url);

        return false;
    }

    return true;
}

Problem 2: Creation Modals & AJAX Filters Failing with 403 (PR #1818)

Cause:

The AJAX prefilter in scripts.js attempted to append &_ip_csrf= blindly. When values were not populated or already serialized, it appended &_ip_csrf=undefined or duplicate tokens, causing CodeIgniter to reject the request.

Fix:

In assets/core/js/scripts.js , replace the token getters and $.ajaxPrefilter block with a resilient helper:

// Robust CSRF extraction helpers
function get_csrf_token_name() {
    const meta = document.querySelector('meta[name="csrf_token_name"]');
    return meta ? meta.getAttribute('content') : '_ip_csrf';
}

function get_csrf_token_value() {
    const meta = document.querySelector('meta[name="csrf_token_value"]');
    if (meta && meta.getAttribute('content')) {
        return meta.getAttribute('content');
    }
    const cookieMatch = document.cookie.match(/ip_csrf_cookie=([^;]+)/);
    return cookieMatch ? decodeURIComponent(cookieMatch[1]) : '';
}

var csrf_token_name = get_csrf_token_name();
var csrf_token_value = get_csrf_token_value();

$(function () {
    // Automatic CSRF injection for all jQuery POST calls
    $.ajaxPrefilter(function (options, originalOptions, jqXHR) {
        if (options.type && options.type.toLowerCase() === 'post') {
            const tokenName = get_csrf_token_name();
            const tokenVal = get_csrf_token_value();

            if (!tokenVal || tokenVal === 'undefined') {
                return;
            }

            // Set the custom header
            jqXHR.setRequestHeader('X-' + tokenName, tokenVal);

            // Append safely to serialized query strings without duplicates
            if (typeof options.data === 'string') {
                const encodedName = encodeURIComponent(tokenName);
                if (options.data.indexOf(encodedName + '=') === -1 && options.data.indexOf(tokenName + '=') === -1) {
                    options.data += (options.data.length > 0 ? '&' : '') + encodedName + '=' + encodeURIComponent(tokenVal);
                }
            } else if ($.isPlainObject(options.data)) {
                if (!options.data[tokenName]) {
                    options.data[tokenName] = tokenVal;
                }
            }
        }
    });
    // ... remainder of scripts.js

Crucial Deployment Step:

  1. Copy the updated file to the minified asset:
cp assets/core/js/scripts.js assets/core/js/scripts.min.js

Purge Cloudflare / CDN cache and browser cache so browsers do not serve the stale cached scripts.min.js .

Problem 3: Tax Rate Dropping to “None” / 0.00 after Switching to LEGACY_CALCULATION=false

Cause:

Legacy calculations stored taxes globally at the invoice balance level without associating an explicit item_tax_rate_id to individual rows in ip_invoice_items . Under the modern calculation model, empty item tax fields display as “None” (0.00 ).

BACKUP DB FIRST

Database Migration Steps (phpMyAdmin / MySQL):

  1. Locate your tax rate ID (e.g., 20%):

SELECT tax_rate_id, tax_rate_name, tax_rate_percent FROM ip_tax_rates;

Assign the tax rate ID (tax_rate_id = 1 in this example) to untagged invoice item rows and recalculate item taxes:

UPDATE ip_invoice_items 
SET item_tax_rate_id = 1 
WHERE item_tax_rate_id IS NULL OR item_tax_rate_id = 0;

UPDATE ip_invoice_item_amounts 
SET item_tax_total = ROUND(item_subtotal * 0.20, 2)
WHERE item_subtotal > 0;

Synchronize global invoice totals in ip_invoice_amounts :

UPDATE ip_invoice_amounts ia
JOIN (
    SELECT 
        ii.invoice_id,
        SUM(iia.item_subtotal) AS calc_subtotal,
        SUM(iia.item_tax_total) AS calc_tax,
        SUM(iia.item_total) AS calc_total
    FROM ip_invoice_items ii
    JOIN ip_invoice_item_amounts iia ON iia.item_id = ii.item_id
    GROUP BY ii.invoice_id
) calc ON calc.invoice_id = ia.invoice_id
SET 
    ia.invoice_item_subtotal = calc.calc_subtotal,
    ia.invoice_item_tax_total = calc.calc_tax,
    ia.invoice_tax_total = calc.calc_tax,
    ia.invoice_total = calc.calc_subtotal + calc.calc_tax,
    ia.invoice_balance = (calc.calc_subtotal + calc.calc_tax) - ia.invoice_paid;

Problem 4: Final Configuration Cleanup

In ipconfig.php :

Ini, TOMLCSRF_PROTECTION=true CSRF_REGENERATE=false LEGACY_CALCULATION=false

In application/config/config.php :

PHP$config['csrf_exclude_uris'] = array();

Clear the server cache:

rm -rf application/cache/*

Everything (invoice deletion, quote creation modals, line-item VAT, and global invoice summaries) now functions reliably under strict CSRF protection without URI bypasses.